dsh-data-cleaning-agent 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -13,8 +13,9 @@
13
13
  * - 提示词生成器使用 `conversation.input.overlay`,只对本插件创建的会话生效。
14
14
  * Hero 标题没有公开替换槽位,故以可恢复、精确文本匹配的 DOM Bridge 对齐业务首页。
15
15
  *
16
- * 0.5.0 工作台复用本地 `/mvp/*` 与三域 `/phase3/*` 后端,完整覆盖上传映射、
17
- * 数据体检、匹配核验、补全导出;计费调用仍由 Host Bridge 的确认/幂等/上限门约束。
16
+ * v2 工作台把上传、规则、匹配、补全、下载正式绑定到 Host taskId 工作流;原始行只保留
17
+ * 在浏览器内按 taskId 隔离的 runtime 中,Host 仅持久化任务元数据、摘要与制品引用。
18
+ * 当前业务闭环使用基础企业 G5 Bridge;历史、人员、招投标域仍按产品决策延期。
18
19
  */
19
20
  window.__ModuleLoader__.load({
20
21
  id: 'dsh-data-cleaning-agent',
@@ -179,7 +180,7 @@ window.__ModuleLoader__.load({
179
180
 
180
181
  .dcAgentProductHome {
181
182
  width: 100%;
182
- max-width: var(--dsh-composer-card-max-width, 780px);
183
+ max-width: 980px;
183
184
  box-sizing: border-box;
184
185
  margin: 10px auto 0;
185
186
  padding: 16px 18px;
@@ -202,7 +203,7 @@ window.__ModuleLoader__.load({
202
203
  }
203
204
  .dcAgentHomeFlow {
204
205
  display: grid;
205
- grid-template-columns: repeat(4, minmax(0, 1fr));
206
+ grid-template-columns: repeat(7, minmax(0, 1fr));
206
207
  gap: 8px;
207
208
  }
208
209
  .dcAgentHomeStep {
@@ -675,6 +676,39 @@ window.__ModuleLoader__.load({
675
676
  line-height: 1.6;
676
677
  color: light-dark(#33415a, #c9d4e5);
677
678
  }
679
+ .dcAgentHomeHero { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
680
+ .dcAgentHomeHero p { margin: 5px 0 0; color: light-dark(#697386, #aab5c7); font-size: 12px; line-height: 18px; }
681
+ .dcAgentHomeEyebrow { display: block; margin-bottom: 4px; color: light-dark(#2869e6, #8cb3ff); font-size: 11px; font-weight: 700; letter-spacing: .08em; }
682
+ .dcAgentHomeActions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
683
+ .dcAgentHomeActions button,
684
+ .dcAgentHomeRecent button,
685
+ .dcAgentHistoryTask { border: 1px solid light-dark(#dce4f1, #344052); border-radius: 9px; background: light-dark(#fff, #1b222e); color: inherit; font: inherit; cursor: pointer; }
686
+ .dcAgentHomeActions button { padding: 6px 9px; font-size: 11px; }
687
+ .dcAgentHomeRecent { display: grid; gap: 6px; margin-top: 12px; }
688
+ .dcAgentHomeRecent > b { font-size: 12px; }
689
+ .dcAgentHomeRecent button { display: flex; justify-content: space-between; gap: 12px; padding: 7px 9px; text-align: left; font-size: 11px; }
690
+ .dcAgentHomeRecent small { color: light-dark(#697386, #aab5c7); }
691
+ .dcAgentWizardNav { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-bottom: 12px; }
692
+ .dcAgentWizardStep { display: flex; align-items: center; gap: 6px; padding: 7px; border: 1px solid light-dark(#d9dee8, #394352); border-radius: 9px; background: transparent; color: inherit; font: inherit; font-size: 11px; cursor: pointer; }
693
+ .dcAgentWizardStep b { display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; background: light-dark(#eef1f6, #252e3b); color: light-dark(#607088, #b9c6da); }
694
+ .dcAgentWizardStep.is-active { border-color: #2869e6; background: light-dark(#edf4ff, #172841); color: light-dark(#1556cf, #8cb3ff); }
695
+ .dcAgentWizardStep.is-active b { background: #2869e6; color: white; }
696
+ .dcAgentWizardPane { min-height: 230px; }
697
+ .dcAgentWizardPane h4 { margin: 0 0 3px; font-size: 14px; }
698
+ .dcAgentWizardPane > p { margin: 0 0 12px; color: light-dark(#697386, #aab5c7); font-size: 11px; line-height: 17px; }
699
+ .dcAgentPromptRules { display: grid; gap: 8px; margin-top: 14px; }
700
+ .dcAgentPromptRules label { font-size: 12px; }
701
+ .dcAgentPromptPreview { max-height: 260px; overflow: auto; margin: 10px 0 0; padding: 12px; border-radius: 10px; background: light-dark(#f6f8fb, #11171f); white-space: pre-wrap; word-break: break-word; font: inherit; font-size: 11px; line-height: 18px; }
702
+ .dcAgentFormField { display: grid; gap: 6px; font-size: 12px; font-weight: 600; }
703
+ .dcAgentMappingRow { display: grid; grid-template-columns: minmax(0, 1fr) 24px minmax(180px, 1fr); align-items: center; gap: 8px; margin-top: 7px; font-size: 12px; }
704
+ .dcAgentMappingRow > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
705
+ .dcAgentMappingRow > b { text-align: center; color: light-dark(#8792a3, #8290a5); }
706
+ .dcAgentRulesGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
707
+ .dcAgentFieldGroup { display: grid; gap: 7px; margin-top: 12px; }
708
+ .dcAgentFieldGroup > b { font-size: 12px; }
709
+ .dcAgentPreviewTable { margin-top: 12px; overflow: auto; border: 1px solid light-dark(#e4e8f0, #303947); border-radius: 10px; }
710
+ .dcAgentHistoryTask { display: block; width: 100%; padding: 10px; text-align: left; }
711
+ .dcAgentHistoryTask h3 { margin: 0; font-size: 12px; }
678
712
  @media (max-width: 760px) {
679
713
  .dcAgentWorkbench, .dcAgentWorkbench.is-expanded { width: 100vw; border-radius: 0; }
680
714
  .dcAgentOverlay { background: rgba(8, 10, 14, 0.22); pointer-events: auto; }
@@ -685,6 +719,11 @@ window.__ModuleLoader__.load({
685
719
  .dcAgentCapability { min-width: 92px; }
686
720
  .dcAgentPromptPanel { left: 8px; width: calc(100vw - 88px); max-height: 72vh; }
687
721
  .dcAgentHomeFlow { grid-template-columns: repeat(2, minmax(0, 1fr)); }
722
+ .dcAgentHomeHero { align-items: flex-start; flex-direction: column; }
723
+ .dcAgentWizardNav { grid-template-columns: repeat(2, 1fr); }
724
+ .dcAgentRulesGrid { grid-template-columns: 1fr; }
725
+ .dcAgentMappingRow { grid-template-columns: 1fr; }
726
+ .dcAgentMappingRow > b { display: none; }
688
727
  }
689
728
  `;
690
729
 
@@ -698,46 +737,89 @@ window.__ModuleLoader__.load({
698
737
  return () => { style.remove(); };
699
738
  }
700
739
 
701
- // 会话级数据(不进 store,避免大快照膨胀)。原始行仅用于后端往返,不进模型上下文。
702
- let session = { rows: [], headers: [] };
703
- let lastCsv = { clean: null, complete: null, qcc: null, review: null };
740
+ // 原始数据不进入 Host storageDomain 或模型上下文;只在当前页面按 taskId 隔离。
741
+ const runtimeTasks = new Map();
742
+ const workflowTaskBySession = new Map();
743
+ const workflowTaskCreationBySession = new Map();
744
+ const workflowOperationBySession = new Map();
745
+
746
+ function runtimeFor(taskId, create = true) {
747
+ const key = String(taskId || 'unassigned');
748
+ if (!runtimeTasks.has(key) && create) {
749
+ runtimeTasks.set(key, {
750
+ rows: [],
751
+ headers: [],
752
+ source: null,
753
+ lastCsv: { clean: null, complete: null, qcc: null, review: null },
754
+ resultRows: { clean: null, complete: null, qcc: null },
755
+ });
756
+ }
757
+ return runtimeTasks.get(key) ?? null;
758
+ }
759
+
760
+ function moveRuntime(fromId, toId) {
761
+ const from = runtimeFor(fromId, false);
762
+ if (!from || !toId || String(fromId) === String(toId)) return runtimeFor(toId);
763
+ const target = runtimeFor(toId);
764
+ target.rows = from.rows;
765
+ target.headers = from.headers;
766
+ target.source = from.source;
767
+ target.lastCsv = from.lastCsv;
768
+ target.resultRows = from.resultRows;
769
+ runtimeTasks.delete(String(fromId || 'unassigned'));
770
+ return target;
771
+ }
704
772
 
705
773
  const STEPS = [
706
- { key: 'upload', label: '上传与映射', icon: '📄' },
707
- { key: 'profile', label: '数据体检', icon: '🩺' },
708
- { key: 'review', label: '匹配核验', icon: '🔎' },
709
- { key: 'enrich', label: '补全与导出', icon: '⬇️' },
774
+ { key: 'upload', label: '上传数据', icon: '📄' },
775
+ { key: 'rules', label: '规则确认', icon: '⚙' },
776
+ { key: 'match', label: '数据匹配', icon: '🔎' },
777
+ { key: 'enrich', label: '清洗补全', icon: '▦' },
778
+ { key: 'download', label: '下载数据', icon: '⬇️' },
710
779
  ];
711
780
 
712
781
  const CAPABILITIES = [
713
782
  { key: 'upload', label: '上传清洗', icon: IconPaperclipOutline16, fallback: '+' },
714
783
  { key: 'profile', label: '质量体检', icon: IconChecklistOutline14, fallback: '✓' },
715
- { key: 'review', label: '匹配核验', icon: IconSearchOutline16, fallback: '⌕' },
784
+ { key: 'match', label: '匹配核验', icon: IconSearchOutline16, fallback: '⌕' },
716
785
  { key: 'enrich', label: '字段补全', icon: IconDataOutline16, fallback: '▦' },
717
786
  { key: 'history', label: '任务历史', icon: IconArchiveOutline20, fallback: '◷' },
718
787
  ];
719
788
 
720
789
  const CLEANING_OPTIONS = [
721
- ['normalize_name', '规范企业名称'],
722
- ['complete_name', '补全 / 修正旧名称'],
723
- ['verify_credit', '校验 / 补全信用代码'],
790
+ ['clean_name', '名称补全与规范'],
724
791
  ['deduplicate', '重复企业去重'],
725
- ['manual_review', '模糊候选人工确认'],
792
+ ['validate_identity', '主体标识校验'],
793
+ ['complete_fields', '补全所选企业字段'],
726
794
  ];
727
- const ENRICHMENT_OPTIONS = [
728
- ['credit_no', '统一社会信用代码'],
729
- ['legal_rep', '法定代表人'],
730
- ['reg_capital', '注册资本'],
731
- ['establish_date', '成立日期'],
732
- ['reg_status', '登记 / 经营状态'],
733
- ['panorama', '企业全景与联系方式'],
734
- ['ownership', '股权与对外投资'],
735
- ['governance', '主要人员与变更年报'],
736
- ['risk', '风险信息'],
737
- ['ipr', '知识产权'],
738
- ['operation', '经营信息'],
795
+ const FIELD_GROUPS = [
796
+ ['identity', '基础工商信息', [
797
+ ['company_name', '企业名称'], ['credit_no', '统一社会信用代码'], ['reg_no', '注册号'],
798
+ ['reg_status', '登记状态'], ['legal_rep', '法定代表人'], ['reg_capital', '注册资本'],
799
+ ['paid_capital', '实缴资本'], ['establish_date', '成立日期'], ['company_type', '企业类型'],
800
+ ['registration_authority', '登记机关'], ['former_name', '曾用名'], ['english_name', '英文名'],
801
+ ]],
802
+ ['contact', '地址与联系方式', [
803
+ ['registered_address', '注册地址'], ['province', '省份地区'], ['city', '城市'],
804
+ ['district', '区县'], ['phone', '电话'], ['email', '邮箱'], ['website', '官网'],
805
+ ]],
806
+ ['operation', '经营信息', [
807
+ ['business_scope', '经营范围'], ['industry_category', '国标行业'], ['industry_large', '一级行业'],
808
+ ['industry_middle', '二级行业'], ['operating_period', '营业期限'], ['company_size', '企业规模'],
809
+ ['company_profile', '企业简介'],
810
+ ]],
811
+ ['risk', '风险摘要', [
812
+ ['risk_summary', '风险摘要'], ['operating_exception', '经营异常摘要'], ['administrative_penalty', '行政处罚摘要'],
813
+ ]],
814
+ ['ipr', '知识产权摘要', [
815
+ ['trademark_summary', '商标摘要'], ['patent_summary', '专利摘要'], ['software_copyright_summary', '软件著作权摘要'],
816
+ ]],
739
817
  ];
740
- const DEFAULT_CLEANING_KEYS = ['normalize_name', 'complete_name', 'verify_credit', 'deduplicate', 'manual_review'];
818
+ const ENRICHMENT_OPTIONS = FIELD_GROUPS.flatMap(([, , fields]) => fields);
819
+ const MATCH_ANCHOR_OPTIONS = [
820
+ ['company_name', '企业名称'], ['credit_no', '统一社会信用代码'], ['reg_no', '注册号'],
821
+ ];
822
+ const DEFAULT_CLEANING_KEYS = ['clean_name', 'deduplicate', 'validate_identity', 'complete_fields'];
741
823
  const DEFAULT_ENRICHMENT_KEYS = ['credit_no', 'legal_rep', 'reg_capital', 'establish_date', 'reg_status'];
742
824
  const DEFAULT_SESSION_PROMPT = '请帮我清洗并补全企业名单。可点击输入框左上角「提示词生成」录入名单、上传 Excel 或图片,也可直接修改本段任务说明后开始。';
743
825
  const CLEANING_SESSION_STORAGE_KEY = 'dsh.data-cleaning-agent.sessions.v1';
@@ -796,6 +878,7 @@ window.__ModuleLoader__.load({
796
878
  const mode = config.mode ?? 'text';
797
879
  const cleanLabels = optionLabels(CLEANING_OPTIONS, config.cleaningKeys);
798
880
  const enrichLabels = optionLabels(ENRICHMENT_OPTIONS, config.enrichmentKeys);
881
+ const anchorLabels = optionLabels(MATCH_ANCHOR_OPTIONS, config.anchorKeys);
799
882
  const entries = Array.isArray(config.entries)
800
883
  ? config.entries.map((value) => String(value ?? '').trim()).filter(Boolean)
801
884
  : [];
@@ -817,23 +900,176 @@ window.__ModuleLoader__.load({
817
900
  }
818
901
  lines.push(`清洗要求:${cleanLabels.length ? cleanLabels.join('、') : '仅解析,不自动修改'}。`);
819
902
  lines.push(`需要补全的字段 / 维度:${enrichLabels.length ? enrichLabels.join('、') : '不调用外部补全,仅输出本地清洗结果'}。`);
820
- lines.push('处理规则:企业全称、统一社会信用代码或注册号优先精确匹配;精确匹配失败时再进入模糊候选,存在多个候选必须暂停并让我确认,不得默认选择第一项。');
903
+ lines.push(`处理规则:优先使用${anchorLabels.length ? anchorLabels.join('、') : '企业名称、统一社会信用代码或注册号'}精确匹配;${config.matchRules?.manualReviewAmbiguous === false ? '精确匹配失败时直接输出未匹配记录' : '精确匹配失败时再进入模糊候选,存在多个候选必须暂停并让我确认,不得默认选择第一项'}。`);
821
904
  lines.push('请先生成数据质量体检与匹配摘要,经我确认后再执行需要消耗额度的企查查 MCP 调用;企查查连接、套餐额度和费用均由当前用户自己的账号承担。缺失或无权限字段请留空并标记原因,不得编造。');
822
905
  lines.push('完成后保留来源原值、标准主体、匹配状态与字段来源,并提供结果和待复核清单的导出。');
823
906
  return lines.join('\n\n');
824
907
  }
825
908
 
826
- /** 后端往返:POST JSON,返回解析后的对象。 */
827
- async function api(path, body) {
909
+ function entriesToDataset(entries) {
910
+ // 文本向导允许企业名称和信用代码混输。统一为一个检索锚点列,避免 G5 Bridge
911
+ // 只能选择单一 nameField 时遗漏信用代码行;QCC entity lookup 对两者均可检索。
912
+ const rows = (Array.isArray(entries) ? entries : [])
913
+ .map((value) => ({ 主体标识: String(value ?? '').trim() }))
914
+ .filter((row) => row.主体标识);
915
+ return {
916
+ ok: true,
917
+ fmt: 'text',
918
+ headers: ['主体标识'],
919
+ rowCount: rows.length,
920
+ rows,
921
+ preview: rows.slice(0, 5),
922
+ };
923
+ }
924
+
925
+ const MAPPING_PATTERNS = [
926
+ ['company_name', /^(name|company|company_name|企业名称|公司名称|单位名称)$/i],
927
+ ['credit_no', /^(credit_no|creditCode|unified_credit_code|统一社会信用代码|信用代码)$/i],
928
+ ['reg_no', /^(reg_no|registration_no|注册号)$/i],
929
+ ['phone', /^(phone|mobile|tel|telephone|联系电话|手机号码|手机号)$/i],
930
+ ['province', /^(province|省份|省份地区)$/i],
931
+ ['registered_address', /^(address|registered_address|注册地址|企业地址|公司地址)$/i],
932
+ ];
933
+
934
+ function guessMappings(headers) {
935
+ const output = [];
936
+ const usedTargets = new Set();
937
+ for (const sourceField of Array.isArray(headers) ? headers : []) {
938
+ const match = MAPPING_PATTERNS.find(([targetField, pattern]) => !usedTargets.has(targetField) && pattern.test(String(sourceField)));
939
+ if (match) {
940
+ output.push({ sourceField: String(sourceField), targetField: match[0] });
941
+ usedTargets.add(match[0]);
942
+ }
943
+ }
944
+ if (!output.length && Array.isArray(headers) && headers[0]) {
945
+ output.push({ sourceField: String(headers[0]), targetField: 'company_name' });
946
+ }
947
+ return output;
948
+ }
949
+
950
+ function qualitySummaryFor(rows, mappings) {
951
+ const list = Array.isArray(rows) ? rows : [];
952
+ const anchors = (Array.isArray(mappings) ? mappings : []).filter((mapping) => ['company_name', 'credit_no', 'reg_no'].includes(mapping.targetField));
953
+ const signatures = new Set();
954
+ let missingAnchor = 0;
955
+ let duplicates = 0;
956
+ let invalidCreditNo = 0;
957
+ let invalidPhone = 0;
958
+ let emptyFields = 0;
959
+ const creditSource = mappings?.find((mapping) => mapping.targetField === 'credit_no')?.sourceField;
960
+ const phoneSource = mappings?.find((mapping) => mapping.targetField === 'phone')?.sourceField;
961
+ for (const row of list) {
962
+ const values = anchors.map((mapping) => String(row?.[mapping.sourceField] ?? '').trim()).filter(Boolean);
963
+ if (!values.length) missingAnchor += 1;
964
+ const signature = values.join('|').toUpperCase();
965
+ if (signature && signatures.has(signature)) duplicates += 1;
966
+ if (signature) signatures.add(signature);
967
+ const credit = creditSource ? String(row?.[creditSource] ?? '').trim() : '';
968
+ if (credit && !/^[0-9A-Z]{18}$/.test(credit)) invalidCreditNo += 1;
969
+ const phone = phoneSource ? String(row?.[phoneSource] ?? '').replace(/[\s-]/g, '') : '';
970
+ if (phone && !/^(?:\+?86)?(?:1\d{10}|0\d{9,11})$/.test(phone)) invalidPhone += 1;
971
+ emptyFields += Object.values(row ?? {}).filter((value) => String(value ?? '').trim() === '').length;
972
+ }
973
+ return {
974
+ total: list.length,
975
+ valid: Math.max(0, list.length - missingAnchor),
976
+ missingAnchor,
977
+ duplicates,
978
+ invalidCreditNo,
979
+ invalidPhone,
980
+ emptyFields,
981
+ };
982
+ }
983
+
984
+ /** 同源 Host API;保留旧 api(path, body) 调用,同时为工作流开放 PATCH。 */
985
+ async function requestJson(path, method = 'GET', body) {
828
986
  const res = await fetch(path, {
829
- method: body ? 'POST' : 'GET',
830
- headers: body ? { 'content-type': 'application/json' } : undefined,
831
- body: body ? JSON.stringify(body) : undefined,
987
+ method,
988
+ headers: body !== undefined ? { 'content-type': 'application/json' } : undefined,
989
+ body: body !== undefined ? JSON.stringify(body) : undefined,
832
990
  });
833
- return res.json();
991
+ const payload = await res.json();
992
+ if (res.ok === false || payload?.ok === false) {
993
+ const error = new Error(payload?.message || payload?.error || `Host request failed (${res.status})`);
994
+ error.code = payload?.code;
995
+ throw error;
996
+ }
997
+ return payload;
998
+ }
999
+
1000
+ async function api(path, body) {
1001
+ return requestJson(path, body === undefined ? 'GET' : 'POST', body);
1002
+ }
1003
+
1004
+ function cacheWorkflowTask(actions, sessionId, task) {
1005
+ if (!task) return null;
1006
+ if (sessionId) workflowTaskBySession.set(String(sessionId), task);
1007
+ actions.setWorkflowTask(task);
1008
+ if (task.title) actions.setTaskTitle(task.title);
1009
+ if (Array.isArray(task.objectives)) actions.setObjectives(task.objectives);
1010
+ if (Array.isArray(task.fieldSelection)) actions.setFieldSelection(task.fieldSelection);
1011
+ if (Array.isArray(task.mappings) && task.mappings.length) actions.setMappings(task.mappings);
1012
+ return task;
1013
+ }
1014
+
1015
+ async function ensureWorkflowTask(actions, sessionId, draft = {}) {
1016
+ const key = String(sessionId || 'unassigned');
1017
+ const cached = workflowTaskBySession.get(key);
1018
+ if (cached) return cacheWorkflowTask(actions, sessionId, cached);
1019
+ let pending = workflowTaskCreationBySession.get(key);
1020
+ if (!pending) {
1021
+ pending = requestJson('/data-cleaning/api/workflow/tasks', 'POST', {
1022
+ title: draft.title || '企业数据清洗补全任务',
1023
+ objectives: draft.objectives || DEFAULT_CLEANING_KEYS,
1024
+ fieldSelection: draft.fieldSelection || DEFAULT_ENRICHMENT_KEYS,
1025
+ mappings: draft.mappings || [],
1026
+ matchRules: draft.matchRules,
1027
+ });
1028
+ workflowTaskCreationBySession.set(key, pending);
1029
+ const clearPending = () => {
1030
+ if (workflowTaskCreationBySession.get(key) === pending) workflowTaskCreationBySession.delete(key);
1031
+ };
1032
+ pending.then(clearPending, clearPending);
1033
+ }
1034
+ const response = await pending;
1035
+ moveRuntime(`session:${key}`, response.task.id);
1036
+ return cacheWorkflowTask(actions, sessionId, response.task);
834
1037
  }
835
1038
 
836
- /** 工作台 store:入口按钮与工作台共享 open,另存四步流程 UI 状态。 */
1039
+ /** 同一会话的 Host 写操作串行化,确保 expectedRevision 不因 UI 事件竞态失效。 */
1040
+ function queueWorkflowOperation(sessionId, operation) {
1041
+ const key = String(sessionId || 'unassigned');
1042
+ const previous = workflowOperationBySession.get(key) ?? Promise.resolve();
1043
+ const next = previous.catch(() => {}).then(operation);
1044
+ workflowOperationBySession.set(key, next);
1045
+ const clear = () => {
1046
+ if (workflowOperationBySession.get(key) === next) workflowOperationBySession.delete(key);
1047
+ };
1048
+ next.then(clear, clear);
1049
+ return next;
1050
+ }
1051
+
1052
+ async function updateWorkflowTask(actions, sessionId, task, input) {
1053
+ const latest = workflowTaskBySession.get(String(sessionId || 'unassigned')) ?? task;
1054
+ if (!latest) return ensureWorkflowTask(actions, sessionId, input);
1055
+ const response = await requestJson(`/data-cleaning/api/workflow/tasks/${encodeURIComponent(latest.id)}`, 'PATCH', {
1056
+ ...input,
1057
+ expectedRevision: latest.revision,
1058
+ });
1059
+ return cacheWorkflowTask(actions, sessionId, response.task);
1060
+ }
1061
+
1062
+ async function workflowAction(actions, sessionId, task, action, input = {}) {
1063
+ const latest = workflowTaskBySession.get(String(sessionId || 'unassigned')) ?? task;
1064
+ if (!latest) throw new Error('请先创建数据清洗补全任务。');
1065
+ const response = await requestJson(`/data-cleaning/api/workflow/tasks/${encodeURIComponent(latest.id)}/actions/${action}`, 'POST', {
1066
+ ...input,
1067
+ expectedRevision: latest.revision,
1068
+ });
1069
+ return cacheWorkflowTask(actions, sessionId, response.task);
1070
+ }
1071
+
1072
+ /** 工作台 store:入口按钮与工作台共享 open,并保存当前 taskId 的可持久化 UI 元数据。 */
837
1073
  function createWorkbenchStore() {
838
1074
  return defineStore({
839
1075
  init: () => ({
@@ -843,6 +1079,14 @@ window.__ModuleLoader__.load({
843
1079
  busy: false,
844
1080
  error: null,
845
1081
  input: '',
1082
+ workflowContract: null,
1083
+ workflowTask: null,
1084
+ workflowTasks: [],
1085
+ taskTitle: '企业数据清洗补全任务',
1086
+ mappings: [],
1087
+ objectives: [...DEFAULT_CLEANING_KEYS],
1088
+ fieldSelection: [...DEFAULT_ENRICHMENT_KEYS],
1089
+ matchRules: { normalizeNames: true, preferCreditNo: true, deduplicate: true, manualReviewAmbiguous: true },
846
1090
  dataset: null, // { fmt, headers, rowCount, preview }
847
1091
  profile: null, // summary
848
1092
  clean: null, // summary
@@ -870,6 +1114,46 @@ window.__ModuleLoader__.load({
870
1114
  setBusy: (draft, busy) => { draft.busy = busy; },
871
1115
  setError: (draft, error) => { draft.error = error; },
872
1116
  setInput: (draft, input) => { draft.input = input; },
1117
+ setWorkflowContract: (draft, value) => { draft.workflowContract = value; },
1118
+ setWorkflowTask: (draft, value) => { draft.workflowTask = value ?? null; },
1119
+ setWorkflowTasks: (draft, value) => { draft.workflowTasks = Array.isArray(value) ? value : []; },
1120
+ setTaskTitle: (draft, value) => { draft.taskTitle = String(value ?? ''); },
1121
+ setMappings: (draft, value) => { draft.mappings = Array.isArray(value) ? value : []; },
1122
+ setMapping: (draft, sourceField, targetField) => {
1123
+ const mappings = draft.mappings.filter((item) => item.sourceField !== sourceField && item.targetField !== targetField);
1124
+ if (targetField) mappings.push({ sourceField, targetField });
1125
+ draft.mappings = mappings;
1126
+ },
1127
+ setObjectives: (draft, value) => { draft.objectives = Array.isArray(value) ? value : []; },
1128
+ toggleObjective: (draft, value) => {
1129
+ const selected = new Set(draft.objectives);
1130
+ if (selected.has(value)) selected.delete(value); else selected.add(value);
1131
+ draft.objectives = [...selected];
1132
+ },
1133
+ setFieldSelection: (draft, value) => { draft.fieldSelection = Array.isArray(value) ? value : []; },
1134
+ toggleField: (draft, value) => {
1135
+ const selected = new Set(draft.fieldSelection);
1136
+ if (selected.has(value)) selected.delete(value); else selected.add(value);
1137
+ draft.fieldSelection = [...selected];
1138
+ },
1139
+ setMatchRule: (draft, key, value) => { draft.matchRules = { ...draft.matchRules, [key]: Boolean(value) }; },
1140
+ resetWorkflow: (draft) => {
1141
+ draft.step = 'upload';
1142
+ draft.workflowTask = null;
1143
+ draft.taskTitle = '企业数据清洗补全任务';
1144
+ draft.mappings = [];
1145
+ draft.objectives = [...DEFAULT_CLEANING_KEYS];
1146
+ draft.fieldSelection = [...DEFAULT_ENRICHMENT_KEYS];
1147
+ draft.matchRules = { normalizeNames: true, preferCreditNo: true, deduplicate: true, manualReviewAmbiguous: true };
1148
+ draft.dataset = null;
1149
+ draft.profile = null;
1150
+ draft.clean = null;
1151
+ draft.complete = null;
1152
+ draft.qccEstimate = null;
1153
+ draft.qccRun = null;
1154
+ draft.paidConfirmed = false;
1155
+ draft.error = null;
1156
+ },
873
1157
  setDataset: (draft, dataset) => { draft.dataset = dataset; draft.error = null; },
874
1158
  setProfile: (draft, profile) => { draft.profile = profile; draft.error = null; },
875
1159
  setClean: (draft, clean) => { draft.clean = clean; draft.error = null; },
@@ -980,38 +1264,55 @@ window.__ModuleLoader__.load({
980
1264
 
981
1265
  const WORKBENCH_OPEN_EVENT = 'dsh:data-cleaning-workbench-open';
982
1266
  const WORKBENCH_DATASET_EVENT = 'dsh:data-cleaning-workbench-dataset';
1267
+ const WORKBENCH_DRAFT_EVENT = 'dsh:data-cleaning-workbench-draft';
983
1268
 
984
1269
  // session scope 不能复用 root scope 的 store handle。DSH 当前会分别物化 root/session
985
1270
  // 插槽组件,因此以 document 事件跨 scope 通知;闭包引用仅作为无 DOM 测试降级。
986
1271
  let rootWorkbenchActions = null;
987
1272
 
988
1273
  /** 从 session scope 的 composer/header 触发 root scope 工作台。 */
989
- function requestWorkbenchOpen(step, sessionId) {
1274
+ function requestWorkbenchOpen(step, sessionId, task = null) {
990
1275
  if (typeof document !== 'undefined'
991
1276
  && typeof document.createEvent === 'function'
992
1277
  && typeof document.dispatchEvent === 'function') {
993
1278
  const event = document.createEvent('CustomEvent');
994
- event.initCustomEvent(WORKBENCH_OPEN_EVENT, false, true, { step, sessionId });
1279
+ event.initCustomEvent(WORKBENCH_OPEN_EVENT, false, true, { step, sessionId, task });
995
1280
  if (!document.dispatchEvent(event)) return;
996
1281
  }
997
1282
  if (rootWorkbenchActions) openWorkbench(rootWorkbenchActions, step, sessionId);
998
1283
  }
999
1284
 
1000
1285
  /** 把提示词生成器解析出的完整表格交给 root 工作台,不把整表塞进模型上下文。 */
1001
- function requestWorkbenchDataset(result, sessionId) {
1286
+ function requestWorkbenchDataset(result, sessionId, source) {
1002
1287
  if (typeof document !== 'undefined'
1003
1288
  && typeof document.createEvent === 'function'
1004
1289
  && typeof document.dispatchEvent === 'function') {
1005
1290
  const event = document.createEvent('CustomEvent');
1006
- event.initCustomEvent(WORKBENCH_DATASET_EVENT, false, true, { result, sessionId });
1291
+ event.initCustomEvent(WORKBENCH_DATASET_EVENT, false, true, { result, sessionId, source });
1007
1292
  if (!document.dispatchEvent(event)) return;
1008
1293
  }
1009
1294
  if (rootWorkbenchActions) {
1010
- applyParsed(result, rootWorkbenchActions);
1295
+ applyParsed(result, rootWorkbenchActions, `session:${sessionId || 'unassigned'}`, source);
1011
1296
  rootWorkbenchActions.setActiveSession(sessionId);
1012
1297
  }
1013
1298
  }
1014
1299
 
1300
+ /** 提示词向导只提交可持久化的任务设置;名单明细仍通过 dataset 事件进入 task runtime。 */
1301
+ function requestWorkbenchDraft(draft, sessionId) {
1302
+ if (typeof document !== 'undefined'
1303
+ && typeof document.createEvent === 'function'
1304
+ && typeof document.dispatchEvent === 'function') {
1305
+ const event = document.createEvent('CustomEvent');
1306
+ event.initCustomEvent(WORKBENCH_DRAFT_EVENT, false, true, { draft, sessionId });
1307
+ if (!document.dispatchEvent(event)) return;
1308
+ }
1309
+ if (rootWorkbenchActions) {
1310
+ rootWorkbenchActions.setTaskTitle(draft.title);
1311
+ rootWorkbenchActions.setObjectives(draft.objectives);
1312
+ rootWorkbenchActions.setFieldSelection(draft.fieldSelection);
1313
+ }
1314
+ }
1315
+
1015
1316
  /** 左栏入口按钮:公开 footer 托管,真实按钮 Portal 到「新会话 / 工作区」之间。 */
1016
1317
  function SidebarEntry(props) {
1017
1318
  const { wide, useStore, actions, startSession } = props;
@@ -1083,16 +1384,41 @@ window.__ModuleLoader__.load({
1083
1384
  );
1084
1385
  }
1085
1386
 
1086
- function ProductHome() {
1387
+ const WORKFLOW_STATE_LABELS = {
1388
+ draft: '待上传', uploaded: '待确认规则', rules_confirmed: '规则已确认', diagnosed: '体检完成',
1389
+ matching: '匹配中', review_required: '待人工核验', matched: '匹配完成', enriching: '补全中',
1390
+ export_ready: '可下载', completed: '已完成', partial: '部分完成', failed: '失败',
1391
+ authorization_required: '待连接企查查', cancelled: '已取消', parse_failed: '解析失败',
1392
+ };
1393
+
1394
+ function ProductHome(props = {}) {
1395
+ const { sessionId } = props;
1396
+ const [recentTasks, setRecentTasks] = react.useState([]);
1087
1397
  const flow = [
1088
- ['01', '录入名单', '文本、Excel 或图片'],
1089
- ['02', '质量体检', '缺失、重复与格式诊断'],
1090
- ['03', '主体匹配', '精确优先,多候选复核'],
1091
- ['04', '补全导出', '按需选字段,保留来源'],
1398
+ ['01', '任务设置', '提示词向导明确目标'],
1399
+ ['02', '上传数据', '文本、Excel、JSON 或图片'],
1400
+ ['03', '规则确认', '字段映射与匹配主键'],
1401
+ ['04', '质量体检', '缺失、重复与格式诊断'],
1402
+ ['05', '数据匹配', '精确优先,多候选复核'],
1403
+ ['06', '清洗补全', '按需选择当前企业字段'],
1404
+ ['07', '下载数据', '保留原值、依据与状态'],
1092
1405
  ];
1406
+ react.useEffect(() => {
1407
+ let disposed = false;
1408
+ requestJson('/data-cleaning/api/workflow/tasks').then((response) => {
1409
+ if (!disposed) setRecentTasks((response.tasks || []).slice(0, 3));
1410
+ }).catch(() => {});
1411
+ return () => { disposed = true; };
1412
+ }, [sessionId]);
1093
1413
  return h('section', { className: 'dcAgentProductHome', 'aria-label': '数据清洗补全产品介绍' },
1094
- h('h2', null, '把企业名单变成可核验、可回写的标准数据'),
1095
- h('p', null, '面向销售线索、客户尽调、供应商管理与 CRM / ERP 数据治理,先本地体检清洗,再按需使用当前用户连接的企查查 MCP 补全。'),
1414
+ h('div', { className: 'dcAgentHomeHero' },
1415
+ h('div', null,
1416
+ h('span', { className: 'dcAgentHomeEyebrow' }, '企业主数据治理工作台'),
1417
+ h('h2', null, '把企业名单变成可核验、可回写的标准数据'),
1418
+ h('p', null, '面向销售线索、客户尽调、供应商管理与 CRM / ERP 数据治理,先确认任务和规则,再按需使用当前用户连接的企查查 MCP。'),
1419
+ ),
1420
+ h('button', { type: 'button', className: 'dcAgentButton is-primary', onClick: () => requestWorkbenchOpen('upload', sessionId) }, '开始新任务'),
1421
+ ),
1096
1422
  h('div', { className: 'dcAgentHomeFlow', 'aria-label': '数据清洗补全工作流' },
1097
1423
  flow.map(([index, title, hint]) => h('div', { className: 'dcAgentHomeStep', key: index },
1098
1424
  h('b', null, index),
@@ -1106,6 +1432,22 @@ window.__ModuleLoader__.load({
1106
1432
  h('span', null, '客户自带企查查连接与额度'),
1107
1433
  h('span', null, '结果与待复核清单可导出'),
1108
1434
  ),
1435
+ h('div', { className: 'dcAgentHomeActions' },
1436
+ h('button', { type: 'button', onClick: () => requestWorkbenchOpen('profile', sessionId) }, '🩺 查看质量体检'),
1437
+ h('button', { type: 'button', onClick: () => requestWorkbenchOpen('match', sessionId) }, '🔎 进入匹配核验'),
1438
+ h('button', { type: 'button', onClick: () => requestWorkbenchOpen('history', sessionId) }, '◷ 查看任务历史'),
1439
+ ),
1440
+ recentTasks.length ? h('div', { className: 'dcAgentHomeRecent', 'aria-label': '最近任务' },
1441
+ h('b', null, '最近任务'),
1442
+ recentTasks.map((task) => h('button', {
1443
+ key: task.id,
1444
+ type: 'button',
1445
+ onClick: () => requestWorkbenchOpen(task.stage || 'upload', sessionId, task),
1446
+ },
1447
+ h('span', null, task.title || '未命名任务'),
1448
+ h('small', null, WORKFLOW_STATE_LABELS[task.state] || task.state),
1449
+ )),
1450
+ ) : null,
1109
1451
  );
1110
1452
  }
1111
1453
 
@@ -1161,8 +1503,8 @@ window.__ModuleLoader__.load({
1161
1503
  className: `dcAgentExperience${hero ? ' is-home' : ''}`,
1162
1504
  'data-session-id': sessionId,
1163
1505
  },
1164
- CapabilityBar({ sessionId }),
1165
- hero ? ProductHome() : null,
1506
+ h(CapabilityBar, { sessionId }),
1507
+ hero ? h(ProductHome, { sessionId }) : null,
1166
1508
  );
1167
1509
  }
1168
1510
 
@@ -1186,6 +1528,7 @@ window.__ModuleLoader__.load({
1186
1528
  const { sessionId, inputActions, attachImages } = props;
1187
1529
  const enabled = useCleaningSession(sessionId);
1188
1530
  const [open, setOpen] = react.useState(false);
1531
+ const [wizardStep, setWizardStep] = react.useState(1);
1189
1532
  const [mode, setMode] = react.useState('text');
1190
1533
  const [rawText, setRawText] = react.useState('');
1191
1534
  const [entries, setEntries] = react.useState([]);
@@ -1195,6 +1538,13 @@ window.__ModuleLoader__.load({
1195
1538
  const [imageAttached, setImageAttached] = react.useState(false);
1196
1539
  const [cleaningKeys, setCleaningKeys] = react.useState(DEFAULT_CLEANING_KEYS);
1197
1540
  const [enrichmentKeys, setEnrichmentKeys] = react.useState(DEFAULT_ENRICHMENT_KEYS);
1541
+ const [anchorKeys, setAnchorKeys] = react.useState(['company_name', 'credit_no']);
1542
+ const [matchRules, setPromptMatchRules] = react.useState({
1543
+ normalizeNames: true,
1544
+ preferCreditNo: true,
1545
+ deduplicate: true,
1546
+ manualReviewAmbiguous: true,
1547
+ });
1198
1548
  const [busy, setBusy] = react.useState(false);
1199
1549
  const [error, setError] = react.useState(null);
1200
1550
 
@@ -1226,7 +1576,9 @@ window.__ModuleLoader__.load({
1226
1576
  setEntries(extracted);
1227
1577
  setEntryCount(Number.isFinite(result.rowCount) ? result.rowCount : (Array.isArray(result.rows) ? result.rows.length : extracted.length));
1228
1578
  setSpreadsheetFileName(file.name);
1229
- requestWorkbenchDataset(result, sessionId);
1579
+ requestWorkbenchDataset(result, sessionId, {
1580
+ type: result.fmt || 'xlsx', fileName: file.name, sizeBytes: file.size || 0,
1581
+ });
1230
1582
  }
1231
1583
  } catch (fileError) {
1232
1584
  setError(fileError instanceof Error ? fileError.message : String(fileError));
@@ -1235,35 +1587,61 @@ window.__ModuleLoader__.load({
1235
1587
  if (event.target) event.target.value = '';
1236
1588
  }
1237
1589
  };
1590
+ const selectedEntries = () => mode === 'text'
1591
+ ? rawText.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
1592
+ : entries;
1593
+ const validateSource = () => {
1594
+ const values = selectedEntries();
1595
+ if (mode === 'text' && !values.length) return '请先录入企业名称或统一社会信用代码,每行一条。';
1596
+ if (mode === 'excel' && !values.length) return '请先上传并解析 Excel / CSV / JSON 文件。';
1597
+ if (mode === 'image' && !imageAttached) return '请先选择并附加一张包含企业名单的图片。';
1598
+ return null;
1599
+ };
1600
+ const promptConfig = () => ({
1601
+ mode,
1602
+ entries: selectedEntries(),
1603
+ entryCount: mode === 'excel' ? entryCount : selectedEntries().length,
1604
+ fileName: mode === 'image' ? imageFileName : spreadsheetFileName,
1605
+ cleaningKeys,
1606
+ enrichmentKeys,
1607
+ anchorKeys,
1608
+ matchRules,
1609
+ });
1238
1610
  const generatePrompt = () => {
1239
- const manualEntries = rawText.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
1240
- const selectedEntries = mode === 'text' ? manualEntries : entries;
1241
- if (mode === 'text' && !selectedEntries.length) {
1242
- setError('请先录入企业名称或统一社会信用代码,每行一条。');
1243
- return;
1244
- }
1245
- if (mode === 'excel' && !selectedEntries.length) {
1246
- setError('请先上传并解析 Excel / CSV / JSON 文件。');
1247
- return;
1248
- }
1249
- if (mode === 'image' && !imageAttached) {
1250
- setError('请先选择并附加一张包含企业名单的图片。');
1251
- return;
1252
- }
1611
+ const sourceError = validateSource();
1612
+ if (sourceError) { setError(sourceError); return; }
1613
+ if (!anchorKeys.length) { setError('请至少选择一个企业主体匹配主键。'); return; }
1614
+ if (!cleaningKeys.length) { setError('请至少选择一个清洗或补全目标。'); return; }
1253
1615
  if (!inputActions || typeof inputActions.setDraft !== 'function') {
1254
1616
  setError('当前会话输入机尚未就绪,请稍后重试。');
1255
1617
  return;
1256
1618
  }
1257
- inputActions.setDraft(buildTaskPrompt({
1258
- mode,
1259
- entries: selectedEntries,
1260
- entryCount: mode === 'excel' ? entryCount : selectedEntries.length,
1261
- fileName: mode === 'image' ? imageFileName : spreadsheetFileName,
1262
- cleaningKeys,
1263
- enrichmentKeys,
1264
- }));
1619
+ const config = promptConfig();
1620
+ inputActions.setDraft(buildTaskPrompt(config));
1621
+ if (mode === 'text') {
1622
+ requestWorkbenchDataset(entriesToDataset(config.entries), sessionId, { type: 'text', fileName: '' });
1623
+ }
1624
+ requestWorkbenchDraft({
1625
+ title: `企业数据清洗补全任务 · ${config.entryCount || '待解析'} 条`,
1626
+ objectives: cleaningKeys,
1627
+ fieldSelection: enrichmentKeys,
1628
+ matchRules,
1629
+ source: { type: mode === 'excel' ? 'xlsx' : mode, fileName: config.fileName || '', rowCount: config.entryCount || 0 },
1630
+ }, sessionId);
1265
1631
  setError(null);
1266
1632
  setOpen(false);
1633
+ setWizardStep(1);
1634
+ };
1635
+
1636
+ const nextWizard = () => {
1637
+ if (wizardStep === 1) {
1638
+ const sourceError = validateSource();
1639
+ if (sourceError) { setError(sourceError); return; }
1640
+ }
1641
+ if (wizardStep === 2 && !anchorKeys.length) { setError('请至少选择一个匹配主键。'); return; }
1642
+ if (wizardStep === 3 && !cleaningKeys.length) { setError('请至少选择一个清洗或补全目标。'); return; }
1643
+ if (wizardStep < 4) { setError(null); setWizardStep(wizardStep + 1); return; }
1644
+ generatePrompt();
1267
1645
  };
1268
1646
 
1269
1647
  const choice = (options, values, setter) => options.map(([key, label]) => h('label', {
@@ -1305,49 +1683,75 @@ window.__ModuleLoader__.load({
1305
1683
  ),
1306
1684
  h('button', { type: 'button', className: 'dcAgentPromptClose', 'aria-label': '关闭提示词生成', onClick: () => setOpen(false) }, '✕'),
1307
1685
  ),
1308
- h('div', { className: 'dcAgentPromptTabs', role: 'tablist', 'aria-label': '名单录入方式' },
1309
- [['text', '粘贴名单'], ['excel', '上传 Excel'], ['image', '上传图片']].map(([key, label]) => h('button', {
1310
- key,
1686
+ h('nav', { className: 'dcAgentWizardNav', 'aria-label': '任务设置步骤' },
1687
+ [['1', '数据来源'], ['2', '匹配规则'], ['3', '清洗与补全'], ['4', '确认描述']].map(([index, label]) => h('button', {
1688
+ key: index,
1311
1689
  type: 'button',
1312
- role: 'tab',
1313
- 'aria-selected': mode === key,
1314
- className: `dcAgentPromptTab${mode === key ? ' is-active' : ''}`,
1315
- onClick: () => setSourceMode(key),
1316
- }, label)),
1317
- ),
1318
- mode === 'text' ? h('label', { className: 'dcAgentPromptField' },
1319
- h('span', null, '企业名称或统一社会信用代码(每行一条)'),
1320
- h('textarea', {
1321
- className: 'dcAgentPromptText',
1322
- value: rawText,
1323
- placeholder: '企查查科技股份有限公司\n9132…\n某某信息技术(上海)有限公司',
1324
- onChange: (event) => setRawText(event.target.value),
1325
- }),
1326
- ) : h('label', { className: 'dcAgentPromptFile' },
1327
- h('span', null, mode === 'image' ? '选择企业名单图片' : '选择 Excel / CSV / JSON 名单'),
1328
- h('input', {
1329
- type: 'file',
1330
- accept: mode === 'image' ? 'image/png,image/jpeg,image/webp' : '.xlsx,.xls,.csv,.json',
1331
- disabled: busy,
1332
- onChange: handleFile,
1333
- }),
1334
- (mode === 'excel' ? spreadsheetFileName : imageFileName)
1335
- ? h('b', null, `${mode === 'excel' ? spreadsheetFileName : imageFileName}${mode === 'excel' ? ` · ${entryCount} 条` : ' · 已附加'}`)
1336
- : null,
1337
- ),
1338
- h('div', { className: 'dcAgentPromptGroup' },
1339
- h('b', null, '需要执行哪些清洗?'),
1340
- h('div', { className: 'dcAgentPromptChoices' }, choice(CLEANING_OPTIONS, cleaningKeys, setCleaningKeys)),
1690
+ className: `dcAgentWizardStep${wizardStep === Number(index) ? ' is-active' : ''}`,
1691
+ 'aria-current': wizardStep === Number(index) ? 'step' : undefined,
1692
+ onClick: () => setWizardStep(Number(index)),
1693
+ }, h('b', null, index), h('span', null, label))),
1341
1694
  ),
1342
- h('div', { className: 'dcAgentPromptGroup' },
1343
- h('b', null, '需要补全哪些字段 / 维度?'),
1344
- h('div', { className: 'dcAgentPromptChoices' }, choice(ENRICHMENT_OPTIONS, enrichmentKeys, setEnrichmentKeys)),
1345
- ),
1346
- h('p', { className: 'dcAgentPromptNote' }, '图片模式只负责把图片附加到当前对话,并生成“优先调用已连接的企查查智能文档解析 MCP”的任务要求;插件不会虚构或硬编码未验证的工具名。企查查调用使用当前客户自己的连接、账号与额度。'),
1695
+ wizardStep === 1 ? h('div', { className: 'dcAgentWizardPane' },
1696
+ h('h4', null, '先告诉我数据从哪里来'),
1697
+ h('p', null, '企业名称、统一社会信用代码、注册号任一项都可作为匹配起点。'),
1698
+ h('div', { className: 'dcAgentPromptTabs', role: 'tablist', 'aria-label': '名单录入方式' },
1699
+ [['text', '粘贴企业名单'], ['excel', '上传本地文件'], ['image', '上传图片识别']].map(([key, label]) => h('button', {
1700
+ key, type: 'button', role: 'tab', 'aria-selected': mode === key,
1701
+ className: `dcAgentPromptTab${mode === key ? ' is-active' : ''}`,
1702
+ onClick: () => setSourceMode(key),
1703
+ }, label)),
1704
+ ),
1705
+ mode === 'text' ? h('label', { className: 'dcAgentPromptField' },
1706
+ h('span', null, '企业名称或统一社会信用代码(每行一条)'),
1707
+ h('textarea', {
1708
+ className: 'dcAgentPromptText', value: rawText,
1709
+ placeholder: '企查查科技股份有限公司\n9132…\n某某信息技术(上海)有限公司',
1710
+ onChange: (event) => setRawText(event.target.value),
1711
+ }),
1712
+ ) : h('label', { className: 'dcAgentPromptFile' },
1713
+ h('span', null, mode === 'image' ? '选择包含企业名单的图片' : '选择 CSV / XLSX / JSON 名单'),
1714
+ h('input', {
1715
+ type: 'file', accept: mode === 'image' ? 'image/png,image/jpeg,image/webp' : '.xlsx,.xls,.csv,.json',
1716
+ disabled: busy, onChange: handleFile,
1717
+ }),
1718
+ (mode === 'excel' ? spreadsheetFileName : imageFileName)
1719
+ ? h('b', null, `${mode === 'excel' ? spreadsheetFileName : imageFileName}${mode === 'excel' ? ` · ${entryCount} 条` : ' · 已附加'}`)
1720
+ : null,
1721
+ ),
1722
+ ) : null,
1723
+ wizardStep === 2 ? h('div', { className: 'dcAgentWizardPane' },
1724
+ h('h4', null, '确认匹配规则'),
1725
+ h('p', null, '强标识优先;名称定位不明确时生成候选,不自动写回。'),
1726
+ h('div', { className: 'dcAgentPromptChoices' }, choice(MATCH_ANCHOR_OPTIONS, anchorKeys, setAnchorKeys)),
1727
+ h('div', { className: 'dcAgentPromptRules' },
1728
+ [['normalizeNames', '匹配前统一企业名称格式'], ['preferCreditNo', '有信用代码时优先精确匹配'], ['deduplicate', '匹配前合并重复主体'], ['manualReviewAmbiguous', '多候选必须人工确认']].map(([key, label]) => h('label', { key },
1729
+ h('input', { type: 'checkbox', checked: matchRules[key], onChange: (event) => setPromptMatchRules({ ...matchRules, [key]: event.target.checked }) }), label,
1730
+ )),
1731
+ ),
1732
+ ) : null,
1733
+ wizardStep === 3 ? h('div', { className: 'dcAgentWizardPane' },
1734
+ h('h4', null, '选择清洗目标和补全字段'),
1735
+ h('p', null, '仅展示当前企业数据范围;最终可用字段以当前用户连接能力为准。'),
1736
+ h('div', { className: 'dcAgentPromptGroup' },
1737
+ h('b', null, '清洗目标'),
1738
+ h('div', { className: 'dcAgentPromptChoices' }, choice(CLEANING_OPTIONS, cleaningKeys, setCleaningKeys)),
1739
+ ),
1740
+ FIELD_GROUPS.map(([groupId, label, fields]) => h('div', { className: 'dcAgentPromptGroup', key: groupId },
1741
+ h('b', null, label),
1742
+ h('div', { className: 'dcAgentPromptChoices' }, choice(fields, enrichmentKeys, setEnrichmentKeys)),
1743
+ )),
1744
+ ) : null,
1745
+ wizardStep === 4 ? h('div', { className: 'dcAgentWizardPane' },
1746
+ h('h4', null, '确认任务描述'),
1747
+ h('p', null, '回填后仍可在对话框继续编辑;回填本身不会调用企查查 MCP。'),
1748
+ h('pre', { className: 'dcAgentPromptPreview' }, buildTaskPrompt(promptConfig())),
1749
+ ) : null,
1750
+ h('p', { className: 'dcAgentPromptNote' }, '向导只生成任务描述并保存任务设置。只有你在任务中明确确认后,才会使用当前客户自己的企查查 MCP 连接、账号和额度。'),
1347
1751
  error ? h('p', { className: 'dcAgentPromptError', role: 'alert' }, error) : null,
1348
1752
  h('div', { className: 'dcAgentPromptActions' },
1349
- h('button', { type: 'button', className: 'dcAgentPromptAction', onClick: () => setOpen(false) }, '取消'),
1350
- h('button', { type: 'button', className: 'dcAgentPromptAction is-primary', disabled: busy, onClick: generatePrompt }, busy ? '处理中…' : '生成并回填'),
1753
+ h('button', { type: 'button', className: 'dcAgentPromptAction', disabled: wizardStep === 1, onClick: () => setWizardStep(Math.max(1, wizardStep - 1)) }, '上一步'),
1754
+ h('button', { type: 'button', className: 'dcAgentPromptAction is-primary', disabled: busy, onClick: nextWizard }, busy ? '处理中…' : wizardStep === 4 ? '回填到对话框' : '下一步'),
1351
1755
  ),
1352
1756
  ) : null,
1353
1757
  );
@@ -1433,6 +1837,15 @@ window.__ModuleLoader__.load({
1433
1837
  // 静默:jobs 不可用不影响工作台主流程。
1434
1838
  }
1435
1839
  }
1840
+ async function loadWorkflowTasks(actions) {
1841
+ try {
1842
+ const response = await requestJson('/data-cleaning/api/workflow/tasks');
1843
+ actions.setWorkflowTasks(response.tasks || []);
1844
+ return response.tasks || [];
1845
+ } catch (_error) {
1846
+ return [];
1847
+ }
1848
+ }
1436
1849
  function startJobsPolling(actions) {
1437
1850
  if (jobsTimer !== null) return;
1438
1851
  pollJobsOnce(actions);
@@ -1469,14 +1882,9 @@ window.__ModuleLoader__.load({
1469
1882
  if (trimmed.startsWith('[')) {
1470
1883
  try {
1471
1884
  const rows = JSON.parse(trimmed);
1472
- session.rows = Array.isArray(rows) ? rows : [];
1473
- session.headers = session.rows.length ? Object.keys(session.rows[0]) : [];
1474
- actions.setDataset({
1475
- fmt: 'json',
1476
- headers: session.headers,
1477
- rowCount: session.rows.length,
1478
- preview: session.rows.slice(0, 5),
1479
- });
1885
+ const list = Array.isArray(rows) ? rows : [];
1886
+ const headers = list.length ? Object.keys(list[0]) : [];
1887
+ return { ok: true, fmt: 'json', headers, rowCount: list.length, rows: list, preview: list.slice(0, 5) };
1480
1888
  } catch (error) {
1481
1889
  actions.setError(`JSON 解析失败:${error instanceof Error ? error.message : String(error)}`);
1482
1890
  }
@@ -1485,21 +1893,50 @@ window.__ModuleLoader__.load({
1485
1893
  return api('/data-cleaning/api/mvp/parse', { filename: 'data.csv', content: trimmed });
1486
1894
  }
1487
1895
 
1488
- /** 解析成功落库 + 前进到「数据体检」。 */
1489
- function applyParsed(result, actions) {
1490
- session.rows = Array.isArray(result.rows) ? result.rows : [];
1491
- session.headers = Array.isArray(result.headers) ? result.headers : [];
1492
- const guessedNameField = session.headers.find((name) => /^(name|company|company_name|企业名称|公司名称)$/i.test(name))
1493
- ?? session.headers.find((name) => /企业|公司|名称|name/i.test(name))
1494
- ?? session.headers[0]
1896
+ /** 解析成功进入 taskId runtime;不把整表写入 Host store。 */
1897
+ function applyParsed(result, actions, taskId = 'unassigned', source = {}) {
1898
+ const runtime = runtimeFor(taskId);
1899
+ runtime.rows = Array.isArray(result.rows) ? result.rows : [];
1900
+ runtime.headers = Array.isArray(result.headers) ? result.headers : [];
1901
+ runtime.source = {
1902
+ type: source.type || result.fmt || 'csv',
1903
+ fileName: source.fileName || '',
1904
+ sizeBytes: source.sizeBytes || 0,
1905
+ };
1906
+ const mappings = guessMappings(runtime.headers);
1907
+ const guessedNameField = mappings.find((mapping) => mapping.targetField === 'company_name')?.sourceField
1908
+ ?? mappings.find((mapping) => mapping.targetField === 'credit_no')?.sourceField
1909
+ ?? mappings.find((mapping) => mapping.targetField === 'reg_no')?.sourceField
1910
+ ?? runtime.headers[0]
1495
1911
  ?? 'name';
1496
1912
  actions.setNameField(guessedNameField);
1913
+ actions.setMappings(mappings);
1497
1914
  actions.setDataset({
1498
1915
  fmt: result.fmt ?? 'csv',
1499
- headers: session.headers,
1500
- rowCount: typeof result.rowCount === 'number' ? result.rowCount : session.rows.length,
1501
- preview: Array.isArray(result.preview) ? result.preview : session.rows.slice(0, 5),
1916
+ headers: runtime.headers,
1917
+ rowCount: typeof result.rowCount === 'number' ? result.rowCount : runtime.rows.length,
1918
+ preview: Array.isArray(result.preview) ? result.preview : runtime.rows.slice(0, 5),
1502
1919
  });
1920
+ return runtime;
1921
+ }
1922
+
1923
+ async function persistParsedWorkflow(actions, sessionId, result, source = {}, task) {
1924
+ let current = task ?? await ensureWorkflowTask(actions, sessionId);
1925
+ const stagingKey = `session:${sessionId || 'unassigned'}`;
1926
+ moveRuntime(stagingKey, current.id);
1927
+ if (['draft', 'uploaded', 'parse_failed'].includes(current.state)) {
1928
+ current = await workflowAction(actions, sessionId, current, 'upload', {
1929
+ source: {
1930
+ type: source.type || result.fmt || 'csv',
1931
+ fileName: source.fileName || '',
1932
+ rowCount: typeof result.rowCount === 'number' ? result.rowCount : (result.rows || []).length,
1933
+ columnCount: Array.isArray(result.headers) ? result.headers.length : 0,
1934
+ headers: Array.isArray(result.headers) ? result.headers : [],
1935
+ sizeBytes: source.sizeBytes || 0,
1936
+ },
1937
+ });
1938
+ }
1939
+ return current;
1503
1940
  }
1504
1941
 
1505
1942
  /** 右侧非模态工作台:中央区域始终保留 DSH 原生会话。 */
@@ -1511,6 +1948,14 @@ window.__ModuleLoader__.load({
1511
1948
  const busy = useStore((state) => state.busy);
1512
1949
  const error = useStore((state) => state.error);
1513
1950
  const input = useStore((state) => state.input);
1951
+ const workflowContract = useStore((state) => state.workflowContract);
1952
+ const workflowTask = useStore((state) => state.workflowTask);
1953
+ const workflowTasks = useStore((state) => state.workflowTasks);
1954
+ const taskTitle = useStore((state) => state.taskTitle);
1955
+ const mappings = useStore((state) => state.mappings);
1956
+ const objectives = useStore((state) => state.objectives);
1957
+ const fieldSelection = useStore((state) => state.fieldSelection);
1958
+ const matchRules = useStore((state) => state.matchRules);
1514
1959
  const dataset = useStore((state) => state.dataset);
1515
1960
  const profile = useStore((state) => state.profile);
1516
1961
  const clean = useStore((state) => state.clean);
@@ -1529,28 +1974,75 @@ window.__ModuleLoader__.load({
1529
1974
  const handleOpen = (event) => {
1530
1975
  event?.preventDefault?.();
1531
1976
  const detail = event?.detail ?? {};
1977
+ if (detail.task?.id) {
1978
+ workflowTaskBySession.set(String(detail.sessionId || 'unassigned'), detail.task);
1979
+ actions.setWorkflowTask(detail.task);
1980
+ actions.setTaskTitle(detail.task.title);
1981
+ actions.setMappings(detail.task.mappings || []);
1982
+ actions.setObjectives(detail.task.objectives || []);
1983
+ actions.setFieldSelection(detail.task.fieldSelection || []);
1984
+ for (const [key, value] of Object.entries(detail.task.matchRules || {})) actions.setMatchRule(key, value);
1985
+ }
1532
1986
  openWorkbench(actions, detail.step ?? null, detail.sessionId ?? null);
1533
1987
  };
1534
1988
  const handleDataset = (event) => {
1535
1989
  event?.preventDefault?.();
1536
1990
  const detail = event?.detail ?? {};
1537
- if (detail.result) applyParsed(detail.result, actions);
1991
+ if (detail.result) applyParsed(detail.result, actions, `session:${detail.sessionId || 'unassigned'}`, detail.source);
1538
1992
  if (detail.sessionId) actions.setActiveSession(detail.sessionId);
1993
+ if (detail.result) {
1994
+ void queueWorkflowOperation(detail.sessionId, () => (
1995
+ persistParsedWorkflow(actions, detail.sessionId, detail.result, detail.source)
1996
+ )).catch((eventError) => {
1997
+ actions.setError(eventError instanceof Error ? eventError.message : String(eventError));
1998
+ });
1999
+ }
2000
+ };
2001
+ const handleDraft = (event) => {
2002
+ event?.preventDefault?.();
2003
+ const detail = event?.detail ?? {};
2004
+ const draft = detail.draft ?? {};
2005
+ if (detail.sessionId) actions.setActiveSession(detail.sessionId);
2006
+ if (draft.title) actions.setTaskTitle(draft.title);
2007
+ if (draft.objectives) actions.setObjectives(draft.objectives);
2008
+ if (draft.fieldSelection) actions.setFieldSelection(draft.fieldSelection);
2009
+ for (const [key, value] of Object.entries(draft.matchRules || {})) actions.setMatchRule(key, value);
2010
+ void queueWorkflowOperation(detail.sessionId, async () => {
2011
+ const task = await ensureWorkflowTask(actions, detail.sessionId, draft);
2012
+ return updateWorkflowTask(actions, detail.sessionId, task, draft);
2013
+ }).catch((eventError) => actions.setError(eventError instanceof Error ? eventError.message : String(eventError)));
1539
2014
  };
1540
2015
  if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
1541
2016
  document.addEventListener(WORKBENCH_OPEN_EVENT, handleOpen);
1542
2017
  document.addEventListener(WORKBENCH_DATASET_EVENT, handleDataset);
2018
+ document.addEventListener(WORKBENCH_DRAFT_EVENT, handleDraft);
1543
2019
  }
1544
2020
  return () => {
1545
2021
  if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
1546
2022
  document.removeEventListener(WORKBENCH_OPEN_EVENT, handleOpen);
1547
2023
  document.removeEventListener(WORKBENCH_DATASET_EVENT, handleDataset);
2024
+ document.removeEventListener(WORKBENCH_DRAFT_EVENT, handleDraft);
1548
2025
  }
1549
2026
  stopJobsPolling();
1550
2027
  if (rootWorkbenchActions === actions) rootWorkbenchActions = null;
1551
2028
  };
1552
2029
  }, [actions]);
1553
2030
 
2031
+ react.useEffect(() => {
2032
+ if (!open) return undefined;
2033
+ let disposed = false;
2034
+ requestJson('/data-cleaning/api/workflow/contract').then((response) => {
2035
+ if (!disposed) actions.setWorkflowContract(response.contract);
2036
+ }).catch(() => {});
2037
+ loadWorkflowTasks(actions);
2038
+ if (activeSessionId && !workflowTaskBySession.has(String(activeSessionId))) {
2039
+ queueWorkflowOperation(activeSessionId, () => ensureWorkflowTask(actions, activeSessionId)).catch((taskError) => {
2040
+ if (!disposed) actions.setError(taskError instanceof Error ? taskError.message : String(taskError));
2041
+ });
2042
+ }
2043
+ return () => { disposed = true; };
2044
+ }, [open, activeSessionId, actions]);
2045
+
1554
2046
  // 非模态右栏会覆盖 Host 的中央画布;按实际重叠量把 composer 能力栏左移,
1555
2047
  // 保证 Mockup 中的五个入口在窄桌面视口仍可见、可点击。展开态专注工作台,
1556
2048
  // 不强行挤压到不足一栏的剩余区域。
@@ -1596,7 +2088,11 @@ window.__ModuleLoader__.load({
1596
2088
  if (!open) return null;
1597
2089
 
1598
2090
  const hasData = dataset !== null && dataset.rowCount > 0;
1599
- const fieldByPattern = (pattern) => session.headers.find((field) => pattern.test(field)) ?? null;
2091
+ const cachedTask = workflowTaskBySession.get(String(activeSessionId || 'unassigned')) ?? workflowTask;
2092
+ const runtimeKey = cachedTask?.id ?? `session:${activeSessionId || 'unassigned'}`;
2093
+ const runtime = runtimeFor(runtimeKey);
2094
+ const lastCsv = runtime.lastCsv;
2095
+ const fieldByPattern = (pattern) => runtime.headers.find((field) => pattern.test(field)) ?? null;
1600
2096
  const phoneField = fieldByPattern(/^(phone|mobile|tel|telephone|联系电话|手机号码|手机号)$/i);
1601
2097
  const amountField = fieldByPattern(/^(amount|price|金额|注册资本)$/i);
1602
2098
  const localCleanOptions = {
@@ -1618,7 +2114,10 @@ window.__ModuleLoader__.load({
1618
2114
  actions.setError(null);
1619
2115
  try {
1620
2116
  const result = await parseText(input, actions);
1621
- if (result) applyParsed(result, actions);
2117
+ if (result) {
2118
+ applyParsed(result, actions, runtimeKey, { type: result.fmt || 'csv' });
2119
+ await persistParsedWorkflow(actions, activeSessionId, result, { type: result.fmt || 'csv' }, cachedTask);
2120
+ }
1622
2121
  } catch (err) {
1623
2122
  actions.setError(err instanceof Error ? err.message : String(err));
1624
2123
  } finally {
@@ -1633,7 +2132,10 @@ window.__ModuleLoader__.load({
1633
2132
  actions.setError(null);
1634
2133
  try {
1635
2134
  const result = await parseFile(file);
1636
- if (result && result.ok !== false) applyParsed(result, actions);
2135
+ if (result && result.ok !== false) {
2136
+ applyParsed(result, actions, runtimeKey, { type: result.fmt || 'csv', fileName: file.name, sizeBytes: file.size || 0 });
2137
+ await persistParsedWorkflow(actions, activeSessionId, result, { type: result.fmt || 'csv', fileName: file.name, sizeBytes: file.size || 0 }, cachedTask);
2138
+ }
1637
2139
  else actions.setError((result && (result.message || result.error)) || '解析失败');
1638
2140
  } catch (err) {
1639
2141
  actions.setError(err instanceof Error ? err.message : String(err));
@@ -1642,25 +2144,28 @@ window.__ModuleLoader__.load({
1642
2144
  }
1643
2145
  };
1644
2146
 
2147
+ const performProfile = async (task) => {
2148
+ const r = await api('/data-cleaning/api/mvp/profile', {
2149
+ rows: runtime.rows,
2150
+ headers: runtime.headers,
2151
+ options: { amountField },
2152
+ });
2153
+ if (!r || r.ok === false) throw new Error((r && (r.message || r.error)) || '体检失败');
2154
+ const quality = qualitySummaryFor(runtime.rows, mappings);
2155
+ actions.setProfile({ ...(r.summary ?? r), workflowSummary: quality });
2156
+ return workflowAction(actions, activeSessionId, task, 'quality', { summary: quality });
2157
+ };
2158
+
1645
2159
  const runProfile = async () => {
1646
- if (busy || !session.rows.length) {
1647
- if (!session.rows.length) actions.setError('请先上传并解析数据。');
2160
+ if (busy || !runtime.rows.length) {
2161
+ if (!runtime.rows.length) actions.setError('请先上传并解析数据。');
1648
2162
  return;
1649
2163
  }
1650
2164
  actions.setBusy(true);
1651
2165
  actions.setError(null);
1652
2166
  try {
1653
- const r = await api('/data-cleaning/api/mvp/profile', {
1654
- rows: session.rows,
1655
- headers: session.headers,
1656
- options: { amountField },
1657
- });
1658
- if (r && r.ok !== false) {
1659
- actions.setProfile(r.summary ?? r);
1660
- actions.setStep('review');
1661
- } else {
1662
- actions.setError((r && (r.message || r.error)) || '体检失败');
1663
- }
2167
+ await performProfile(cachedTask);
2168
+ actions.setStep('match');
1664
2169
  } catch (err) {
1665
2170
  actions.setError(err instanceof Error ? err.message : String(err));
1666
2171
  } finally {
@@ -1669,21 +2174,23 @@ window.__ModuleLoader__.load({
1669
2174
  };
1670
2175
 
1671
2176
  const runClean = async () => {
1672
- if (busy || !session.rows.length) {
1673
- if (!session.rows.length) actions.setError('请先上传并解析数据。');
2177
+ if (busy || !runtime.rows.length) {
2178
+ if (!runtime.rows.length) actions.setError('请先上传并解析数据。');
1674
2179
  return;
1675
2180
  }
1676
2181
  actions.setBusy(true);
1677
2182
  actions.setError(null);
1678
2183
  try {
1679
2184
  const r = await api('/data-cleaning/api/mvp/clean', {
1680
- rows: session.rows,
1681
- headers: session.headers,
2185
+ rows: runtime.rows,
2186
+ headers: runtime.headers,
1682
2187
  options: localCleanOptions,
1683
2188
  });
1684
2189
  if (r && r.ok !== false) {
1685
2190
  actions.setClean(r.summary ?? r);
1686
2191
  lastCsv.clean = { csv: r.csv ?? '', name: r.downloadName ?? 'cleaned.csv' };
2192
+ runtime.resultRows.clean = Array.isArray(r.rows) ? r.rows : null;
2193
+ if (Array.isArray(r.headers) && r.headers.length) runtime.headers = r.headers;
1687
2194
  } else {
1688
2195
  actions.setError((r && (r.message || r.error)) || '清洗失败');
1689
2196
  }
@@ -1695,21 +2202,23 @@ window.__ModuleLoader__.load({
1695
2202
  };
1696
2203
 
1697
2204
  const runComplete = async () => {
1698
- if (busy || !session.rows.length) {
1699
- if (!session.rows.length) actions.setError('请先上传并解析数据。');
2205
+ if (busy || !runtime.rows.length) {
2206
+ if (!runtime.rows.length) actions.setError('请先上传并解析数据。');
1700
2207
  return;
1701
2208
  }
1702
2209
  actions.setBusy(true);
1703
2210
  actions.setError(null);
1704
2211
  try {
1705
2212
  const r = await api('/data-cleaning/api/mvp/complete', {
1706
- rows: session.rows,
1707
- headers: session.headers,
2213
+ rows: runtime.rows,
2214
+ headers: runtime.headers,
1708
2215
  options: localCompleteOptions,
1709
2216
  });
1710
2217
  if (r && r.ok !== false) {
1711
2218
  actions.setComplete(r.summary ?? r);
1712
2219
  lastCsv.complete = { csv: r.csv ?? '', name: r.downloadName ?? 'completed.csv' };
2220
+ runtime.resultRows.complete = Array.isArray(r.rows) ? r.rows : null;
2221
+ if (Array.isArray(r.headers) && r.headers.length) runtime.headers = r.headers;
1713
2222
  } else {
1714
2223
  actions.setError((r && (r.message || r.error)) || '补全失败');
1715
2224
  }
@@ -1725,7 +2234,7 @@ window.__ModuleLoader__.load({
1725
2234
  actions.setBusy(true);
1726
2235
  actions.setError(null);
1727
2236
  try {
1728
- const r = await api('/data-cleaning/api/phase3/capabilities');
2237
+ const r = await api('/data-cleaning/api/g5/capabilities');
1729
2238
  if (r && r.ok !== false) actions.setQccCapabilities(r);
1730
2239
  else actions.setError((r && (r.message || r.error)) || '企查查能力检测失败');
1731
2240
  } catch (err) {
@@ -1736,31 +2245,61 @@ window.__ModuleLoader__.load({
1736
2245
  };
1737
2246
 
1738
2247
  const estimateQcc = async () => {
1739
- if (busy || !selectedDomains.length) {
1740
- if (!selectedDomains.length) actions.setError('请至少选择一个补全域。');
1741
- return;
1742
- }
1743
- actions.setBusy(true);
1744
- actions.setError(null);
1745
- try {
1746
- const r = await api('/data-cleaning/api/phase3/estimate', {
1747
- rows: session.rows, nameField, domains: selectedDomains, maxCalls: 500,
1748
- });
1749
- if (r && r.ok !== false) actions.setQccEstimate(r.estimate);
1750
- else actions.setError((r && (r.message || r.error)) || '调用估算失败');
1751
- } catch (err) {
1752
- actions.setError(err instanceof Error ? err.message : String(err));
1753
- } finally {
1754
- actions.setBusy(false);
1755
- }
2248
+ const uniqueCompanies = new Set(runtime.rows.map((row) => String(row?.[nameField] ?? '').trim()).filter(Boolean)).size;
2249
+ actions.setQccEstimate({
2250
+ uniqueCompanies,
2251
+ tools: ['主体查询', '工商信息'],
2252
+ estimatedCalls: uniqueCompanies * 2,
2253
+ maxCalls: 200,
2254
+ withinLimit: runtime.rows.length <= 100,
2255
+ estimateType: 'upper-bound',
2256
+ });
1756
2257
  };
1757
2258
 
1758
- const applyQccRun = (run) => {
2259
+ const applyQccRun = async (run) => {
1759
2260
  actions.setQccRun(run);
1760
- session.rows = Array.isArray(run.rows) ? run.rows : session.rows;
1761
- lastCsv.qcc = { csv: run.csv ?? '', name: run.downloadName ?? 'qcc-phase3-enriched.csv' };
1762
- lastCsv.review = { csv: run.reviewCsv ?? '', name: run.reviewDownloadName ?? 'qcc-phase3-review.csv' };
1763
- if (!Array.isArray(run.reviewQueue) || run.reviewQueue.length === 0) actions.setStep('enrich');
2261
+ runtime.rows = Array.isArray(run.rows) ? run.rows : runtime.rows;
2262
+ runtime.resultRows.qcc = Array.isArray(run.rows) ? run.rows : null;
2263
+ lastCsv.qcc = { csv: run.csv ?? '', name: run.downloadName ?? 'qcc-enriched.csv' };
2264
+ if (run.reviewCsv) lastCsv.review = { csv: run.reviewCsv, name: run.reviewDownloadName ?? 'qcc-review.csv' };
2265
+ const summary = run.summary ?? {};
2266
+ const reviewRequired = Number(summary.ambiguous ?? run.reviewQueue?.length ?? 0);
2267
+ const currentTask = workflowTaskBySession.get(String(activeSessionId || 'unassigned')) ?? cachedTask;
2268
+ let current = currentTask;
2269
+ // partial 表示上一轮仍有失败项。重试成功后可直接重进 enrichment,
2270
+ // 无需伪造一次新的 match 转换;若重试产生候选,再回到人工核验。
2271
+ if (currentTask?.state !== 'partial' || reviewRequired > 0) {
2272
+ current = await workflowAction(actions, activeSessionId, currentTask, 'match', {
2273
+ qccRunId: run.runId,
2274
+ summary: {
2275
+ total: Number(summary.totalRows ?? runtime.rows.length),
2276
+ exact: Number(summary.enriched ?? 0),
2277
+ candidate: reviewRequired,
2278
+ confirmed: 0,
2279
+ unresolved: Number(summary.unresolved ?? summary.missingName ?? 0),
2280
+ failed: Number(summary.failed ?? 0),
2281
+ reviewRequired,
2282
+ },
2283
+ });
2284
+ }
2285
+ if (reviewRequired > 0) {
2286
+ actions.setStep('match');
2287
+ return current;
2288
+ }
2289
+ current = await workflowAction(actions, activeSessionId, current, 'enrich-start', { fieldSelection });
2290
+ current = await workflowAction(actions, activeSessionId, current, 'enrichment', {
2291
+ qccRunId: run.runId,
2292
+ summary: {
2293
+ total: Number(summary.totalRows ?? runtime.rows.length),
2294
+ completed: Number(summary.enriched ?? 0),
2295
+ unchanged: Number(summary.unresolved ?? 0) + Number(summary.missingName ?? 0),
2296
+ failed: Number(summary.failed ?? 0),
2297
+ reviewRequired: 0,
2298
+ callsUsed: Array.isArray(run.audit) ? run.audit.length : 0,
2299
+ },
2300
+ });
2301
+ actions.setStep(current.stage === 'download' ? 'download' : 'enrich');
2302
+ return current;
1764
2303
  };
1765
2304
 
1766
2305
  const runQcc = async () => {
@@ -1768,20 +2307,23 @@ window.__ModuleLoader__.load({
1768
2307
  actions.setBusy(true);
1769
2308
  actions.setError(null);
1770
2309
  try {
1771
- const key = `phase3-ui-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
1772
- const r = await api('/data-cleaning/api/phase3/enrich', {
1773
- rows: session.rows,
1774
- headers: session.headers,
2310
+ await workflowAction(actions, activeSessionId, cachedTask, 'match-start');
2311
+ const key = `g5-ui-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
2312
+ const r = await api('/data-cleaning/api/g5/enrich', {
2313
+ rows: runtime.rows,
2314
+ headers: runtime.headers,
1775
2315
  nameField,
1776
- domains: selectedDomains,
1777
- maxCalls: qccEstimate.maxCalls,
2316
+ includeRisk: false,
1778
2317
  concurrency: 2,
1779
2318
  confirmPaidCalls: true,
1780
2319
  idempotencyKey: key,
1781
2320
  });
1782
- if (r && r.ok !== false) applyQccRun(r);
1783
- else actions.setError((r && (r.message || r.error)) || '三域补全失败');
2321
+ if (r && r.ok !== false) await applyQccRun(r);
2322
+ else actions.setError((r && (r.message || r.error)) || '企业匹配补全失败');
1784
2323
  } catch (err) {
2324
+ if (['QCC_NOT_CONNECTED', 'QCC_TOOL_UNAVAILABLE', 'QCC_AUTH_REQUIRED'].includes(err?.code)) {
2325
+ try { await workflowAction(actions, activeSessionId, cachedTask, 'authorization-required'); } catch (_workflowError) {}
2326
+ }
1785
2327
  actions.setError(err instanceof Error ? err.message : String(err));
1786
2328
  } finally {
1787
2329
  actions.setBusy(false);
@@ -1793,14 +2335,14 @@ window.__ModuleLoader__.load({
1793
2335
  actions.setBusy(true);
1794
2336
  actions.setError(null);
1795
2337
  try {
1796
- const r = await api('/data-cleaning/api/phase3/resolve', {
2338
+ const r = await api('/data-cleaning/api/g5/resolve', {
1797
2339
  runId: qccRun.runId,
1798
2340
  companyName: item.companyName,
1799
2341
  selectedCreditNo: candidate.creditNo,
1800
2342
  confirmPaidCalls: true,
1801
- idempotencyKey: `phase3-resolve-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
2343
+ idempotencyKey: `g5-resolve-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
1802
2344
  });
1803
- if (r && r.ok !== false) applyQccRun(r);
2345
+ if (r && r.ok !== false) await applyQccRun(r);
1804
2346
  else actions.setError((r && (r.message || r.error)) || '候选确认失败');
1805
2347
  } catch (err) {
1806
2348
  actions.setError(err instanceof Error ? err.message : String(err));
@@ -1816,13 +2358,13 @@ window.__ModuleLoader__.load({
1816
2358
  actions.setBusy(true);
1817
2359
  actions.setError(null);
1818
2360
  try {
1819
- const r = await api('/data-cleaning/api/phase3/retry', {
2361
+ const r = await api('/data-cleaning/api/g5/retry', {
1820
2362
  runId: qccRun.runId,
1821
2363
  companyNames: names,
1822
2364
  confirmPaidCalls: true,
1823
- idempotencyKey: `phase3-retry-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
2365
+ idempotencyKey: `g5-retry-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
1824
2366
  });
1825
- if (r && r.ok !== false) applyQccRun(r);
2367
+ if (r && r.ok !== false) await applyQccRun(r);
1826
2368
  else actions.setError((r && (r.message || r.error)) || '失败项重试失败');
1827
2369
  } catch (err) {
1828
2370
  actions.setError(err instanceof Error ? err.message : String(err));
@@ -1831,46 +2373,192 @@ window.__ModuleLoader__.load({
1831
2373
  }
1832
2374
  };
1833
2375
 
1834
- const download = (slot) => {
1835
- const item = lastCsv[slot];
1836
- if (!item || !item.csv) return;
1837
- const blob = new Blob([item.csv], { type: 'text/csv;charset=utf-8' });
1838
- const a = document.createElement('a');
1839
- a.href = URL.createObjectURL(blob);
1840
- a.download = item.name;
1841
- a.click();
1842
- URL.revokeObjectURL(a.href);
2376
+ const saveTaskSettings = async () => {
2377
+ actions.setBusy(true);
2378
+ actions.setError(null);
2379
+ try {
2380
+ const current = await ensureWorkflowTask(actions, activeSessionId, {
2381
+ title: taskTitle, objectives, fieldSelection, mappings, matchRules,
2382
+ });
2383
+ await updateWorkflowTask(actions, activeSessionId, current, {
2384
+ title: taskTitle, objectives, fieldSelection, mappings, matchRules,
2385
+ });
2386
+ } catch (saveError) {
2387
+ actions.setError(saveError instanceof Error ? saveError.message : String(saveError));
2388
+ } finally {
2389
+ actions.setBusy(false);
2390
+ }
2391
+ };
2392
+
2393
+ const confirmRuleSettings = async () => {
2394
+ if (!hasData) { actions.setError('请先上传并解析数据。'); return; }
2395
+ const anchorMappings = mappings.filter((mapping) => ['company_name', 'credit_no', 'reg_no'].includes(mapping.targetField));
2396
+ if (!anchorMappings.length) { actions.setError('请至少映射企业名称、统一社会信用代码或注册号。'); return; }
2397
+ if (!objectives.length) { actions.setError('请至少选择一个清洗或补全目标。'); return; }
2398
+ actions.setBusy(true);
2399
+ actions.setError(null);
2400
+ try {
2401
+ let current = await ensureWorkflowTask(actions, activeSessionId, {
2402
+ title: taskTitle, objectives, fieldSelection, mappings, matchRules,
2403
+ });
2404
+ if (current.state === 'draft') {
2405
+ current = await workflowAction(actions, activeSessionId, current, 'upload', {
2406
+ source: {
2407
+ type: runtime.source?.type || dataset.fmt || 'csv',
2408
+ fileName: runtime.source?.fileName || '',
2409
+ rowCount: dataset.rowCount,
2410
+ columnCount: dataset.headers?.length || 0,
2411
+ headers: dataset.headers || [],
2412
+ sizeBytes: runtime.source?.sizeBytes || 0,
2413
+ },
2414
+ });
2415
+ }
2416
+ current = await updateWorkflowTask(actions, activeSessionId, current, {
2417
+ title: taskTitle, objectives, fieldSelection, mappings, matchRules,
2418
+ });
2419
+ current = await workflowAction(actions, activeSessionId, current, 'rules', {
2420
+ objectives, fieldSelection, mappings, matchRules,
2421
+ });
2422
+ current = await performProfile(current);
2423
+ const primary = mappings.find((mapping) => mapping.targetField === 'company_name')
2424
+ ?? mappings.find((mapping) => mapping.targetField === 'credit_no')
2425
+ ?? mappings.find((mapping) => mapping.targetField === 'reg_no');
2426
+ if (primary) actions.setNameField(primary.sourceField);
2427
+ actions.setStep('profile');
2428
+ } catch (ruleError) {
2429
+ actions.setError(ruleError instanceof Error ? ruleError.message : String(ruleError));
2430
+ } finally {
2431
+ actions.setBusy(false);
2432
+ }
2433
+ };
2434
+
2435
+ const resumeWorkflowTask = (task) => {
2436
+ workflowTaskBySession.set(String(activeSessionId || 'unassigned'), task);
2437
+ actions.setWorkflowTask(task);
2438
+ actions.setTaskTitle(task.title);
2439
+ actions.setMappings(task.mappings || []);
2440
+ actions.setObjectives(task.objectives || []);
2441
+ actions.setFieldSelection(task.fieldSelection || []);
2442
+ for (const [key, value] of Object.entries(task.matchRules || {})) actions.setMatchRule(key, value);
2443
+ actions.setStep(task.stage || 'upload');
2444
+ if (!runtimeFor(task.id, false)?.rows?.length && task.source?.rowCount && !(task.artifacts || []).length) {
2445
+ actions.setDataset(null);
2446
+ actions.setError('已恢复任务元数据。出于隐私保护,原始企业名单未持久化;继续处理前请重新上传源文件。');
2447
+ }
2448
+ };
2449
+
2450
+ const startNewWorkflow = () => {
2451
+ if (cachedTask?.id) runtimeTasks.delete(String(cachedTask.id));
2452
+ runtimeTasks.delete('unassigned');
2453
+ workflowTaskBySession.delete(String(activeSessionId || 'unassigned'));
2454
+ actions.resetWorkflow();
2455
+ };
2456
+
2457
+ const exportRows = () => runtime.resultRows.qcc
2458
+ ?? runtime.resultRows.complete
2459
+ ?? runtime.resultRows.clean
2460
+ ?? (runtime.rows.length ? runtime.rows : null);
2461
+
2462
+ const createArtifactBundle = async () => {
2463
+ let latest = workflowTaskBySession.get(String(activeSessionId || 'unassigned')) ?? cachedTask;
2464
+ if (!latest) throw new Error('请先创建并执行数据清洗补全任务。');
2465
+ if (Array.isArray(latest.artifacts) && latest.artifacts.length) return latest;
2466
+ const rows = exportRows();
2467
+ if (!rows?.length) throw new Error('当前页面没有可导出的明细。若任务来自历史记录,请重新上传源文件并继续处理。');
2468
+ const response = await requestJson(`/data-cleaning/api/workflow/tasks/${encodeURIComponent(latest.id)}/artifacts`, 'POST', {
2469
+ expectedRevision: latest.revision,
2470
+ headers: runtime.headers,
2471
+ rows,
2472
+ baseName: latest.title || taskTitle,
2473
+ summary: {
2474
+ total: rows.length,
2475
+ completed: rows.length,
2476
+ unchanged: 0,
2477
+ failed: 0,
2478
+ },
2479
+ });
2480
+ latest = cacheWorkflowTask(actions, activeSessionId, response.task);
2481
+ loadWorkflowTasks(actions);
2482
+ return latest;
2483
+ };
2484
+
2485
+ const downloadArtifact = async (artifact) => {
2486
+ if (!artifact || busy) return;
2487
+ actions.setBusy(true);
2488
+ actions.setError(null);
2489
+ try {
2490
+ const latest = workflowTaskBySession.get(String(activeSessionId || 'unassigned')) ?? cachedTask;
2491
+ const response = await fetch(`/data-cleaning/api/workflow/tasks/${encodeURIComponent(latest.id)}/artifacts/${encodeURIComponent(artifact.id)}`, {
2492
+ method: 'GET', credentials: 'same-origin', headers: { accept: artifact.mediaType || 'application/octet-stream' },
2493
+ });
2494
+ if (!response.ok) {
2495
+ let payload = null;
2496
+ try { payload = await response.json(); } catch {}
2497
+ throw new Error(payload?.message || `下载失败(HTTP ${response.status})`);
2498
+ }
2499
+ const blob = await response.blob();
2500
+ const href = URL.createObjectURL(blob);
2501
+ const anchor = document.createElement('a');
2502
+ anchor.href = href;
2503
+ anchor.download = artifact.fileName || `export.${artifact.format || 'bin'}`;
2504
+ anchor.click();
2505
+ URL.revokeObjectURL(href);
2506
+ } catch (downloadError) {
2507
+ actions.setError(downloadError instanceof Error ? downloadError.message : String(downloadError));
2508
+ } finally {
2509
+ actions.setBusy(false);
2510
+ }
2511
+ };
2512
+
2513
+ const createAndDownload = async (kind, format) => {
2514
+ if (busy) return;
2515
+ actions.setBusy(true);
2516
+ actions.setError(null);
2517
+ try {
2518
+ const latest = await createArtifactBundle();
2519
+ const artifact = (latest.artifacts || []).find((item) => item.kind === kind && item.format === format);
2520
+ if (!artifact) throw new Error('Host 未生成所选格式的制品。');
2521
+ actions.setBusy(false);
2522
+ await downloadArtifact(artifact);
2523
+ } catch (downloadError) {
2524
+ actions.setError(downloadError instanceof Error ? downloadError.message : String(downloadError));
2525
+ actions.setBusy(false);
2526
+ }
1843
2527
  };
1844
2528
 
1845
2529
  const stat = (label, value, tone) => h('div', { className: 'dcAgentCard' },
1846
2530
  h('span', null, label),
1847
2531
  h('b', { className: tone ? `is-${tone}` : null }, value)
1848
2532
  );
2533
+ const uiFieldGroups = Array.isArray(workflowContract?.fieldCatalog)
2534
+ ? workflowContract.fieldCatalog.map((group) => [group.id, group.label, (group.fields || []).map((field) => [field.id, field.label])])
2535
+ : FIELD_GROUPS;
2536
+ const targetFields = uiFieldGroups.flatMap(([, , fields]) => fields);
1849
2537
 
1850
2538
  let pane;
1851
2539
  if (step === 'history') {
1852
2540
  pane = h('div', { className: 'dcAgentPane' },
1853
- h('p', { className: 'dcAgentHint' }, '查看当前 Host 的数据清洗后台任务。任务数据仅在本机保存,不进入模型上下文。'),
2541
+ h('p', { className: 'dcAgentHint' }, '查看当前 Host 的数据清洗补全任务。Host 只保存任务元数据和摘要,不保存原始企业名单。'),
1854
2542
  h('div', { className: 'dcAgentRow' },
1855
2543
  h('button', {
1856
2544
  type: 'button',
1857
2545
  className: 'dcAgentButton',
1858
2546
  disabled: busy,
1859
2547
  'aria-label': '刷新任务历史',
1860
- onClick: () => pollJobsOnce(actions),
2548
+ onClick: () => loadWorkflowTasks(actions),
1861
2549
  }, '刷新任务'),
1862
2550
  h('button', {
1863
2551
  type: 'button',
1864
2552
  className: 'dcAgentButton is-primary',
1865
- onClick: () => actions.setStep('upload'),
2553
+ onClick: startNewWorkflow,
1866
2554
  }, '新建清洗任务'),
1867
2555
  ),
1868
- jobs.length ? h('div', { className: 'dcAgentPane', 'aria-label': '任务历史列表' },
1869
- jobs.map((job) => h('section', { key: job.id, className: 'dcAgentSection' },
1870
- h('h3', null, job.name || job.id || '数据清洗任务'),
2556
+ workflowTasks.length ? h('div', { className: 'dcAgentPane', 'aria-label': '任务历史列表' },
2557
+ workflowTasks.map((task) => h('button', { key: task.id, type: 'button', className: 'dcAgentHistoryTask', onClick: () => resumeWorkflowTask(task) },
2558
+ h('h3', null, task.title || task.id || '数据清洗任务'),
1871
2559
  h('div', { className: 'dcAgentRow' },
1872
- h('span', { className: 'dcAgentJobsPill', 'data-state': job.state ?? 'idle' }, JOB_STATE_LABEL[job.state] ?? job.state ?? '未知'),
1873
- h('span', { className: 'dcAgentHint' }, job.createdAt ? new Date(job.createdAt).toLocaleString() : ''),
2560
+ h('span', { className: 'dcAgentJobsPill', 'data-state': task.state ?? 'idle' }, WORKFLOW_STATE_LABELS[task.state] ?? task.state ?? '未知'),
2561
+ h('span', { className: 'dcAgentHint' }, `${task.source?.rowCount ?? 0} 行 · ${task.updatedAt ? new Date(task.updatedAt).toLocaleString() : ''}`),
1874
2562
  ),
1875
2563
  )),
1876
2564
  ) : h('section', { className: 'dcAgentSection' },
@@ -1878,6 +2566,58 @@ window.__ModuleLoader__.load({
1878
2566
  h('p', { className: 'dcAgentHint' }, '上传企业名单并开始处理后,任务状态会显示在这里。'),
1879
2567
  ),
1880
2568
  );
2569
+ } else if (step === 'rules') {
2570
+ pane = h('div', { className: 'dcAgentPane' },
2571
+ h('p', { className: 'dcAgentHint' }, '确认任务名称、字段映射、匹配规则和输出字段。保存草稿不会调用企查查;确认规则后才可进入质量体检。'),
2572
+ h('label', { className: 'dcAgentFormField' },
2573
+ h('span', null, '任务名称'),
2574
+ h('input', { className: 'dcAgentField', value: taskTitle, maxLength: 120, onChange: (event) => actions.setTaskTitle(event.target.value) }),
2575
+ ),
2576
+ h('section', { className: 'dcAgentSection' },
2577
+ h('h3', null, '字段映射'),
2578
+ h('p', { className: 'dcAgentHint' }, '至少映射企业名称、统一社会信用代码或注册号其中一项。未映射列会原样保留,但不参与匹配。'),
2579
+ (dataset?.headers || []).map((sourceField) => h('label', { className: 'dcAgentMappingRow', key: sourceField },
2580
+ h('span', { title: sourceField }, sourceField),
2581
+ h('b', { 'aria-hidden': 'true' }, '→'),
2582
+ h('select', {
2583
+ className: 'dcAgentField',
2584
+ value: mappings.find((mapping) => mapping.sourceField === sourceField)?.targetField || '',
2585
+ 'aria-label': `${sourceField} 字段映射`,
2586
+ onChange: (event) => actions.setMapping(sourceField, event.target.value),
2587
+ },
2588
+ h('option', { value: '' }, '不参与匹配 / 补全'),
2589
+ targetFields.map(([id, label]) => h('option', { key: id, value: id }, label)),
2590
+ ),
2591
+ )),
2592
+ ),
2593
+ h('section', { className: 'dcAgentSection' },
2594
+ h('h3', null, '任务目标'),
2595
+ h('div', { className: 'dcAgentChips' }, CLEANING_OPTIONS.map(([key, label]) => h('label', { key, className: `dcAgentChip${objectives.includes(key) ? ' is-selected' : ''}` },
2596
+ h('input', { type: 'checkbox', checked: objectives.includes(key), onChange: () => actions.toggleObjective(key) }), ` ${label}`,
2597
+ ))),
2598
+ ),
2599
+ h('section', { className: 'dcAgentSection' },
2600
+ h('h3', null, '匹配规则'),
2601
+ h('div', { className: 'dcAgentRulesGrid' },
2602
+ [['normalizeNames', '匹配前规范企业名称'], ['preferCreditNo', '信用代码优先精确匹配'], ['deduplicate', '重复主体合并处理'], ['manualReviewAmbiguous', '多候选必须人工确认']].map(([key, label]) => h('label', { key, className: 'dcAgentCheck' },
2603
+ h('input', { type: 'checkbox', checked: matchRules[key], onChange: (event) => actions.setMatchRule(key, event.target.checked) }), label,
2604
+ )),
2605
+ ),
2606
+ ),
2607
+ h('section', { className: 'dcAgentSection' },
2608
+ h('h3', null, `输出字段 · 已选 ${fieldSelection.length}`),
2609
+ uiFieldGroups.map(([groupId, label, fields]) => h('div', { key: groupId, className: 'dcAgentFieldGroup' },
2610
+ h('b', null, label),
2611
+ h('div', { className: 'dcAgentChips' }, fields.map(([key, fieldLabel]) => h('label', { key, className: `dcAgentChip${fieldSelection.includes(key) ? ' is-selected' : ''}` },
2612
+ h('input', { type: 'checkbox', checked: fieldSelection.includes(key), onChange: () => actions.toggleField(key) }), ` ${fieldLabel}`,
2613
+ ))),
2614
+ )),
2615
+ ),
2616
+ h('div', { className: 'dcAgentRow' },
2617
+ h('button', { type: 'button', className: 'dcAgentButton', disabled: busy, onClick: saveTaskSettings }, '保存草稿'),
2618
+ h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: busy || !hasData, 'aria-label': '确认规则并运行质量体检', onClick: confirmRuleSettings }, busy ? '保存中…' : '确认规则并运行质量体检'),
2619
+ ),
2620
+ );
1881
2621
  } else if (step === 'profile') {
1882
2622
  pane = h('div', { className: 'dcAgentPane' },
1883
2623
  h('p', { className: 'dcAgentHint' }, '基于已解析数据生成本地质量画像(缺失率、去重值、金额分布)。本步骤不发起企查查调用。'),
@@ -1905,45 +2645,28 @@ window.__ModuleLoader__.load({
1905
2645
  ))),
1906
2646
  ) : null,
1907
2647
  h('div', { className: 'dcAgentRow' },
1908
- h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: busy, onClick: () => actions.setStep('review') }, '下一步:匹配核验'),
2648
+ h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: busy, onClick: () => actions.setStep('match') }, '下一步:匹配核验'),
1909
2649
  ),
1910
2650
  ) : h('div', { className: 'dcAgentRow' },
1911
2651
  h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: busy || !hasData, 'aria-label': '生成体检报告', onClick: runProfile }, busy ? '体检中…' : '生成体检报告'),
1912
2652
  ),
1913
2653
  );
1914
- } else if (step === 'review') {
2654
+ } else if (step === 'match') {
1915
2655
  pane = h('div', { className: 'dcAgentPane' },
1916
- h('p', { className: 'dcAgentHint' }, '先执行本地确定性清洗,再按真实 ToolRuntime 状态进行主体匹配。多候选始终由人工选择;界面不生成虚构置信度。'),
2656
+ h('p', { className: 'dcAgentHint' }, '通过当前用户连接的企查查 MCP 定位企业主体并补全基础工商字段。多候选始终由人工选择;界面不生成虚构置信度。'),
1917
2657
  h('section', { className: 'dcAgentSection' },
1918
- h('h3', null, '本地清洗预处理'),
1919
- h('div', { className: 'dcAgentRow' },
1920
- h('button', { type: 'button', className: 'dcAgentButton', disabled: busy, 'aria-label': '执行清洗', onClick: runClean }, busy ? '处理中…' : '执行清洗'),
1921
- h('button', { type: 'button', className: 'dcAgentButton', disabled: busy, 'aria-label': '执行补全', onClick: runComplete }, busy ? '处理中…' : '本地规则补全'),
1922
- ),
1923
- clean ? h('div', { className: 'dcAgentGrid' },
1924
- stat('总数', clean.total), stat('保留', clean.kept, 'good'), stat('剔除', clean.dropped, 'bad'),
1925
- stat('缺失关键字段', clean.badMissing, clean.badMissing > 0 ? 'bad' : null),
1926
- stat('非法金额', clean.badAmount, clean.badAmount > 0 ? 'warn' : null),
1927
- stat('重复', clean.badDuplicate, clean.badDuplicate > 0 ? 'warn' : null),
1928
- ) : null,
2658
+ h('h3', null, '匹配依据'),
2659
+ h('div', { className: 'dcAgentChips' }, mappings.filter((mapping) => ['company_name', 'credit_no', 'reg_no'].includes(mapping.targetField)).map((mapping) => h('span', { key: mapping.sourceField, className: 'dcAgentChip' }, `${mapping.sourceField} → ${ENRICHMENT_OPTIONS.find(([id]) => id === mapping.targetField)?.[1] || mapping.targetField}`))),
2660
+ h('p', { className: 'dcAgentHint' }, '统一社会信用代码等强标识优先;名称匹配不明确时进入人工核验队列。'),
1929
2661
  ),
1930
2662
  h('section', { className: 'dcAgentSection' },
1931
- h('h3', null, '企查查三域能力与调用范围'),
2663
+ h('h3', null, '企查查基础企业能力'),
1932
2664
  h('div', { className: 'dcAgentRow' },
1933
2665
  h('button', { type: 'button', className: 'dcAgentButton', disabled: busy, onClick: loadQccCapabilities }, busy ? '检测中…' : '检测企查查连接'),
1934
- qccCapabilities ? h('span', { className: 'dcAgentHint' }, qccCapabilities.capabilities?.ready ? '91 个三域工具已就绪' : `${qccCapabilities.capabilities?.totalRegistered ?? 0}/${qccCapabilities.capabilities?.total ?? 91} 工具可用`) : null,
1935
- ),
1936
- h('div', { className: 'dcAgentChips' },
1937
- [['risk', '风险信息 · 38'], ['ipr', '知识产权 · 18'], ['operation', '经营信息 · 35']].map(([domain, label]) => h('label', {
1938
- key: domain,
1939
- className: `dcAgentChip${selectedDomains.includes(domain) ? ' is-selected' : ''}`,
1940
- },
1941
- h('input', { type: 'checkbox', checked: selectedDomains.includes(domain), 'aria-label': label, onChange: () => actions.toggleDomain(domain) }),
1942
- ` ${label}`,
1943
- )),
2666
+ qccCapabilities ? h('span', { className: 'dcAgentHint' }, qccCapabilities.capabilities?.ready ? '主体查询与工商信息工具已就绪' : '连接未就绪或基础工具不完整') : null,
1944
2667
  ),
1945
2668
  h('div', { className: 'dcAgentRow' },
1946
- h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !selectedDomains.length, onClick: estimateQcc }, '估算调用量'),
2669
+ h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !runtime.rows.length, onClick: estimateQcc }, '估算调用量'),
1947
2670
  ),
1948
2671
  qccEstimate ? h('div', null,
1949
2672
  h('div', { className: 'dcAgentGrid' },
@@ -1954,10 +2677,10 @@ window.__ModuleLoader__.load({
1954
2677
  ),
1955
2678
  h('label', { className: 'dcAgentCheck' },
1956
2679
  h('input', { type: 'checkbox', checked: paidConfirmed, 'aria-label': '确认使用当前用户的企查查账号额度', onChange: (event) => actions.setPaidConfirmed(event.target.checked) }),
1957
- '我已核对企业数量、所选域及调用上界,并确认使用自己连接的企查查 MCP 账号;额度或费用由该账号自行承担',
2680
+ '我已核对企业数量和调用上界,并确认使用自己连接的企查查 MCP 账号;额度或费用由该账号自行承担',
1958
2681
  ),
1959
2682
  h('div', { className: 'dcAgentRow' },
1960
- h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: busy || !paidConfirmed || !qccEstimate.withinLimit, onClick: runQcc }, busy ? '执行中…' : '开始匹配与补全'),
2683
+ h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: busy || !paidConfirmed || !qccEstimate.withinLimit, onClick: runQcc }, busy ? '执行中…' : '开始主体匹配'),
1961
2684
  ),
1962
2685
  ) : null,
1963
2686
  ),
@@ -1965,11 +2688,21 @@ window.__ModuleLoader__.load({
1965
2688
  h('h3', null, `任务 ${qccRun.runId} · ${qccRun.state}`),
1966
2689
  h('div', { className: 'dcAgentGrid' },
1967
2690
  stat('已补全', qccRun.summary?.enriched ?? 0, 'good'),
1968
- stat('部分成功', qccRun.summary?.partial ?? 0, (qccRun.summary?.partial ?? 0) > 0 ? 'warn' : null),
1969
2691
  stat('待核验', qccRun.summary?.ambiguous ?? 0, (qccRun.summary?.ambiguous ?? 0) > 0 ? 'warn' : null),
2692
+ stat('未匹配', qccRun.summary?.unresolved ?? 0, (qccRun.summary?.unresolved ?? 0) > 0 ? 'warn' : null),
1970
2693
  stat('失败', qccRun.summary?.failed ?? 0, (qccRun.summary?.failed ?? 0) > 0 ? 'bad' : null),
1971
- stat('实际调用', qccRun.summary?.actualCalls ?? 0),
1972
2694
  ),
2695
+ Array.isArray(qccRun.rows) && qccRun.rows.length ? h('div', { className: 'dcAgentTableWrap' },
2696
+ h('table', { className: 'dcAgentTable', 'aria-label': '匹配补全结果预览' },
2697
+ h('thead', null, h('tr', null,
2698
+ Object.keys(qccRun.rows[0] || {}).slice(0, 6).map((key) => h('th', { key }, key)),
2699
+ )),
2700
+ h('tbody', null, qccRun.rows.slice(0, 8).map((row, index) => h('tr', { key: `${index}-${row?.qcc_credit_no || row?.credit_no || ''}` },
2701
+ Object.keys(qccRun.rows[0] || {}).slice(0, 6).map((key) => h('td', { key }, String(row?.[key] ?? ''))),
2702
+ ))),
2703
+ ),
2704
+ h('p', { className: 'dcAgentHint' }, `显示前 ${Math.min(8, qccRun.rows.length)} 行;完整结果在下载阶段生成耐久制品。`),
2705
+ ) : null,
1973
2706
  (qccRun.reviewQueue || []).map((item) => h('div', { key: item.companyName, className: 'dcAgentSection' },
1974
2707
  h('h3', null, `待核验:${item.companyName}`),
1975
2708
  item.candidates.map((candidate) => h('div', { key: candidate.creditNo, className: 'dcAgentCandidate' },
@@ -1979,16 +2712,27 @@ window.__ModuleLoader__.load({
1979
2712
  )),
1980
2713
  )),
1981
2714
  (qccRun.errors || []).some((item) => item.error?.retryable) ? h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !paidConfirmed, onClick: retryFailures }, '重试可恢复失败项') : null,
1982
- h('div', { className: 'dcAgentRow' },
1983
- h('button', { type: 'button', className: 'dcAgentButton is-primary', onClick: () => actions.setStep('enrich') }, '进入补全与导出'),
1984
- ),
1985
- ) : h('div', { className: 'dcAgentRow' },
1986
- h('button', { type: 'button', className: 'dcAgentButton', onClick: () => actions.setStep('enrich') }, '仅使用本地结果并导出'),
1987
- ),
2715
+ ) : null,
1988
2716
  );
1989
2717
  } else if (step === 'enrich') {
1990
2718
  pane = h('div', { className: 'dcAgentPane' },
1991
- h('p', { className: 'dcAgentHint' }, '导出本地清洗结果、企查查三域补全结果和待核验清单。每个三域值均保留 sourceTool 与上游原值。'),
2719
+ h('p', { className: 'dcAgentHint' }, '执行本地确定性清洗并核对本任务所选补全字段。外部字段以当前用户企查查 MCP 的真实返回为准,缺失值不会被编造。'),
2720
+ h('section', { className: 'dcAgentSection' },
2721
+ h('h3', null, `已选补全字段 · ${fieldSelection.length}`),
2722
+ h('div', { className: 'dcAgentChips' }, fieldSelection.map((id) => h('span', { className: 'dcAgentChip', key: id }, ENRICHMENT_OPTIONS.find(([fieldId]) => fieldId === id)?.[1] || id))),
2723
+ ),
2724
+ h('section', { className: 'dcAgentSection' },
2725
+ h('h3', null, '本地清洗预处理'),
2726
+ h('div', { className: 'dcAgentRow' },
2727
+ h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !hasData, 'aria-label': '执行清洗', onClick: runClean }, busy ? '处理中…' : '执行确定性清洗'),
2728
+ h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !hasData, 'aria-label': '执行补全', onClick: runComplete }, busy ? '处理中…' : '本地规则补全'),
2729
+ ),
2730
+ clean ? h('div', { className: 'dcAgentGrid' },
2731
+ stat('总数', clean.total), stat('保留', clean.kept, 'good'), stat('剔除', clean.dropped, 'bad'),
2732
+ stat('缺失关键字段', clean.badMissing, clean.badMissing > 0 ? 'bad' : null),
2733
+ stat('重复', clean.badDuplicate, clean.badDuplicate > 0 ? 'warn' : null),
2734
+ ) : null,
2735
+ ),
1992
2736
  h('div', { className: 'dcAgentGrid' },
1993
2737
  stat('输入行数', dataset ? dataset.rowCount : '—'),
1994
2738
  stat('清洗保留', clean ? clean.kept : '—', clean && clean.kept > 0 ? 'good' : null),
@@ -1996,11 +2740,36 @@ window.__ModuleLoader__.load({
1996
2740
  stat('QCC 已补全', qccRun ? qccRun.summary?.enriched ?? 0 : '—', qccRun && qccRun.summary?.enriched > 0 ? 'good' : null),
1997
2741
  stat('待核验', qccRun ? qccRun.summary?.ambiguous ?? 0 : '—', qccRun && qccRun.summary?.ambiguous > 0 ? 'warn' : null),
1998
2742
  ),
1999
- h('div', { className: 'dcAgentRow' },
2000
- h('button', { type: 'button', className: 'dcAgentButton', disabled: !lastCsv.clean, 'aria-label': '下载清洗结果', onClick: () => download('clean') }, '下载清洗结果 CSV'),
2001
- h('button', { type: 'button', className: 'dcAgentButton', disabled: !lastCsv.complete, 'aria-label': '下载补全结果', onClick: () => download('complete') }, '下载补全结果 CSV'),
2002
- h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: !lastCsv.qcc, 'aria-label': '下载 QCC 补全结果', onClick: () => download('qcc') }, '下载 QCC 补全结果 CSV'),
2003
- h('button', { type: 'button', className: 'dcAgentButton', disabled: !lastCsv.review, 'aria-label': '下载待核验清单', onClick: () => download('review') }, '下载待核验清单 CSV'),
2743
+ h('div', { className: 'dcAgentRow' }, h('button', { type: 'button', className: 'dcAgentButton is-primary', onClick: () => actions.setStep('download') }, '进入下载数据')),
2744
+ );
2745
+ } else if (step === 'download') {
2746
+ const availableArtifacts = cachedTask?.artifacts || [];
2747
+ const canCreateArtifacts = Boolean(exportRows()?.length) && ['rules_confirmed', 'diagnosed', 'export_ready', 'partial'].includes(cachedTask?.state);
2748
+ pane = h('div', { className: 'dcAgentPane' },
2749
+ h('p', { className: 'dcAgentHint' }, '结果与异常清单由 Host 生成 CSV/XLSX 四件套并耐久保存。任务或插件重启后,可从任务历史继续下载;无需再次调用企查查。'),
2750
+ h('div', { className: 'dcAgentGrid' },
2751
+ stat('输入行数', dataset ? dataset.rowCount : workflowTask?.source?.rowCount ?? '—'),
2752
+ stat('匹配补全', qccRun ? qccRun.summary?.enriched ?? 0 : '—', qccRun && qccRun.summary?.enriched > 0 ? 'good' : null),
2753
+ stat('待核验', qccRun ? qccRun.summary?.ambiguous ?? 0 : '—', qccRun && qccRun.summary?.ambiguous > 0 ? 'warn' : null),
2754
+ stat('任务状态', WORKFLOW_STATE_LABELS[cachedTask?.state] || cachedTask?.state || '进行中'),
2755
+ ),
2756
+ availableArtifacts.length ? h('div', { className: 'dcAgentArtifactList' },
2757
+ availableArtifacts.map((artifact) => h('button', {
2758
+ key: artifact.id,
2759
+ type: 'button',
2760
+ className: `dcAgentButton${artifact.kind === 'complete' && artifact.format === 'xlsx' ? ' is-primary' : ''}`,
2761
+ disabled: busy,
2762
+ onClick: () => downloadArtifact(artifact),
2763
+ 'aria-label': `下载 ${artifact.fileName}`,
2764
+ }, `${artifact.kind === 'review' ? '异常清单' : '清洗补全结果'} ${String(artifact.format).toUpperCase()} · ${artifact.rowCount} 行`)),
2765
+ ) : h('div', null,
2766
+ h('div', { className: 'dcAgentRow' },
2767
+ h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: busy || !canCreateArtifacts, onClick: () => createAndDownload('complete', 'xlsx') }, '生成并下载结果 XLSX'),
2768
+ h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !canCreateArtifacts, onClick: () => createAndDownload('complete', 'csv') }, '生成并下载结果 CSV'),
2769
+ h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !canCreateArtifacts, onClick: () => createAndDownload('review', 'xlsx') }, '生成并下载异常清单 XLSX'),
2770
+ h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !canCreateArtifacts, onClick: () => createAndDownload('review', 'csv') }, '生成并下载异常清单 CSV'),
2771
+ ),
2772
+ !canCreateArtifacts ? h('p', { className: 'dcAgentHint' }, '请先完成规则确认与清洗补全;历史任务若尚未生成制品,需要重新上传源文件。') : null,
2004
2773
  ),
2005
2774
  );
2006
2775
  } else {
@@ -2026,15 +2795,15 @@ window.__ModuleLoader__.load({
2026
2795
  (dataset.headers || []).slice(0, 12).map((name) => h('span', { key: name, className: 'dcAgentChip' }, name)),
2027
2796
  (dataset.headers || []).length > 12 ? h('span', { className: 'dcAgentChip' }, `+${dataset.headers.length - 12} 列`) : null,
2028
2797
  ) : null,
2029
- dataset ? h('label', { className: 'dcAgentRow' },
2030
- h('span', { className: 'dcAgentHint' }, '企业名称字段'),
2031
- h('select', { className: 'dcAgentField', value: nameField, 'aria-label': '企业名称字段映射', onChange: (event) => actions.setNameField(event.target.value) },
2032
- (dataset.headers || []).map((name) => h('option', { key: name, value: name }, name)),
2798
+ dataset ? h('div', { className: 'dcAgentPreviewTable', 'aria-label': '数据预览' },
2799
+ h('table', { className: 'dcAgentTable' },
2800
+ h('thead', null, h('tr', null, (dataset.headers || []).slice(0, 8).map((name) => h('th', { key: name }, name)))),
2801
+ h('tbody', null, (dataset.preview || []).slice(0, 5).map((row, index) => h('tr', { key: index }, (dataset.headers || []).slice(0, 8).map((name) => h('td', { key: name }, String(row?.[name] ?? '')))))),
2033
2802
  ),
2034
2803
  ) : null,
2035
2804
  h('div', { className: 'dcAgentRow' },
2036
2805
  h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: busy, 'aria-label': '解析数据', onClick: handleParse }, busy ? '解析中…' : '解析数据'),
2037
- hasData ? h('button', { type: 'button', className: 'dcAgentButton', onClick: () => actions.setStep('profile') }, '继续到数据体检') : null,
2806
+ hasData ? h('button', { type: 'button', className: 'dcAgentButton', onClick: () => actions.setStep('rules') }, '下一步:字段映射与规则') : null,
2038
2807
  ),
2039
2808
  );
2040
2809
  }
@@ -2068,7 +2837,7 @@ window.__ModuleLoader__.load({
2068
2837
  h('span', { className: 'dcAgentWbIcon', 'aria-hidden': 'true' }, '🧹'),
2069
2838
  h('div', null,
2070
2839
  h('b', null, '数据清洗补全'),
2071
- h('small', null, '本地确定性清洗 · 可选企查查 MCP 补全'),
2840
+ h('small', null, cachedTask ? `${WORKFLOW_STATE_LABELS[cachedTask.state] || cachedTask.state} · ${cachedTask.id.slice(0, 18)}` : '正在创建 Host taskId…'),
2072
2841
  ),
2073
2842
  ),
2074
2843
  h('span', {
@@ -2190,7 +2959,16 @@ window.__ModuleLoader__.load({
2190
2959
  exports.apply = apply;
2191
2960
  exports.inject = inject;
2192
2961
  // 测试用纯函数,不构成 Host / DSH 稳定 API。
2193
- exports.__testing = { buildTaskPrompt, extractPromptEntries, markCleaningSession };
2962
+ exports.__testing = {
2963
+ buildTaskPrompt,
2964
+ entriesToDataset,
2965
+ extractPromptEntries,
2966
+ guessMappings,
2967
+ markCleaningSession,
2968
+ qualitySummaryFor,
2969
+ ensureWorkflowTask,
2970
+ queueWorkflowOperation,
2971
+ };
2194
2972
  return module.exports;
2195
2973
  },
2196
2974
  });