dsh-data-cleaning-agent 0.5.3 → 0.6.1

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,76 +737,151 @@ 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', '补全所选企业字段'],
794
+ ];
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
+ ]],
726
817
  ];
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', '经营信息'],
818
+ const ENRICHMENT_OPTIONS = FIELD_GROUPS.flatMap(([, , fields]) => fields);
819
+ const MATCH_ANCHOR_OPTIONS = [
820
+ ['company_name', '企业名称'], ['credit_no', '统一社会信用代码'], ['reg_no', '注册号'],
739
821
  ];
740
- const DEFAULT_CLEANING_KEYS = ['normalize_name', 'complete_name', 'verify_credit', 'deduplicate', 'manual_review'];
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
- const CLEANING_SESSION_STORAGE_KEY = 'dsh.data-cleaning-agent.sessions.v1';
744
- const CLEANING_SESSION_EVENT = 'dsh:data-cleaning-session-marked';
745
- const cleaningSessionIds = new Set();
825
+ const CLEANING_SESSION_STORAGE_KEYS = [
826
+ 'dsh.data-cleaning-agent.active-session.v2',
827
+ 'dsh.data-cleaning-agent.sessions.v1',
828
+ ];
829
+ const CLEANING_SESSION_EVENT = 'dsh:data-cleaning-session-ownership';
830
+ let activeCleaningSessionId = null;
746
831
 
747
832
  try {
748
- const stored = window.sessionStorage?.getItem(CLEANING_SESSION_STORAGE_KEY);
749
- for (const id of JSON.parse(stored || '[]')) {
750
- if (typeof id === 'string' && id) cleaningSessionIds.add(id);
751
- }
833
+ // 旧版本持久化过 sessionId;但 DSH 会复用空白新会话,无法仅凭 ID 判断入口来源。
834
+ // 子系统归属因此只在当前页面、经菜单显式激活,并在升级时清除所有旧标记。
835
+ for (const key of CLEANING_SESSION_STORAGE_KEYS) window.sessionStorage?.removeItem(key);
752
836
  } catch (_error) {
753
- // sessionStorage 可能被禁用;当前页面内的 Set 仍可工作。
837
+ // sessionStorage 可能被禁用;内存态不受影响。
754
838
  }
755
839
 
756
840
  function isCleaningSession(sessionId) {
757
- return typeof sessionId === 'string' && cleaningSessionIds.has(sessionId);
841
+ return typeof sessionId === 'string' && sessionId === activeCleaningSessionId;
758
842
  }
759
843
 
760
844
  function markCleaningSession(sessionId) {
761
845
  if (typeof sessionId !== 'string' || !sessionId) return;
762
- cleaningSessionIds.add(sessionId);
763
- try {
764
- window.sessionStorage?.setItem(CLEANING_SESSION_STORAGE_KEY, JSON.stringify([...cleaningSessionIds].slice(-20)));
765
- } catch (_error) {
766
- // 不因浏览器存储策略阻断会话。
846
+ activeCleaningSessionId = sessionId;
847
+ if (typeof window.CustomEvent === 'function' && typeof window.dispatchEvent === 'function') {
848
+ window.dispatchEvent(new window.CustomEvent(CLEANING_SESSION_EVENT, { detail: { sessionId, active: true } }));
849
+ }
850
+ }
851
+
852
+ function isKnownCleaningDraft(value) {
853
+ if (value === DEFAULT_SESSION_PROMPT) return true;
854
+ if (typeof value !== 'string') return false;
855
+ return value.startsWith('请执行一项企业名单数据清洗补全任务。')
856
+ && value.includes('企查查连接、套餐额度和费用均由当前用户自己的账号承担。')
857
+ && value.includes('提供结果和待复核清单的导出。');
858
+ }
859
+
860
+ function clearCleaningDraft(ctx, sessionId, onlyDefault = false) {
861
+ if (!sessionId) return false;
862
+ const conversation = typeof ctx.get === 'function' ? ctx.get('conversation') : ctx.conversation;
863
+ const shell = conversation?.input?.shell?.(sessionId);
864
+ if (!shell || typeof shell.setDraft !== 'function') return false;
865
+ if (onlyDefault && !isKnownCleaningDraft(shell.snapshot?.draft)) return false;
866
+ shell.setDraft('');
867
+ return true;
868
+ }
869
+
870
+ function deactivateCleaningSession(sessionId = activeCleaningSessionId) {
871
+ if (!activeCleaningSessionId || (sessionId && sessionId !== activeCleaningSessionId)) return false;
872
+ const previousSessionId = activeCleaningSessionId;
873
+ activeCleaningSessionId = null;
874
+ if (rootWorkbenchActions) {
875
+ stopJobsPolling();
876
+ rootWorkbenchActions.close?.();
877
+ rootWorkbenchActions.setActiveSession?.(null);
767
878
  }
768
879
  if (typeof window.CustomEvent === 'function' && typeof window.dispatchEvent === 'function') {
769
- window.dispatchEvent(new window.CustomEvent(CLEANING_SESSION_EVENT, { detail: { sessionId } }));
880
+ window.dispatchEvent(new window.CustomEvent(CLEANING_SESSION_EVENT, {
881
+ detail: { sessionId: previousSessionId, active: false },
882
+ }));
770
883
  }
884
+ return true;
771
885
  }
772
886
 
773
887
  function useCleaningSession(sessionId) {
@@ -776,14 +890,89 @@ window.__ModuleLoader__.load({
776
890
  );
777
891
  react.useEffect(() => {
778
892
  const handleMarked = (event) => {
779
- if (event?.detail?.sessionId === sessionId) setMarkedSessionId(sessionId);
893
+ const detail = event?.detail ?? {};
894
+ setMarkedSessionId(detail.active === true && detail.sessionId === sessionId ? sessionId : null);
780
895
  };
781
896
  window.addEventListener?.(CLEANING_SESSION_EVENT, handleMarked);
782
897
  return () => window.removeEventListener?.(CLEANING_SESSION_EVENT, handleMarked);
783
898
  }, [sessionId]);
784
899
  // DSH 可能复用同一个 slot component 切换会话;状态必须绑定具体 sessionId,
785
900
  // 避免从清洗会话切回普通会话后仍残留业务首页与提示词入口。
786
- return markedSessionId === sessionId || isCleaningSession(sessionId);
901
+ return markedSessionId === sessionId && isCleaningSession(sessionId);
902
+ }
903
+
904
+ /**
905
+ * 清洗补全是一个显式进入的业务子系统。切换会话、点击新会话或点击侧栏其它业务
906
+ * 入口时立即撤销其激活态,即使 DSH 复用了同一个空白 sessionId 也不会残留首页、
907
+ * 提示词、能力栏或工作台。点击本插件自己的顶部入口不会提前撤销。
908
+ */
909
+ function installSessionOwnershipBridge(ctx) {
910
+ let staleDraftObserver = null;
911
+ let staleDraftObserverTimer = null;
912
+ const clearStaleDefaultDraft = () => {
913
+ if (activeCleaningSessionId) return false;
914
+ const currentSessionId = ctx.sessions?.list?.getSnapshot?.()?.current;
915
+ const cleared = clearCleaningDraft(ctx, currentSessionId, true);
916
+ if (cleared && staleDraftObserver) {
917
+ staleDraftObserver.disconnect();
918
+ staleDraftObserver = null;
919
+ }
920
+ return cleared;
921
+ };
922
+ const leaveSubsystem = () => {
923
+ const sessionId = activeCleaningSessionId;
924
+ if (sessionId) clearCleaningDraft(ctx, sessionId);
925
+ deactivateCleaningSession(sessionId);
926
+ };
927
+ const handleSidebarNavigation = (event) => {
928
+ if (!activeCleaningSessionId) return;
929
+ const target = event?.target;
930
+ if (!target || typeof target.closest !== 'function') return;
931
+ if (target.closest('.dcAgentLauncher, .dcAgentExperience, .dcAgentPromptPopover, .dcAgentWorkbench, .dcAgentHeaderAction')) return;
932
+
933
+ const actionable = target.closest('button, a, [role="button"]');
934
+ if (!actionable) return;
935
+ const workspaceSlot = document.querySelector?.(SIDEBAR_WORKSPACES_SELECTOR);
936
+ const sidebarRoot = workspaceSlot?.closest?.('aside, nav, [data-slot="sidebar"], [class*="sidebar"]')
937
+ ?? workspaceSlot?.parentElement;
938
+ const label = `${actionable.getAttribute?.('aria-label') ?? ''} ${actionable.textContent ?? ''}`.trim();
939
+ const sessionOrAgentEntry = /新建?会话|新会话|new\s+session|智能体|agent|尽调/i.test(label);
940
+ if (sidebarRoot?.contains?.(actionable) || sessionOrAgentEntry) leaveSubsystem();
941
+ };
942
+
943
+ document.addEventListener?.('click', handleSidebarNavigation, true);
944
+ // 升级 / 刷新后不恢复子系统所有权;仅清理由旧版本遗留且仍未被用户编辑的默认文案。
945
+ clearStaleDefaultDraft();
946
+ // DSH 会在插件 apply() 之后异步恢复 composer draft。在首屏稳定期内
947
+ // 监听 DOM 变化并仅清理完全匹配本插件默认文案,或同时命中
948
+ // 提示词向导固定首尾与费用声明签名的草稿;不触碰用户自写内容。
949
+ if (typeof MutationObserver === 'function' && document.documentElement) {
950
+ staleDraftObserver = new MutationObserver(clearStaleDefaultDraft);
951
+ staleDraftObserver.observe(document.documentElement, {
952
+ childList: true,
953
+ characterData: true,
954
+ subtree: true,
955
+ });
956
+ staleDraftObserverTimer = setTimeout(() => {
957
+ staleDraftObserver?.disconnect();
958
+ staleDraftObserver = null;
959
+ staleDraftObserverTimer = null;
960
+ }, 10_000);
961
+ }
962
+ const unsubscribe = ctx.sessions?.list?.subscribe?.((snapshot) => {
963
+ const nextSessionId = snapshot?.current;
964
+ if (activeCleaningSessionId && nextSessionId && nextSessionId !== activeCleaningSessionId) {
965
+ leaveSubsystem();
966
+ } else if (!activeCleaningSessionId) {
967
+ clearStaleDefaultDraft();
968
+ }
969
+ });
970
+ return () => {
971
+ document.removeEventListener?.('click', handleSidebarNavigation, true);
972
+ staleDraftObserver?.disconnect();
973
+ if (staleDraftObserverTimer !== null) clearTimeout(staleDraftObserverTimer);
974
+ if (typeof unsubscribe === 'function') unsubscribe();
975
+ };
787
976
  }
788
977
 
789
978
  function optionLabels(options, keys) {
@@ -796,6 +985,7 @@ window.__ModuleLoader__.load({
796
985
  const mode = config.mode ?? 'text';
797
986
  const cleanLabels = optionLabels(CLEANING_OPTIONS, config.cleaningKeys);
798
987
  const enrichLabels = optionLabels(ENRICHMENT_OPTIONS, config.enrichmentKeys);
988
+ const anchorLabels = optionLabels(MATCH_ANCHOR_OPTIONS, config.anchorKeys);
799
989
  const entries = Array.isArray(config.entries)
800
990
  ? config.entries.map((value) => String(value ?? '').trim()).filter(Boolean)
801
991
  : [];
@@ -817,23 +1007,188 @@ window.__ModuleLoader__.load({
817
1007
  }
818
1008
  lines.push(`清洗要求:${cleanLabels.length ? cleanLabels.join('、') : '仅解析,不自动修改'}。`);
819
1009
  lines.push(`需要补全的字段 / 维度:${enrichLabels.length ? enrichLabels.join('、') : '不调用外部补全,仅输出本地清洗结果'}。`);
820
- lines.push('处理规则:企业全称、统一社会信用代码或注册号优先精确匹配;精确匹配失败时再进入模糊候选,存在多个候选必须暂停并让我确认,不得默认选择第一项。');
1010
+ lines.push(`处理规则:优先使用${anchorLabels.length ? anchorLabels.join('、') : '企业名称、统一社会信用代码或注册号'}精确匹配;${config.matchRules?.manualReviewAmbiguous === false ? '精确匹配失败时直接输出未匹配记录' : '精确匹配失败时再进入模糊候选,存在多个候选必须暂停并让我确认,不得默认选择第一项'}。`);
821
1011
  lines.push('请先生成数据质量体检与匹配摘要,经我确认后再执行需要消耗额度的企查查 MCP 调用;企查查连接、套餐额度和费用均由当前用户自己的账号承担。缺失或无权限字段请留空并标记原因,不得编造。');
822
1012
  lines.push('完成后保留来源原值、标准主体、匹配状态与字段来源,并提供结果和待复核清单的导出。');
823
1013
  return lines.join('\n\n');
824
1014
  }
825
1015
 
826
- /** 后端往返:POST JSON,返回解析后的对象。 */
827
- async function api(path, body) {
1016
+ function entriesToDataset(entries) {
1017
+ // 文本向导允许企业名称和信用代码混输。统一为一个检索锚点列,避免 G5 Bridge
1018
+ // 只能选择单一 nameField 时遗漏信用代码行;QCC entity lookup 对两者均可检索。
1019
+ const rows = (Array.isArray(entries) ? entries : [])
1020
+ .map((value) => ({ 主体标识: String(value ?? '').trim() }))
1021
+ .filter((row) => row.主体标识);
1022
+ return {
1023
+ ok: true,
1024
+ fmt: 'text',
1025
+ headers: ['主体标识'],
1026
+ rowCount: rows.length,
1027
+ rows,
1028
+ preview: rows.slice(0, 5),
1029
+ };
1030
+ }
1031
+
1032
+ const MAPPING_PATTERNS = [
1033
+ ['company_name', /^(name|company|company_name|企业名称|公司名称|单位名称)$/i],
1034
+ ['credit_no', /^(credit_no|creditCode|unified_credit_code|统一社会信用代码|信用代码)$/i],
1035
+ ['reg_no', /^(reg_no|registration_no|注册号)$/i],
1036
+ ['phone', /^(phone|mobile|tel|telephone|联系电话|手机号码|手机号)$/i],
1037
+ ['province', /^(province|省份|省份地区)$/i],
1038
+ ['registered_address', /^(address|registered_address|注册地址|企业地址|公司地址)$/i],
1039
+ ];
1040
+
1041
+ function guessMappings(headers) {
1042
+ const output = [];
1043
+ const usedTargets = new Set();
1044
+ for (const sourceField of Array.isArray(headers) ? headers : []) {
1045
+ const match = MAPPING_PATTERNS.find(([targetField, pattern]) => !usedTargets.has(targetField) && pattern.test(String(sourceField)));
1046
+ if (match) {
1047
+ output.push({ sourceField: String(sourceField), targetField: match[0] });
1048
+ usedTargets.add(match[0]);
1049
+ }
1050
+ }
1051
+ if (!output.length && Array.isArray(headers) && headers[0]) {
1052
+ output.push({ sourceField: String(headers[0]), targetField: 'company_name' });
1053
+ }
1054
+ return output;
1055
+ }
1056
+
1057
+ function qualitySummaryFor(rows, mappings) {
1058
+ const list = Array.isArray(rows) ? rows : [];
1059
+ const anchors = (Array.isArray(mappings) ? mappings : []).filter((mapping) => ['company_name', 'credit_no', 'reg_no'].includes(mapping.targetField));
1060
+ const signatures = new Set();
1061
+ let missingAnchor = 0;
1062
+ let duplicates = 0;
1063
+ let invalidCreditNo = 0;
1064
+ let invalidPhone = 0;
1065
+ let emptyFields = 0;
1066
+ const creditSource = mappings?.find((mapping) => mapping.targetField === 'credit_no')?.sourceField;
1067
+ const phoneSource = mappings?.find((mapping) => mapping.targetField === 'phone')?.sourceField;
1068
+ for (const row of list) {
1069
+ const values = anchors.map((mapping) => String(row?.[mapping.sourceField] ?? '').trim()).filter(Boolean);
1070
+ if (!values.length) missingAnchor += 1;
1071
+ const signature = values.join('|').toUpperCase();
1072
+ if (signature && signatures.has(signature)) duplicates += 1;
1073
+ if (signature) signatures.add(signature);
1074
+ const credit = creditSource ? String(row?.[creditSource] ?? '').trim() : '';
1075
+ if (credit && !/^[0-9A-Z]{18}$/.test(credit)) invalidCreditNo += 1;
1076
+ const phone = phoneSource ? String(row?.[phoneSource] ?? '').replace(/[\s-]/g, '') : '';
1077
+ if (phone && !/^(?:\+?86)?(?:1\d{10}|0\d{9,11})$/.test(phone)) invalidPhone += 1;
1078
+ emptyFields += Object.values(row ?? {}).filter((value) => String(value ?? '').trim() === '').length;
1079
+ }
1080
+ return {
1081
+ total: list.length,
1082
+ valid: Math.max(0, list.length - missingAnchor),
1083
+ missingAnchor,
1084
+ duplicates,
1085
+ invalidCreditNo,
1086
+ invalidPhone,
1087
+ emptyFields,
1088
+ };
1089
+ }
1090
+
1091
+ /** 同源 Host API;保留旧 api(path, body) 调用,同时为工作流开放 PATCH。 */
1092
+ async function requestJson(path, method = 'GET', body) {
828
1093
  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,
1094
+ method,
1095
+ headers: body !== undefined ? { 'content-type': 'application/json' } : undefined,
1096
+ body: body !== undefined ? JSON.stringify(body) : undefined,
1097
+ });
1098
+ const payload = await res.json();
1099
+ if (res.ok === false || payload?.ok === false) {
1100
+ const error = new Error(payload?.message || payload?.error || `Host request failed (${res.status})`);
1101
+ error.code = payload?.code;
1102
+ throw error;
1103
+ }
1104
+ return payload;
1105
+ }
1106
+
1107
+ async function api(path, body) {
1108
+ return requestJson(path, body === undefined ? 'GET' : 'POST', body);
1109
+ }
1110
+
1111
+ async function waitForQccCommand(commandId, attempts = 180) {
1112
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
1113
+ const response = await api(`/data-cleaning/api/g5/commands/${encodeURIComponent(commandId)}`);
1114
+ const command = response?.command;
1115
+ if (command?.state === 'completed' || command?.state === 'failed') return command;
1116
+ await new Promise((resolve) => setTimeout(resolve, 1000));
1117
+ }
1118
+ const error = new Error('等待智能体执行企查查任务超时;可稍后从任务历史恢复结果。');
1119
+ error.code = 'QCC_COMMAND_TIMEOUT';
1120
+ throw error;
1121
+ }
1122
+
1123
+ function cacheWorkflowTask(actions, sessionId, task) {
1124
+ if (!task) return null;
1125
+ if (sessionId) workflowTaskBySession.set(String(sessionId), task);
1126
+ actions.setWorkflowTask(task);
1127
+ if (task.title) actions.setTaskTitle(task.title);
1128
+ if (Array.isArray(task.objectives)) actions.setObjectives(task.objectives);
1129
+ if (Array.isArray(task.fieldSelection)) actions.setFieldSelection(task.fieldSelection);
1130
+ if (Array.isArray(task.mappings) && task.mappings.length) actions.setMappings(task.mappings);
1131
+ return task;
1132
+ }
1133
+
1134
+ async function ensureWorkflowTask(actions, sessionId, draft = {}) {
1135
+ const key = String(sessionId || 'unassigned');
1136
+ const cached = workflowTaskBySession.get(key);
1137
+ if (cached) return cacheWorkflowTask(actions, sessionId, cached);
1138
+ let pending = workflowTaskCreationBySession.get(key);
1139
+ if (!pending) {
1140
+ pending = requestJson('/data-cleaning/api/workflow/tasks', 'POST', {
1141
+ title: draft.title || '企业数据清洗补全任务',
1142
+ objectives: draft.objectives || DEFAULT_CLEANING_KEYS,
1143
+ fieldSelection: draft.fieldSelection || DEFAULT_ENRICHMENT_KEYS,
1144
+ mappings: draft.mappings || [],
1145
+ matchRules: draft.matchRules,
1146
+ });
1147
+ workflowTaskCreationBySession.set(key, pending);
1148
+ const clearPending = () => {
1149
+ if (workflowTaskCreationBySession.get(key) === pending) workflowTaskCreationBySession.delete(key);
1150
+ };
1151
+ pending.then(clearPending, clearPending);
1152
+ }
1153
+ const response = await pending;
1154
+ moveRuntime(`session:${key}`, response.task.id);
1155
+ return cacheWorkflowTask(actions, sessionId, response.task);
1156
+ }
1157
+
1158
+ /** 同一会话的 Host 写操作串行化,确保 expectedRevision 不因 UI 事件竞态失效。 */
1159
+ function queueWorkflowOperation(sessionId, operation) {
1160
+ const key = String(sessionId || 'unassigned');
1161
+ const previous = workflowOperationBySession.get(key) ?? Promise.resolve();
1162
+ const next = previous.catch(() => {}).then(operation);
1163
+ workflowOperationBySession.set(key, next);
1164
+ const clear = () => {
1165
+ if (workflowOperationBySession.get(key) === next) workflowOperationBySession.delete(key);
1166
+ };
1167
+ next.then(clear, clear);
1168
+ return next;
1169
+ }
1170
+
1171
+ async function updateWorkflowTask(actions, sessionId, task, input) {
1172
+ const latest = workflowTaskBySession.get(String(sessionId || 'unassigned')) ?? task;
1173
+ if (!latest) return ensureWorkflowTask(actions, sessionId, input);
1174
+ const response = await requestJson(`/data-cleaning/api/workflow/tasks/${encodeURIComponent(latest.id)}`, 'PATCH', {
1175
+ ...input,
1176
+ expectedRevision: latest.revision,
832
1177
  });
833
- return res.json();
1178
+ return cacheWorkflowTask(actions, sessionId, response.task);
834
1179
  }
835
1180
 
836
- /** 工作台 store:入口按钮与工作台共享 open,另存四步流程 UI 状态。 */
1181
+ async function workflowAction(actions, sessionId, task, action, input = {}) {
1182
+ const latest = workflowTaskBySession.get(String(sessionId || 'unassigned')) ?? task;
1183
+ if (!latest) throw new Error('请先创建数据清洗补全任务。');
1184
+ const response = await requestJson(`/data-cleaning/api/workflow/tasks/${encodeURIComponent(latest.id)}/actions/${action}`, 'POST', {
1185
+ ...input,
1186
+ expectedRevision: latest.revision,
1187
+ });
1188
+ return cacheWorkflowTask(actions, sessionId, response.task);
1189
+ }
1190
+
1191
+ /** 工作台 store:入口按钮与工作台共享 open,并保存当前 taskId 的可持久化 UI 元数据。 */
837
1192
  function createWorkbenchStore() {
838
1193
  return defineStore({
839
1194
  init: () => ({
@@ -843,6 +1198,14 @@ window.__ModuleLoader__.load({
843
1198
  busy: false,
844
1199
  error: null,
845
1200
  input: '',
1201
+ workflowContract: null,
1202
+ workflowTask: null,
1203
+ workflowTasks: [],
1204
+ taskTitle: '企业数据清洗补全任务',
1205
+ mappings: [],
1206
+ objectives: [...DEFAULT_CLEANING_KEYS],
1207
+ fieldSelection: [...DEFAULT_ENRICHMENT_KEYS],
1208
+ matchRules: { normalizeNames: true, preferCreditNo: true, deduplicate: true, manualReviewAmbiguous: true },
846
1209
  dataset: null, // { fmt, headers, rowCount, preview }
847
1210
  profile: null, // summary
848
1211
  clean: null, // summary
@@ -870,6 +1233,46 @@ window.__ModuleLoader__.load({
870
1233
  setBusy: (draft, busy) => { draft.busy = busy; },
871
1234
  setError: (draft, error) => { draft.error = error; },
872
1235
  setInput: (draft, input) => { draft.input = input; },
1236
+ setWorkflowContract: (draft, value) => { draft.workflowContract = value; },
1237
+ setWorkflowTask: (draft, value) => { draft.workflowTask = value ?? null; },
1238
+ setWorkflowTasks: (draft, value) => { draft.workflowTasks = Array.isArray(value) ? value : []; },
1239
+ setTaskTitle: (draft, value) => { draft.taskTitle = String(value ?? ''); },
1240
+ setMappings: (draft, value) => { draft.mappings = Array.isArray(value) ? value : []; },
1241
+ setMapping: (draft, sourceField, targetField) => {
1242
+ const mappings = draft.mappings.filter((item) => item.sourceField !== sourceField && item.targetField !== targetField);
1243
+ if (targetField) mappings.push({ sourceField, targetField });
1244
+ draft.mappings = mappings;
1245
+ },
1246
+ setObjectives: (draft, value) => { draft.objectives = Array.isArray(value) ? value : []; },
1247
+ toggleObjective: (draft, value) => {
1248
+ const selected = new Set(draft.objectives);
1249
+ if (selected.has(value)) selected.delete(value); else selected.add(value);
1250
+ draft.objectives = [...selected];
1251
+ },
1252
+ setFieldSelection: (draft, value) => { draft.fieldSelection = Array.isArray(value) ? value : []; },
1253
+ toggleField: (draft, value) => {
1254
+ const selected = new Set(draft.fieldSelection);
1255
+ if (selected.has(value)) selected.delete(value); else selected.add(value);
1256
+ draft.fieldSelection = [...selected];
1257
+ },
1258
+ setMatchRule: (draft, key, value) => { draft.matchRules = { ...draft.matchRules, [key]: Boolean(value) }; },
1259
+ resetWorkflow: (draft) => {
1260
+ draft.step = 'upload';
1261
+ draft.workflowTask = null;
1262
+ draft.taskTitle = '企业数据清洗补全任务';
1263
+ draft.mappings = [];
1264
+ draft.objectives = [...DEFAULT_CLEANING_KEYS];
1265
+ draft.fieldSelection = [...DEFAULT_ENRICHMENT_KEYS];
1266
+ draft.matchRules = { normalizeNames: true, preferCreditNo: true, deduplicate: true, manualReviewAmbiguous: true };
1267
+ draft.dataset = null;
1268
+ draft.profile = null;
1269
+ draft.clean = null;
1270
+ draft.complete = null;
1271
+ draft.qccEstimate = null;
1272
+ draft.qccRun = null;
1273
+ draft.paidConfirmed = false;
1274
+ draft.error = null;
1275
+ },
873
1276
  setDataset: (draft, dataset) => { draft.dataset = dataset; draft.error = null; },
874
1277
  setProfile: (draft, profile) => { draft.profile = profile; draft.error = null; },
875
1278
  setClean: (draft, clean) => { draft.clean = clean; draft.error = null; },
@@ -967,6 +1370,15 @@ window.__ModuleLoader__.load({
967
1370
  return images.length;
968
1371
  }
969
1372
 
1373
+ async function sendQccAgentCommand(ctx, sessionId, prompt) {
1374
+ const scoped = typeof ctx.sessions?.scope === 'function' ? ctx.sessions.scope(sessionId) : null;
1375
+ const sessionConversation = scoped?.get?.('conversation');
1376
+ if (!sessionConversation || typeof sessionConversation.send !== 'function') {
1377
+ throw new Error('当前 DSH 版本没有可用的 Session conversation.send 能力');
1378
+ }
1379
+ await sessionConversation.send(prompt);
1380
+ }
1381
+
970
1382
  function capabilityIcon(item, size = 16) {
971
1383
  return typeof item.icon === 'function'
972
1384
  ? h(item.icon, { size, 'aria-hidden': 'true' })
@@ -980,38 +1392,55 @@ window.__ModuleLoader__.load({
980
1392
 
981
1393
  const WORKBENCH_OPEN_EVENT = 'dsh:data-cleaning-workbench-open';
982
1394
  const WORKBENCH_DATASET_EVENT = 'dsh:data-cleaning-workbench-dataset';
1395
+ const WORKBENCH_DRAFT_EVENT = 'dsh:data-cleaning-workbench-draft';
983
1396
 
984
1397
  // session scope 不能复用 root scope 的 store handle。DSH 当前会分别物化 root/session
985
1398
  // 插槽组件,因此以 document 事件跨 scope 通知;闭包引用仅作为无 DOM 测试降级。
986
1399
  let rootWorkbenchActions = null;
987
1400
 
988
1401
  /** 从 session scope 的 composer/header 触发 root scope 工作台。 */
989
- function requestWorkbenchOpen(step, sessionId) {
1402
+ function requestWorkbenchOpen(step, sessionId, task = null) {
990
1403
  if (typeof document !== 'undefined'
991
1404
  && typeof document.createEvent === 'function'
992
1405
  && typeof document.dispatchEvent === 'function') {
993
1406
  const event = document.createEvent('CustomEvent');
994
- event.initCustomEvent(WORKBENCH_OPEN_EVENT, false, true, { step, sessionId });
1407
+ event.initCustomEvent(WORKBENCH_OPEN_EVENT, false, true, { step, sessionId, task });
995
1408
  if (!document.dispatchEvent(event)) return;
996
1409
  }
997
1410
  if (rootWorkbenchActions) openWorkbench(rootWorkbenchActions, step, sessionId);
998
1411
  }
999
1412
 
1000
1413
  /** 把提示词生成器解析出的完整表格交给 root 工作台,不把整表塞进模型上下文。 */
1001
- function requestWorkbenchDataset(result, sessionId) {
1414
+ function requestWorkbenchDataset(result, sessionId, source) {
1002
1415
  if (typeof document !== 'undefined'
1003
1416
  && typeof document.createEvent === 'function'
1004
1417
  && typeof document.dispatchEvent === 'function') {
1005
1418
  const event = document.createEvent('CustomEvent');
1006
- event.initCustomEvent(WORKBENCH_DATASET_EVENT, false, true, { result, sessionId });
1419
+ event.initCustomEvent(WORKBENCH_DATASET_EVENT, false, true, { result, sessionId, source });
1007
1420
  if (!document.dispatchEvent(event)) return;
1008
1421
  }
1009
1422
  if (rootWorkbenchActions) {
1010
- applyParsed(result, rootWorkbenchActions);
1423
+ applyParsed(result, rootWorkbenchActions, `session:${sessionId || 'unassigned'}`, source);
1011
1424
  rootWorkbenchActions.setActiveSession(sessionId);
1012
1425
  }
1013
1426
  }
1014
1427
 
1428
+ /** 提示词向导只提交可持久化的任务设置;名单明细仍通过 dataset 事件进入 task runtime。 */
1429
+ function requestWorkbenchDraft(draft, sessionId) {
1430
+ if (typeof document !== 'undefined'
1431
+ && typeof document.createEvent === 'function'
1432
+ && typeof document.dispatchEvent === 'function') {
1433
+ const event = document.createEvent('CustomEvent');
1434
+ event.initCustomEvent(WORKBENCH_DRAFT_EVENT, false, true, { draft, sessionId });
1435
+ if (!document.dispatchEvent(event)) return;
1436
+ }
1437
+ if (rootWorkbenchActions) {
1438
+ rootWorkbenchActions.setTaskTitle(draft.title);
1439
+ rootWorkbenchActions.setObjectives(draft.objectives);
1440
+ rootWorkbenchActions.setFieldSelection(draft.fieldSelection);
1441
+ }
1442
+ }
1443
+
1015
1444
  /** 左栏入口按钮:公开 footer 托管,真实按钮 Portal 到「新会话 / 工作区」之间。 */
1016
1445
  function SidebarEntry(props) {
1017
1446
  const { wide, useStore, actions, startSession } = props;
@@ -1083,16 +1512,41 @@ window.__ModuleLoader__.load({
1083
1512
  );
1084
1513
  }
1085
1514
 
1086
- function ProductHome() {
1515
+ const WORKFLOW_STATE_LABELS = {
1516
+ draft: '待上传', uploaded: '待确认规则', rules_confirmed: '规则已确认', diagnosed: '体检完成',
1517
+ matching: '匹配中', review_required: '待人工核验', matched: '匹配完成', enriching: '补全中',
1518
+ export_ready: '可下载', completed: '已完成', partial: '部分完成', failed: '失败',
1519
+ authorization_required: '待连接企查查', cancelled: '已取消', parse_failed: '解析失败',
1520
+ };
1521
+
1522
+ function ProductHome(props = {}) {
1523
+ const { sessionId } = props;
1524
+ const [recentTasks, setRecentTasks] = react.useState([]);
1087
1525
  const flow = [
1088
- ['01', '录入名单', '文本、Excel 或图片'],
1089
- ['02', '质量体检', '缺失、重复与格式诊断'],
1090
- ['03', '主体匹配', '精确优先,多候选复核'],
1091
- ['04', '补全导出', '按需选字段,保留来源'],
1526
+ ['01', '任务设置', '提示词向导明确目标'],
1527
+ ['02', '上传数据', '文本、Excel、JSON 或图片'],
1528
+ ['03', '规则确认', '字段映射与匹配主键'],
1529
+ ['04', '质量体检', '缺失、重复与格式诊断'],
1530
+ ['05', '数据匹配', '精确优先,多候选复核'],
1531
+ ['06', '清洗补全', '按需选择当前企业字段'],
1532
+ ['07', '下载数据', '保留原值、依据与状态'],
1092
1533
  ];
1534
+ react.useEffect(() => {
1535
+ let disposed = false;
1536
+ requestJson('/data-cleaning/api/workflow/tasks').then((response) => {
1537
+ if (!disposed) setRecentTasks((response.tasks || []).slice(0, 3));
1538
+ }).catch(() => {});
1539
+ return () => { disposed = true; };
1540
+ }, [sessionId]);
1093
1541
  return h('section', { className: 'dcAgentProductHome', 'aria-label': '数据清洗补全产品介绍' },
1094
- h('h2', null, '把企业名单变成可核验、可回写的标准数据'),
1095
- h('p', null, '面向销售线索、客户尽调、供应商管理与 CRM / ERP 数据治理,先本地体检清洗,再按需使用当前用户连接的企查查 MCP 补全。'),
1542
+ h('div', { className: 'dcAgentHomeHero' },
1543
+ h('div', null,
1544
+ h('span', { className: 'dcAgentHomeEyebrow' }, '企业主数据治理工作台'),
1545
+ h('h2', null, '把企业名单变成可核验、可回写的标准数据'),
1546
+ h('p', null, '面向销售线索、客户尽调、供应商管理与 CRM / ERP 数据治理,先确认任务和规则,再按需使用当前用户连接的企查查 MCP。'),
1547
+ ),
1548
+ h('button', { type: 'button', className: 'dcAgentButton is-primary', onClick: () => requestWorkbenchOpen('upload', sessionId) }, '开始新任务'),
1549
+ ),
1096
1550
  h('div', { className: 'dcAgentHomeFlow', 'aria-label': '数据清洗补全工作流' },
1097
1551
  flow.map(([index, title, hint]) => h('div', { className: 'dcAgentHomeStep', key: index },
1098
1552
  h('b', null, index),
@@ -1106,12 +1560,33 @@ window.__ModuleLoader__.load({
1106
1560
  h('span', null, '客户自带企查查连接与额度'),
1107
1561
  h('span', null, '结果与待复核清单可导出'),
1108
1562
  ),
1563
+ h('div', { className: 'dcAgentHomeActions' },
1564
+ h('button', { type: 'button', onClick: () => requestWorkbenchOpen('profile', sessionId) }, '🩺 查看质量体检'),
1565
+ h('button', { type: 'button', onClick: () => requestWorkbenchOpen('match', sessionId) }, '🔎 进入匹配核验'),
1566
+ h('button', { type: 'button', onClick: () => requestWorkbenchOpen('history', sessionId) }, '◷ 查看任务历史'),
1567
+ ),
1568
+ recentTasks.length ? h('div', { className: 'dcAgentHomeRecent', 'aria-label': '最近任务' },
1569
+ h('b', null, '最近任务'),
1570
+ recentTasks.map((task) => h('button', {
1571
+ key: task.id,
1572
+ type: 'button',
1573
+ onClick: () => requestWorkbenchOpen(task.stage || 'upload', sessionId, task),
1574
+ },
1575
+ h('span', null, task.title || '未命名任务'),
1576
+ h('small', null, WORKFLOW_STATE_LABELS[task.state] || task.state),
1577
+ )),
1578
+ ) : null,
1109
1579
  );
1110
1580
  }
1111
1581
 
1112
1582
  /**
1113
1583
  * DSH 当前只公开 hero brand.mark,没有 headline slot。这里仅在本插件会话且 blank hero
1114
- * 阶段精确替换原生中英文标题,并在卸载时恢复,避免污染普通会话。
1584
+ * 阶段替换标题,并在卸载时恢复,避免污染普通会话。
1585
+ *
1586
+ * 某些第三方插件会全局改写 hero headline、向所有会话注入自己的 dock。清洗会话必须
1587
+ * 仍然保持自己的标题和单一业务入口,因此用 headline class 作为最后降级,并只隐藏与
1588
+ * 当前清洗 hero 同一 composer stack 内、带明确「尽调类型」aria 标签的已知外来 dock。
1589
+ * 所有变更均记录原值并在会话离开 / 组件卸载时恢复。
1115
1590
  */
1116
1591
  function rewriteHeroChrome(sessionId, enabled) {
1117
1592
  if (!enabled || typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') return () => {};
@@ -1119,32 +1594,63 @@ window.__ModuleLoader__.load({
1119
1594
  .find((element) => element?.dataset?.sessionId === sessionId);
1120
1595
  const hero = marker?.closest?.('[data-phase="hero"]');
1121
1596
  if (!hero) return () => {};
1122
- const spans = [...hero.querySelectorAll('span')];
1123
- const title = spans.find((element) => ['探索未至之境', 'Into the Unknown'].includes(element.textContent?.trim()))
1124
- ?? spans.find((element) => element.dataset?.dcAgentHeroTitle === 'true');
1125
- const badge = spans.find((element) => ['预览版', 'Preview'].includes(element.textContent?.trim()))
1126
- ?? spans.find((element) => element.dataset?.dcAgentHeroBadge === 'true');
1127
- if (title) {
1128
- title.dataset.dcAgentHeroTitle = 'true';
1129
- title.dataset.dcAgentOriginalText ??= title.textContent ?? '';
1130
- title.textContent = '数据清洗补全智能体';
1131
- }
1132
- if (badge) {
1133
- badge.dataset.dcAgentHeroBadge = 'true';
1134
- badge.dataset.dcAgentOriginalDisplay ??= badge.style?.display ?? '';
1135
- if (badge.style) badge.style.display = 'none';
1136
- }
1597
+ const changedTitles = new Map();
1598
+ const changedBadges = new Map();
1599
+ const hiddenForeignDocks = new Map();
1600
+ const composerStack = marker?.parentElement?.parentElement;
1601
+
1602
+ const fix = () => {
1603
+ const spans = [...hero.querySelectorAll('span')];
1604
+ const title = spans.find((element) => element.dataset?.dcAgentHeroTitle === 'true')
1605
+ ?? spans.find((element) => ['探索未至之境', 'Into the Unknown'].includes(element.textContent?.trim()))
1606
+ ?? hero.querySelector?.('[class*="headlineText"]');
1607
+ const badge = spans.find((element) => element.dataset?.dcAgentHeroBadge === 'true')
1608
+ ?? spans.find((element) => ['预览版', 'Preview'].includes(element.textContent?.trim()));
1609
+ if (title) {
1610
+ if (!changedTitles.has(title)) changedTitles.set(title, title.textContent ?? '');
1611
+ title.dataset.dcAgentHeroTitle = 'true';
1612
+ if (title.textContent !== '数据清洗补全智能体') title.textContent = '数据清洗补全智能体';
1613
+ }
1614
+ if (badge) {
1615
+ if (!changedBadges.has(badge)) changedBadges.set(badge, badge.style?.display ?? '');
1616
+ badge.dataset.dcAgentHeroBadge = 'true';
1617
+ if (badge.style && badge.style.display !== 'none') badge.style.display = 'none';
1618
+ }
1619
+
1620
+ const foreignSurfaces = composerStack?.querySelectorAll?.('[aria-label="尽调类型"], .qccDock') ?? [];
1621
+ for (const foreignSurface of foreignSurfaces) {
1622
+ let hideTarget = foreignSurface;
1623
+ while (hideTarget?.parentElement
1624
+ && hideTarget.parentElement !== composerStack
1625
+ && hideTarget.parentElement !== marker.parentElement) {
1626
+ hideTarget = hideTarget.parentElement;
1627
+ }
1628
+ // DSH 的 list slot 可能把多个插件放进同一个 data-slot 容器。此时只能隐藏
1629
+ // 对方自己的根节点,不能隐藏共享 slot;独立 host 则隐藏其直接容器。
1630
+ if ((hideTarget?.parentElement === composerStack || hideTarget?.parentElement === marker.parentElement)
1631
+ && !hideTarget.contains?.(marker)) {
1632
+ if (!hiddenForeignDocks.has(hideTarget)) hiddenForeignDocks.set(hideTarget, hideTarget.style?.display ?? '');
1633
+ if (hideTarget.style && hideTarget.style.display !== 'none') hideTarget.style.display = 'none';
1634
+ }
1635
+ }
1636
+ };
1637
+
1638
+ fix();
1639
+ const observer = typeof MutationObserver === 'function' ? new MutationObserver(fix) : null;
1640
+ observer?.observe?.(hero, { childList: true, subtree: true });
1137
1641
  return () => {
1138
- if (title?.dataset?.dcAgentOriginalText !== undefined) {
1139
- title.textContent = title.dataset.dcAgentOriginalText;
1140
- delete title.dataset.dcAgentOriginalText;
1642
+ observer?.disconnect?.();
1643
+ for (const [title, originalText] of changedTitles) {
1644
+ if (title.textContent === '数据清洗补全智能体') title.textContent = originalText;
1141
1645
  delete title.dataset.dcAgentHeroTitle;
1142
1646
  }
1143
- if (badge?.dataset?.dcAgentOriginalDisplay !== undefined) {
1144
- if (badge.style) badge.style.display = badge.dataset.dcAgentOriginalDisplay;
1145
- delete badge.dataset.dcAgentOriginalDisplay;
1647
+ for (const [badge, originalDisplay] of changedBadges) {
1648
+ if (badge.style) badge.style.display = originalDisplay;
1146
1649
  delete badge.dataset.dcAgentHeroBadge;
1147
1650
  }
1651
+ for (const [foreignDock, originalDisplay] of hiddenForeignDocks) {
1652
+ if (foreignDock.style) foreignDock.style.display = originalDisplay;
1653
+ }
1148
1654
  };
1149
1655
  }
1150
1656
 
@@ -1161,8 +1667,8 @@ window.__ModuleLoader__.load({
1161
1667
  className: `dcAgentExperience${hero ? ' is-home' : ''}`,
1162
1668
  'data-session-id': sessionId,
1163
1669
  },
1164
- CapabilityBar({ sessionId }),
1165
- hero ? ProductHome() : null,
1670
+ h(CapabilityBar, { sessionId }),
1671
+ hero ? h(ProductHome, { sessionId }) : null,
1166
1672
  );
1167
1673
  }
1168
1674
 
@@ -1186,6 +1692,7 @@ window.__ModuleLoader__.load({
1186
1692
  const { sessionId, inputActions, attachImages } = props;
1187
1693
  const enabled = useCleaningSession(sessionId);
1188
1694
  const [open, setOpen] = react.useState(false);
1695
+ const [wizardStep, setWizardStep] = react.useState(1);
1189
1696
  const [mode, setMode] = react.useState('text');
1190
1697
  const [rawText, setRawText] = react.useState('');
1191
1698
  const [entries, setEntries] = react.useState([]);
@@ -1195,6 +1702,13 @@ window.__ModuleLoader__.load({
1195
1702
  const [imageAttached, setImageAttached] = react.useState(false);
1196
1703
  const [cleaningKeys, setCleaningKeys] = react.useState(DEFAULT_CLEANING_KEYS);
1197
1704
  const [enrichmentKeys, setEnrichmentKeys] = react.useState(DEFAULT_ENRICHMENT_KEYS);
1705
+ const [anchorKeys, setAnchorKeys] = react.useState(['company_name', 'credit_no']);
1706
+ const [matchRules, setPromptMatchRules] = react.useState({
1707
+ normalizeNames: true,
1708
+ preferCreditNo: true,
1709
+ deduplicate: true,
1710
+ manualReviewAmbiguous: true,
1711
+ });
1198
1712
  const [busy, setBusy] = react.useState(false);
1199
1713
  const [error, setError] = react.useState(null);
1200
1714
 
@@ -1226,7 +1740,9 @@ window.__ModuleLoader__.load({
1226
1740
  setEntries(extracted);
1227
1741
  setEntryCount(Number.isFinite(result.rowCount) ? result.rowCount : (Array.isArray(result.rows) ? result.rows.length : extracted.length));
1228
1742
  setSpreadsheetFileName(file.name);
1229
- requestWorkbenchDataset(result, sessionId);
1743
+ requestWorkbenchDataset(result, sessionId, {
1744
+ type: result.fmt || 'xlsx', fileName: file.name, sizeBytes: file.size || 0,
1745
+ });
1230
1746
  }
1231
1747
  } catch (fileError) {
1232
1748
  setError(fileError instanceof Error ? fileError.message : String(fileError));
@@ -1235,35 +1751,61 @@ window.__ModuleLoader__.load({
1235
1751
  if (event.target) event.target.value = '';
1236
1752
  }
1237
1753
  };
1754
+ const selectedEntries = () => mode === 'text'
1755
+ ? rawText.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
1756
+ : entries;
1757
+ const validateSource = () => {
1758
+ const values = selectedEntries();
1759
+ if (mode === 'text' && !values.length) return '请先录入企业名称或统一社会信用代码,每行一条。';
1760
+ if (mode === 'excel' && !values.length) return '请先上传并解析 Excel / CSV / JSON 文件。';
1761
+ if (mode === 'image' && !imageAttached) return '请先选择并附加一张包含企业名单的图片。';
1762
+ return null;
1763
+ };
1764
+ const promptConfig = () => ({
1765
+ mode,
1766
+ entries: selectedEntries(),
1767
+ entryCount: mode === 'excel' ? entryCount : selectedEntries().length,
1768
+ fileName: mode === 'image' ? imageFileName : spreadsheetFileName,
1769
+ cleaningKeys,
1770
+ enrichmentKeys,
1771
+ anchorKeys,
1772
+ matchRules,
1773
+ });
1238
1774
  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
- }
1775
+ const sourceError = validateSource();
1776
+ if (sourceError) { setError(sourceError); return; }
1777
+ if (!anchorKeys.length) { setError('请至少选择一个企业主体匹配主键。'); return; }
1778
+ if (!cleaningKeys.length) { setError('请至少选择一个清洗或补全目标。'); return; }
1253
1779
  if (!inputActions || typeof inputActions.setDraft !== 'function') {
1254
1780
  setError('当前会话输入机尚未就绪,请稍后重试。');
1255
1781
  return;
1256
1782
  }
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
- }));
1783
+ const config = promptConfig();
1784
+ inputActions.setDraft(buildTaskPrompt(config));
1785
+ if (mode === 'text') {
1786
+ requestWorkbenchDataset(entriesToDataset(config.entries), sessionId, { type: 'text', fileName: '' });
1787
+ }
1788
+ requestWorkbenchDraft({
1789
+ title: `企业数据清洗补全任务 · ${config.entryCount || '待解析'} 条`,
1790
+ objectives: cleaningKeys,
1791
+ fieldSelection: enrichmentKeys,
1792
+ matchRules,
1793
+ source: { type: mode === 'excel' ? 'xlsx' : mode, fileName: config.fileName || '', rowCount: config.entryCount || 0 },
1794
+ }, sessionId);
1265
1795
  setError(null);
1266
1796
  setOpen(false);
1797
+ setWizardStep(1);
1798
+ };
1799
+
1800
+ const nextWizard = () => {
1801
+ if (wizardStep === 1) {
1802
+ const sourceError = validateSource();
1803
+ if (sourceError) { setError(sourceError); return; }
1804
+ }
1805
+ if (wizardStep === 2 && !anchorKeys.length) { setError('请至少选择一个匹配主键。'); return; }
1806
+ if (wizardStep === 3 && !cleaningKeys.length) { setError('请至少选择一个清洗或补全目标。'); return; }
1807
+ if (wizardStep < 4) { setError(null); setWizardStep(wizardStep + 1); return; }
1808
+ generatePrompt();
1267
1809
  };
1268
1810
 
1269
1811
  const choice = (options, values, setter) => options.map(([key, label]) => h('label', {
@@ -1305,49 +1847,75 @@ window.__ModuleLoader__.load({
1305
1847
  ),
1306
1848
  h('button', { type: 'button', className: 'dcAgentPromptClose', 'aria-label': '关闭提示词生成', onClick: () => setOpen(false) }, '✕'),
1307
1849
  ),
1308
- h('div', { className: 'dcAgentPromptTabs', role: 'tablist', 'aria-label': '名单录入方式' },
1309
- [['text', '粘贴名单'], ['excel', '上传 Excel'], ['image', '上传图片']].map(([key, label]) => h('button', {
1310
- key,
1850
+ h('nav', { className: 'dcAgentWizardNav', 'aria-label': '任务设置步骤' },
1851
+ [['1', '数据来源'], ['2', '匹配规则'], ['3', '清洗与补全'], ['4', '确认描述']].map(([index, label]) => h('button', {
1852
+ key: index,
1311
1853
  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)),
1854
+ className: `dcAgentWizardStep${wizardStep === Number(index) ? ' is-active' : ''}`,
1855
+ 'aria-current': wizardStep === Number(index) ? 'step' : undefined,
1856
+ onClick: () => setWizardStep(Number(index)),
1857
+ }, h('b', null, index), h('span', null, label))),
1341
1858
  ),
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”的任务要求;插件不会虚构或硬编码未验证的工具名。企查查调用使用当前客户自己的连接、账号与额度。'),
1859
+ wizardStep === 1 ? h('div', { className: 'dcAgentWizardPane' },
1860
+ h('h4', null, '先告诉我数据从哪里来'),
1861
+ h('p', null, '企业名称、统一社会信用代码、注册号任一项都可作为匹配起点。'),
1862
+ h('div', { className: 'dcAgentPromptTabs', role: 'tablist', 'aria-label': '名单录入方式' },
1863
+ [['text', '粘贴企业名单'], ['excel', '上传本地文件'], ['image', '上传图片识别']].map(([key, label]) => h('button', {
1864
+ key, type: 'button', role: 'tab', 'aria-selected': mode === key,
1865
+ className: `dcAgentPromptTab${mode === key ? ' is-active' : ''}`,
1866
+ onClick: () => setSourceMode(key),
1867
+ }, label)),
1868
+ ),
1869
+ mode === 'text' ? h('label', { className: 'dcAgentPromptField' },
1870
+ h('span', null, '企业名称或统一社会信用代码(每行一条)'),
1871
+ h('textarea', {
1872
+ className: 'dcAgentPromptText', value: rawText,
1873
+ placeholder: '企查查科技股份有限公司\n9132…\n某某信息技术(上海)有限公司',
1874
+ onChange: (event) => setRawText(event.target.value),
1875
+ }),
1876
+ ) : h('label', { className: 'dcAgentPromptFile' },
1877
+ h('span', null, mode === 'image' ? '选择包含企业名单的图片' : '选择 CSV / XLSX / JSON 名单'),
1878
+ h('input', {
1879
+ type: 'file', accept: mode === 'image' ? 'image/png,image/jpeg,image/webp' : '.xlsx,.xls,.csv,.json',
1880
+ disabled: busy, onChange: handleFile,
1881
+ }),
1882
+ (mode === 'excel' ? spreadsheetFileName : imageFileName)
1883
+ ? h('b', null, `${mode === 'excel' ? spreadsheetFileName : imageFileName}${mode === 'excel' ? ` · ${entryCount} 条` : ' · 已附加'}`)
1884
+ : null,
1885
+ ),
1886
+ ) : null,
1887
+ wizardStep === 2 ? h('div', { className: 'dcAgentWizardPane' },
1888
+ h('h4', null, '确认匹配规则'),
1889
+ h('p', null, '强标识优先;名称定位不明确时生成候选,不自动写回。'),
1890
+ h('div', { className: 'dcAgentPromptChoices' }, choice(MATCH_ANCHOR_OPTIONS, anchorKeys, setAnchorKeys)),
1891
+ h('div', { className: 'dcAgentPromptRules' },
1892
+ [['normalizeNames', '匹配前统一企业名称格式'], ['preferCreditNo', '有信用代码时优先精确匹配'], ['deduplicate', '匹配前合并重复主体'], ['manualReviewAmbiguous', '多候选必须人工确认']].map(([key, label]) => h('label', { key },
1893
+ h('input', { type: 'checkbox', checked: matchRules[key], onChange: (event) => setPromptMatchRules({ ...matchRules, [key]: event.target.checked }) }), label,
1894
+ )),
1895
+ ),
1896
+ ) : null,
1897
+ wizardStep === 3 ? h('div', { className: 'dcAgentWizardPane' },
1898
+ h('h4', null, '选择清洗目标和补全字段'),
1899
+ h('p', null, '仅展示当前企业数据范围;最终可用字段以当前用户连接能力为准。'),
1900
+ h('div', { className: 'dcAgentPromptGroup' },
1901
+ h('b', null, '清洗目标'),
1902
+ h('div', { className: 'dcAgentPromptChoices' }, choice(CLEANING_OPTIONS, cleaningKeys, setCleaningKeys)),
1903
+ ),
1904
+ FIELD_GROUPS.map(([groupId, label, fields]) => h('div', { className: 'dcAgentPromptGroup', key: groupId },
1905
+ h('b', null, label),
1906
+ h('div', { className: 'dcAgentPromptChoices' }, choice(fields, enrichmentKeys, setEnrichmentKeys)),
1907
+ )),
1908
+ ) : null,
1909
+ wizardStep === 4 ? h('div', { className: 'dcAgentWizardPane' },
1910
+ h('h4', null, '确认任务描述'),
1911
+ h('p', null, '回填后仍可在对话框继续编辑;回填本身不会调用企查查 MCP。'),
1912
+ h('pre', { className: 'dcAgentPromptPreview' }, buildTaskPrompt(promptConfig())),
1913
+ ) : null,
1914
+ h('p', { className: 'dcAgentPromptNote' }, '向导只生成任务描述并保存任务设置。只有你在任务中明确确认后,才会使用当前客户自己的企查查 MCP 连接、账号和额度。'),
1347
1915
  error ? h('p', { className: 'dcAgentPromptError', role: 'alert' }, error) : null,
1348
1916
  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 ? '处理中…' : '生成并回填'),
1917
+ h('button', { type: 'button', className: 'dcAgentPromptAction', disabled: wizardStep === 1, onClick: () => setWizardStep(Math.max(1, wizardStep - 1)) }, '上一步'),
1918
+ h('button', { type: 'button', className: 'dcAgentPromptAction is-primary', disabled: busy, onClick: nextWizard }, busy ? '处理中…' : wizardStep === 4 ? '回填到对话框' : '下一步'),
1351
1919
  ),
1352
1920
  ) : null,
1353
1921
  );
@@ -1433,6 +2001,15 @@ window.__ModuleLoader__.load({
1433
2001
  // 静默:jobs 不可用不影响工作台主流程。
1434
2002
  }
1435
2003
  }
2004
+ async function loadWorkflowTasks(actions) {
2005
+ try {
2006
+ const response = await requestJson('/data-cleaning/api/workflow/tasks');
2007
+ actions.setWorkflowTasks(response.tasks || []);
2008
+ return response.tasks || [];
2009
+ } catch (_error) {
2010
+ return [];
2011
+ }
2012
+ }
1436
2013
  function startJobsPolling(actions) {
1437
2014
  if (jobsTimer !== null) return;
1438
2015
  pollJobsOnce(actions);
@@ -1469,14 +2046,9 @@ window.__ModuleLoader__.load({
1469
2046
  if (trimmed.startsWith('[')) {
1470
2047
  try {
1471
2048
  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
- });
2049
+ const list = Array.isArray(rows) ? rows : [];
2050
+ const headers = list.length ? Object.keys(list[0]) : [];
2051
+ return { ok: true, fmt: 'json', headers, rowCount: list.length, rows: list, preview: list.slice(0, 5) };
1480
2052
  } catch (error) {
1481
2053
  actions.setError(`JSON 解析失败:${error instanceof Error ? error.message : String(error)}`);
1482
2054
  }
@@ -1485,32 +2057,70 @@ window.__ModuleLoader__.load({
1485
2057
  return api('/data-cleaning/api/mvp/parse', { filename: 'data.csv', content: trimmed });
1486
2058
  }
1487
2059
 
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]
2060
+ /** 解析成功进入 taskId runtime;不把整表写入 Host store。 */
2061
+ function applyParsed(result, actions, taskId = 'unassigned', source = {}) {
2062
+ const runtime = runtimeFor(taskId);
2063
+ runtime.rows = Array.isArray(result.rows) ? result.rows : [];
2064
+ runtime.headers = Array.isArray(result.headers) ? result.headers : [];
2065
+ runtime.source = {
2066
+ type: source.type || result.fmt || 'csv',
2067
+ fileName: source.fileName || '',
2068
+ sizeBytes: source.sizeBytes || 0,
2069
+ };
2070
+ const mappings = guessMappings(runtime.headers);
2071
+ const guessedNameField = mappings.find((mapping) => mapping.targetField === 'company_name')?.sourceField
2072
+ ?? mappings.find((mapping) => mapping.targetField === 'credit_no')?.sourceField
2073
+ ?? mappings.find((mapping) => mapping.targetField === 'reg_no')?.sourceField
2074
+ ?? runtime.headers[0]
1495
2075
  ?? 'name';
1496
2076
  actions.setNameField(guessedNameField);
2077
+ actions.setMappings(mappings);
1497
2078
  actions.setDataset({
1498
2079
  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),
2080
+ headers: runtime.headers,
2081
+ rowCount: typeof result.rowCount === 'number' ? result.rowCount : runtime.rows.length,
2082
+ preview: Array.isArray(result.preview) ? result.preview : runtime.rows.slice(0, 5),
1502
2083
  });
2084
+ return runtime;
2085
+ }
2086
+
2087
+ async function persistParsedWorkflow(actions, sessionId, result, source = {}, task) {
2088
+ let current = task ?? await ensureWorkflowTask(actions, sessionId);
2089
+ const stagingKey = `session:${sessionId || 'unassigned'}`;
2090
+ moveRuntime(stagingKey, current.id);
2091
+ if (['draft', 'uploaded', 'parse_failed'].includes(current.state)) {
2092
+ current = await workflowAction(actions, sessionId, current, 'upload', {
2093
+ source: {
2094
+ type: source.type || result.fmt || 'csv',
2095
+ fileName: source.fileName || '',
2096
+ rowCount: typeof result.rowCount === 'number' ? result.rowCount : (result.rows || []).length,
2097
+ columnCount: Array.isArray(result.headers) ? result.headers.length : 0,
2098
+ headers: Array.isArray(result.headers) ? result.headers : [],
2099
+ sizeBytes: source.sizeBytes || 0,
2100
+ },
2101
+ });
2102
+ }
2103
+ return current;
1503
2104
  }
1504
2105
 
1505
2106
  /** 右侧非模态工作台:中央区域始终保留 DSH 原生会话。 */
1506
2107
  function WorkbenchDrawer(props) {
1507
2108
  const { useStore, actions } = props;
2109
+ const sendSessionCommand = props.sendSessionCommand;
1508
2110
  const open = useStore((state) => state.open);
1509
2111
  const step = useStore((state) => state.step);
1510
2112
  const expanded = useStore((state) => state.expanded);
1511
2113
  const busy = useStore((state) => state.busy);
1512
2114
  const error = useStore((state) => state.error);
1513
2115
  const input = useStore((state) => state.input);
2116
+ const workflowContract = useStore((state) => state.workflowContract);
2117
+ const workflowTask = useStore((state) => state.workflowTask);
2118
+ const workflowTasks = useStore((state) => state.workflowTasks);
2119
+ const taskTitle = useStore((state) => state.taskTitle);
2120
+ const mappings = useStore((state) => state.mappings);
2121
+ const objectives = useStore((state) => state.objectives);
2122
+ const fieldSelection = useStore((state) => state.fieldSelection);
2123
+ const matchRules = useStore((state) => state.matchRules);
1514
2124
  const dataset = useStore((state) => state.dataset);
1515
2125
  const profile = useStore((state) => state.profile);
1516
2126
  const clean = useStore((state) => state.clean);
@@ -1529,28 +2139,75 @@ window.__ModuleLoader__.load({
1529
2139
  const handleOpen = (event) => {
1530
2140
  event?.preventDefault?.();
1531
2141
  const detail = event?.detail ?? {};
2142
+ if (detail.task?.id) {
2143
+ workflowTaskBySession.set(String(detail.sessionId || 'unassigned'), detail.task);
2144
+ actions.setWorkflowTask(detail.task);
2145
+ actions.setTaskTitle(detail.task.title);
2146
+ actions.setMappings(detail.task.mappings || []);
2147
+ actions.setObjectives(detail.task.objectives || []);
2148
+ actions.setFieldSelection(detail.task.fieldSelection || []);
2149
+ for (const [key, value] of Object.entries(detail.task.matchRules || {})) actions.setMatchRule(key, value);
2150
+ }
1532
2151
  openWorkbench(actions, detail.step ?? null, detail.sessionId ?? null);
1533
2152
  };
1534
2153
  const handleDataset = (event) => {
1535
2154
  event?.preventDefault?.();
1536
2155
  const detail = event?.detail ?? {};
1537
- if (detail.result) applyParsed(detail.result, actions);
2156
+ if (detail.result) applyParsed(detail.result, actions, `session:${detail.sessionId || 'unassigned'}`, detail.source);
2157
+ if (detail.sessionId) actions.setActiveSession(detail.sessionId);
2158
+ if (detail.result) {
2159
+ void queueWorkflowOperation(detail.sessionId, () => (
2160
+ persistParsedWorkflow(actions, detail.sessionId, detail.result, detail.source)
2161
+ )).catch((eventError) => {
2162
+ actions.setError(eventError instanceof Error ? eventError.message : String(eventError));
2163
+ });
2164
+ }
2165
+ };
2166
+ const handleDraft = (event) => {
2167
+ event?.preventDefault?.();
2168
+ const detail = event?.detail ?? {};
2169
+ const draft = detail.draft ?? {};
1538
2170
  if (detail.sessionId) actions.setActiveSession(detail.sessionId);
2171
+ if (draft.title) actions.setTaskTitle(draft.title);
2172
+ if (draft.objectives) actions.setObjectives(draft.objectives);
2173
+ if (draft.fieldSelection) actions.setFieldSelection(draft.fieldSelection);
2174
+ for (const [key, value] of Object.entries(draft.matchRules || {})) actions.setMatchRule(key, value);
2175
+ void queueWorkflowOperation(detail.sessionId, async () => {
2176
+ const task = await ensureWorkflowTask(actions, detail.sessionId, draft);
2177
+ return updateWorkflowTask(actions, detail.sessionId, task, draft);
2178
+ }).catch((eventError) => actions.setError(eventError instanceof Error ? eventError.message : String(eventError)));
1539
2179
  };
1540
2180
  if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
1541
2181
  document.addEventListener(WORKBENCH_OPEN_EVENT, handleOpen);
1542
2182
  document.addEventListener(WORKBENCH_DATASET_EVENT, handleDataset);
2183
+ document.addEventListener(WORKBENCH_DRAFT_EVENT, handleDraft);
1543
2184
  }
1544
2185
  return () => {
1545
2186
  if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
1546
2187
  document.removeEventListener(WORKBENCH_OPEN_EVENT, handleOpen);
1547
2188
  document.removeEventListener(WORKBENCH_DATASET_EVENT, handleDataset);
2189
+ document.removeEventListener(WORKBENCH_DRAFT_EVENT, handleDraft);
1548
2190
  }
1549
2191
  stopJobsPolling();
1550
2192
  if (rootWorkbenchActions === actions) rootWorkbenchActions = null;
1551
2193
  };
1552
2194
  }, [actions]);
1553
2195
 
2196
+ react.useEffect(() => {
2197
+ if (!open) return undefined;
2198
+ let disposed = false;
2199
+ requestJson('/data-cleaning/api/workflow/contract').then((response) => {
2200
+ if (!disposed) actions.setWorkflowContract(response.contract);
2201
+ }).catch(() => {});
2202
+ loadWorkflowTasks(actions);
2203
+ if (activeSessionId && !workflowTaskBySession.has(String(activeSessionId))) {
2204
+ queueWorkflowOperation(activeSessionId, () => ensureWorkflowTask(actions, activeSessionId)).catch((taskError) => {
2205
+ if (!disposed) actions.setError(taskError instanceof Error ? taskError.message : String(taskError));
2206
+ });
2207
+ }
2208
+ return () => { disposed = true; };
2209
+ }, [open, activeSessionId, actions]);
2210
+
1554
2211
  // 非模态右栏会覆盖 Host 的中央画布;按实际重叠量把 composer 能力栏左移,
1555
2212
  // 保证 Mockup 中的五个入口在窄桌面视口仍可见、可点击。展开态专注工作台,
1556
2213
  // 不强行挤压到不足一栏的剩余区域。
@@ -1596,7 +2253,12 @@ window.__ModuleLoader__.load({
1596
2253
  if (!open) return null;
1597
2254
 
1598
2255
  const hasData = dataset !== null && dataset.rowCount > 0;
1599
- const fieldByPattern = (pattern) => session.headers.find((field) => pattern.test(field)) ?? null;
2256
+ const requiresQcc = objectives.includes('validate_identity') || objectives.includes('complete_fields');
2257
+ const cachedTask = workflowTaskBySession.get(String(activeSessionId || 'unassigned')) ?? workflowTask;
2258
+ const runtimeKey = cachedTask?.id ?? `session:${activeSessionId || 'unassigned'}`;
2259
+ const runtime = runtimeFor(runtimeKey);
2260
+ const lastCsv = runtime.lastCsv;
2261
+ const fieldByPattern = (pattern) => runtime.headers.find((field) => pattern.test(field)) ?? null;
1600
2262
  const phoneField = fieldByPattern(/^(phone|mobile|tel|telephone|联系电话|手机号码|手机号)$/i);
1601
2263
  const amountField = fieldByPattern(/^(amount|price|金额|注册资本)$/i);
1602
2264
  const localCleanOptions = {
@@ -1618,7 +2280,10 @@ window.__ModuleLoader__.load({
1618
2280
  actions.setError(null);
1619
2281
  try {
1620
2282
  const result = await parseText(input, actions);
1621
- if (result) applyParsed(result, actions);
2283
+ if (result) {
2284
+ applyParsed(result, actions, runtimeKey, { type: result.fmt || 'csv' });
2285
+ await persistParsedWorkflow(actions, activeSessionId, result, { type: result.fmt || 'csv' }, cachedTask);
2286
+ }
1622
2287
  } catch (err) {
1623
2288
  actions.setError(err instanceof Error ? err.message : String(err));
1624
2289
  } finally {
@@ -1633,7 +2298,10 @@ window.__ModuleLoader__.load({
1633
2298
  actions.setError(null);
1634
2299
  try {
1635
2300
  const result = await parseFile(file);
1636
- if (result && result.ok !== false) applyParsed(result, actions);
2301
+ if (result && result.ok !== false) {
2302
+ applyParsed(result, actions, runtimeKey, { type: result.fmt || 'csv', fileName: file.name, sizeBytes: file.size || 0 });
2303
+ await persistParsedWorkflow(actions, activeSessionId, result, { type: result.fmt || 'csv', fileName: file.name, sizeBytes: file.size || 0 }, cachedTask);
2304
+ }
1637
2305
  else actions.setError((result && (result.message || result.error)) || '解析失败');
1638
2306
  } catch (err) {
1639
2307
  actions.setError(err instanceof Error ? err.message : String(err));
@@ -1642,25 +2310,28 @@ window.__ModuleLoader__.load({
1642
2310
  }
1643
2311
  };
1644
2312
 
2313
+ const performProfile = async (task) => {
2314
+ const r = await api('/data-cleaning/api/mvp/profile', {
2315
+ rows: runtime.rows,
2316
+ headers: runtime.headers,
2317
+ options: { amountField },
2318
+ });
2319
+ if (!r || r.ok === false) throw new Error((r && (r.message || r.error)) || '体检失败');
2320
+ const quality = qualitySummaryFor(runtime.rows, mappings);
2321
+ actions.setProfile({ ...(r.summary ?? r), workflowSummary: quality });
2322
+ return workflowAction(actions, activeSessionId, task, 'quality', { summary: quality });
2323
+ };
2324
+
1645
2325
  const runProfile = async () => {
1646
- if (busy || !session.rows.length) {
1647
- if (!session.rows.length) actions.setError('请先上传并解析数据。');
2326
+ if (busy || !runtime.rows.length) {
2327
+ if (!runtime.rows.length) actions.setError('请先上传并解析数据。');
1648
2328
  return;
1649
2329
  }
1650
2330
  actions.setBusy(true);
1651
2331
  actions.setError(null);
1652
2332
  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
- }
2333
+ await performProfile(cachedTask);
2334
+ actions.setStep(requiresQcc ? 'match' : 'enrich');
1664
2335
  } catch (err) {
1665
2336
  actions.setError(err instanceof Error ? err.message : String(err));
1666
2337
  } finally {
@@ -1669,21 +2340,23 @@ window.__ModuleLoader__.load({
1669
2340
  };
1670
2341
 
1671
2342
  const runClean = async () => {
1672
- if (busy || !session.rows.length) {
1673
- if (!session.rows.length) actions.setError('请先上传并解析数据。');
2343
+ if (busy || !runtime.rows.length) {
2344
+ if (!runtime.rows.length) actions.setError('请先上传并解析数据。');
1674
2345
  return;
1675
2346
  }
1676
2347
  actions.setBusy(true);
1677
2348
  actions.setError(null);
1678
2349
  try {
1679
2350
  const r = await api('/data-cleaning/api/mvp/clean', {
1680
- rows: session.rows,
1681
- headers: session.headers,
2351
+ rows: runtime.rows,
2352
+ headers: runtime.headers,
1682
2353
  options: localCleanOptions,
1683
2354
  });
1684
2355
  if (r && r.ok !== false) {
1685
2356
  actions.setClean(r.summary ?? r);
1686
2357
  lastCsv.clean = { csv: r.csv ?? '', name: r.downloadName ?? 'cleaned.csv' };
2358
+ runtime.resultRows.clean = Array.isArray(r.rows) ? r.rows : null;
2359
+ if (Array.isArray(r.headers) && r.headers.length) runtime.headers = r.headers;
1687
2360
  } else {
1688
2361
  actions.setError((r && (r.message || r.error)) || '清洗失败');
1689
2362
  }
@@ -1695,21 +2368,23 @@ window.__ModuleLoader__.load({
1695
2368
  };
1696
2369
 
1697
2370
  const runComplete = async () => {
1698
- if (busy || !session.rows.length) {
1699
- if (!session.rows.length) actions.setError('请先上传并解析数据。');
2371
+ if (busy || !runtime.rows.length) {
2372
+ if (!runtime.rows.length) actions.setError('请先上传并解析数据。');
1700
2373
  return;
1701
2374
  }
1702
2375
  actions.setBusy(true);
1703
2376
  actions.setError(null);
1704
2377
  try {
1705
2378
  const r = await api('/data-cleaning/api/mvp/complete', {
1706
- rows: session.rows,
1707
- headers: session.headers,
2379
+ rows: runtime.rows,
2380
+ headers: runtime.headers,
1708
2381
  options: localCompleteOptions,
1709
2382
  });
1710
2383
  if (r && r.ok !== false) {
1711
2384
  actions.setComplete(r.summary ?? r);
1712
2385
  lastCsv.complete = { csv: r.csv ?? '', name: r.downloadName ?? 'completed.csv' };
2386
+ runtime.resultRows.complete = Array.isArray(r.rows) ? r.rows : null;
2387
+ if (Array.isArray(r.headers) && r.headers.length) runtime.headers = r.headers;
1713
2388
  } else {
1714
2389
  actions.setError((r && (r.message || r.error)) || '补全失败');
1715
2390
  }
@@ -1725,7 +2400,7 @@ window.__ModuleLoader__.load({
1725
2400
  actions.setBusy(true);
1726
2401
  actions.setError(null);
1727
2402
  try {
1728
- const r = await api('/data-cleaning/api/phase3/capabilities');
2403
+ const r = await api('/data-cleaning/api/g5/capabilities');
1729
2404
  if (r && r.ok !== false) actions.setQccCapabilities(r);
1730
2405
  else actions.setError((r && (r.message || r.error)) || '企查查能力检测失败');
1731
2406
  } catch (err) {
@@ -1736,31 +2411,82 @@ window.__ModuleLoader__.load({
1736
2411
  };
1737
2412
 
1738
2413
  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,
2414
+ const uniqueCompanies = new Set(runtime.rows.map((row) => String(row?.[nameField] ?? '').trim()).filter(Boolean)).size;
2415
+ actions.setQccEstimate({
2416
+ uniqueCompanies,
2417
+ tools: ['主体查询', '工商信息'],
2418
+ estimatedCalls: uniqueCompanies * 2,
2419
+ maxCalls: 200,
2420
+ withinLimit: runtime.rows.length <= 100,
2421
+ estimateType: 'upper-bound',
2422
+ });
2423
+ };
2424
+
2425
+ const applyQccRun = async (run) => {
2426
+ actions.setQccRun(run);
2427
+ runtime.rows = Array.isArray(run.rows) ? run.rows : runtime.rows;
2428
+ runtime.resultRows.qcc = Array.isArray(run.rows) ? run.rows : null;
2429
+ lastCsv.qcc = { csv: run.csv ?? '', name: run.downloadName ?? 'qcc-enriched.csv' };
2430
+ if (run.reviewCsv) lastCsv.review = { csv: run.reviewCsv, name: run.reviewDownloadName ?? 'qcc-review.csv' };
2431
+ const summary = run.summary ?? {};
2432
+ const reviewRequired = Number(summary.ambiguous ?? run.reviewQueue?.length ?? 0);
2433
+ const currentTask = workflowTaskBySession.get(String(activeSessionId || 'unassigned')) ?? cachedTask;
2434
+ let current = currentTask;
2435
+ // partial 表示上一轮仍有失败项。重试成功后可直接重进 enrichment,
2436
+ // 无需伪造一次新的 match 转换;若重试产生候选,再回到人工核验。
2437
+ if (currentTask?.state !== 'partial' || reviewRequired > 0) {
2438
+ current = await workflowAction(actions, activeSessionId, currentTask, 'match', {
2439
+ qccRunId: run.runId,
2440
+ summary: {
2441
+ total: Number(summary.totalRows ?? runtime.rows.length),
2442
+ exact: Number(summary.enriched ?? 0),
2443
+ candidate: reviewRequired,
2444
+ confirmed: 0,
2445
+ unresolved: Number(summary.unresolved ?? summary.missingName ?? 0),
2446
+ failed: Number(summary.failed ?? 0),
2447
+ reviewRequired,
2448
+ },
1748
2449
  });
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
2450
  }
2451
+ if (reviewRequired > 0) {
2452
+ actions.setStep('match');
2453
+ return current;
2454
+ }
2455
+ current = await workflowAction(actions, activeSessionId, current, 'enrich-start', { fieldSelection });
2456
+ current = await workflowAction(actions, activeSessionId, current, 'enrichment', {
2457
+ qccRunId: run.runId,
2458
+ summary: {
2459
+ total: Number(summary.totalRows ?? runtime.rows.length),
2460
+ completed: Number(summary.enriched ?? 0),
2461
+ unchanged: Number(summary.unresolved ?? 0) + Number(summary.missingName ?? 0),
2462
+ failed: Number(summary.failed ?? 0),
2463
+ reviewRequired: 0,
2464
+ callsUsed: Array.isArray(run.audit) ? run.audit.length : 0,
2465
+ },
2466
+ });
2467
+ actions.setStep(current.stage === 'download' ? 'download' : 'enrich');
2468
+ return current;
1756
2469
  };
1757
2470
 
1758
- const applyQccRun = (run) => {
1759
- 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');
2471
+ const executePreparedQccCommand = async (payload) => {
2472
+ if (!activeSessionId || typeof sendSessionCommand !== 'function') {
2473
+ throw new Error('当前会话无法提交数据清洗补全企查查任务');
2474
+ }
2475
+ const prepared = await api('/data-cleaning/api/g5/commands', {
2476
+ ...payload,
2477
+ confirmPaidCalls: true,
2478
+ });
2479
+ const command = prepared?.command;
2480
+ if (!command?.commandId || !command?.prompt) throw new Error('Host 未返回有效的企查查任务命令');
2481
+ await sendSessionCommand(activeSessionId, command.prompt);
2482
+ const completedCommand = await waitForQccCommand(command.commandId);
2483
+ if (completedCommand.state === 'failed') {
2484
+ const commandError = new Error(completedCommand.error?.message || '智能体企查查任务执行失败');
2485
+ commandError.code = completedCommand.error?.code;
2486
+ throw commandError;
2487
+ }
2488
+ if (!completedCommand.run) throw new Error('智能体企查查任务完成但结果已失效');
2489
+ return completedCommand.run;
1764
2490
  };
1765
2491
 
1766
2492
  const runQcc = async () => {
@@ -1768,20 +2494,21 @@ window.__ModuleLoader__.load({
1768
2494
  actions.setBusy(true);
1769
2495
  actions.setError(null);
1770
2496
  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,
2497
+ const current = await workflowAction(actions, activeSessionId, cachedTask, 'match-start');
2498
+ const r = await executePreparedQccCommand({
2499
+ kind: 'enrich',
2500
+ taskId: current.id,
2501
+ rows: runtime.rows,
2502
+ headers: runtime.headers,
1775
2503
  nameField,
1776
- domains: selectedDomains,
1777
- maxCalls: qccEstimate.maxCalls,
2504
+ includeRisk: false,
1778
2505
  concurrency: 2,
1779
- confirmPaidCalls: true,
1780
- idempotencyKey: key,
1781
2506
  });
1782
- if (r && r.ok !== false) applyQccRun(r);
1783
- else actions.setError((r && (r.message || r.error)) || '三域补全失败');
2507
+ await applyQccRun(r);
1784
2508
  } catch (err) {
2509
+ if (['QCC_NOT_CONNECTED', 'QCC_TOOL_UNAVAILABLE', 'QCC_AUTH_REQUIRED'].includes(err?.code)) {
2510
+ try { await workflowAction(actions, activeSessionId, cachedTask, 'authorization-required'); } catch (_workflowError) {}
2511
+ }
1785
2512
  actions.setError(err instanceof Error ? err.message : String(err));
1786
2513
  } finally {
1787
2514
  actions.setBusy(false);
@@ -1793,15 +2520,15 @@ window.__ModuleLoader__.load({
1793
2520
  actions.setBusy(true);
1794
2521
  actions.setError(null);
1795
2522
  try {
1796
- const r = await api('/data-cleaning/api/phase3/resolve', {
2523
+ const task = workflowTaskBySession.get(String(activeSessionId || 'unassigned')) ?? cachedTask;
2524
+ const r = await executePreparedQccCommand({
2525
+ kind: 'resolve',
2526
+ taskId: task.id,
1797
2527
  runId: qccRun.runId,
1798
2528
  companyName: item.companyName,
1799
2529
  selectedCreditNo: candidate.creditNo,
1800
- confirmPaidCalls: true,
1801
- idempotencyKey: `phase3-resolve-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
1802
2530
  });
1803
- if (r && r.ok !== false) applyQccRun(r);
1804
- else actions.setError((r && (r.message || r.error)) || '候选确认失败');
2531
+ await applyQccRun(r);
1805
2532
  } catch (err) {
1806
2533
  actions.setError(err instanceof Error ? err.message : String(err));
1807
2534
  } finally {
@@ -1816,14 +2543,14 @@ window.__ModuleLoader__.load({
1816
2543
  actions.setBusy(true);
1817
2544
  actions.setError(null);
1818
2545
  try {
1819
- const r = await api('/data-cleaning/api/phase3/retry', {
2546
+ const task = workflowTaskBySession.get(String(activeSessionId || 'unassigned')) ?? cachedTask;
2547
+ const r = await executePreparedQccCommand({
2548
+ kind: 'retry',
2549
+ taskId: task.id,
1820
2550
  runId: qccRun.runId,
1821
2551
  companyNames: names,
1822
- confirmPaidCalls: true,
1823
- idempotencyKey: `phase3-retry-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
1824
2552
  });
1825
- if (r && r.ok !== false) applyQccRun(r);
1826
- else actions.setError((r && (r.message || r.error)) || '失败项重试失败');
2553
+ await applyQccRun(r);
1827
2554
  } catch (err) {
1828
2555
  actions.setError(err instanceof Error ? err.message : String(err));
1829
2556
  } finally {
@@ -1831,46 +2558,206 @@ window.__ModuleLoader__.load({
1831
2558
  }
1832
2559
  };
1833
2560
 
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);
2561
+ const saveTaskSettings = async () => {
2562
+ actions.setBusy(true);
2563
+ actions.setError(null);
2564
+ try {
2565
+ const current = await ensureWorkflowTask(actions, activeSessionId, {
2566
+ title: taskTitle, objectives, fieldSelection, mappings, matchRules,
2567
+ });
2568
+ await updateWorkflowTask(actions, activeSessionId, current, {
2569
+ title: taskTitle, objectives, fieldSelection, mappings, matchRules,
2570
+ });
2571
+ } catch (saveError) {
2572
+ actions.setError(saveError instanceof Error ? saveError.message : String(saveError));
2573
+ } finally {
2574
+ actions.setBusy(false);
2575
+ }
2576
+ };
2577
+
2578
+ const confirmRuleSettings = async () => {
2579
+ if (!hasData) { actions.setError('请先上传并解析数据。'); return; }
2580
+ const anchorMappings = mappings.filter((mapping) => ['company_name', 'credit_no', 'reg_no'].includes(mapping.targetField));
2581
+ if (!anchorMappings.length) { actions.setError('请至少映射企业名称、统一社会信用代码或注册号。'); return; }
2582
+ if (!objectives.length) { actions.setError('请至少选择一个清洗或补全目标。'); return; }
2583
+ actions.setBusy(true);
2584
+ actions.setError(null);
2585
+ try {
2586
+ let current = await ensureWorkflowTask(actions, activeSessionId, {
2587
+ title: taskTitle, objectives, fieldSelection, mappings, matchRules,
2588
+ });
2589
+ if (current.state === 'draft') {
2590
+ current = await workflowAction(actions, activeSessionId, current, 'upload', {
2591
+ source: {
2592
+ type: runtime.source?.type || dataset.fmt || 'csv',
2593
+ fileName: runtime.source?.fileName || '',
2594
+ rowCount: dataset.rowCount,
2595
+ columnCount: dataset.headers?.length || 0,
2596
+ headers: dataset.headers || [],
2597
+ sizeBytes: runtime.source?.sizeBytes || 0,
2598
+ },
2599
+ });
2600
+ }
2601
+ current = await updateWorkflowTask(actions, activeSessionId, current, {
2602
+ title: taskTitle, objectives, fieldSelection, mappings, matchRules,
2603
+ });
2604
+ current = await workflowAction(actions, activeSessionId, current, 'rules', {
2605
+ objectives, fieldSelection, mappings, matchRules,
2606
+ });
2607
+ current = await performProfile(current);
2608
+ const primary = mappings.find((mapping) => mapping.targetField === 'company_name')
2609
+ ?? mappings.find((mapping) => mapping.targetField === 'credit_no')
2610
+ ?? mappings.find((mapping) => mapping.targetField === 'reg_no');
2611
+ if (primary) actions.setNameField(primary.sourceField);
2612
+ actions.setStep('profile');
2613
+ } catch (ruleError) {
2614
+ actions.setError(ruleError instanceof Error ? ruleError.message : String(ruleError));
2615
+ } finally {
2616
+ actions.setBusy(false);
2617
+ }
2618
+ };
2619
+
2620
+ const resumeWorkflowTask = (task) => {
2621
+ workflowTaskBySession.set(String(activeSessionId || 'unassigned'), task);
2622
+ actions.setWorkflowTask(task);
2623
+ actions.setTaskTitle(task.title);
2624
+ actions.setMappings(task.mappings || []);
2625
+ actions.setObjectives(task.objectives || []);
2626
+ actions.setFieldSelection(task.fieldSelection || []);
2627
+ for (const [key, value] of Object.entries(task.matchRules || {})) actions.setMatchRule(key, value);
2628
+ actions.setStep(task.stage || 'upload');
2629
+ if (!runtimeFor(task.id, false)?.rows?.length && task.source?.rowCount && !(task.artifacts || []).length) {
2630
+ actions.setDataset(null);
2631
+ actions.setError('已恢复任务元数据。出于隐私保护,原始企业名单未持久化;继续处理前请重新上传源文件。');
2632
+ }
2633
+ };
2634
+
2635
+ const startNewWorkflow = () => {
2636
+ if (cachedTask?.id) runtimeTasks.delete(String(cachedTask.id));
2637
+ runtimeTasks.delete('unassigned');
2638
+ workflowTaskBySession.delete(String(activeSessionId || 'unassigned'));
2639
+ actions.resetWorkflow();
2640
+ };
2641
+
2642
+ const exportRows = () => runtime.resultRows.qcc
2643
+ ?? runtime.resultRows.complete
2644
+ ?? runtime.resultRows.clean
2645
+ ?? (runtime.rows.length ? runtime.rows : null);
2646
+
2647
+ const createArtifactBundle = async () => {
2648
+ let latest = workflowTaskBySession.get(String(activeSessionId || 'unassigned')) ?? cachedTask;
2649
+ if (!latest) throw new Error('请先创建并执行数据清洗补全任务。');
2650
+ if (Array.isArray(latest.artifacts) && latest.artifacts.length) return latest;
2651
+ const rows = exportRows();
2652
+ if (!rows?.length) throw new Error('当前页面没有可导出的明细。若任务来自历史记录,请重新上传源文件并继续处理。');
2653
+ const response = await requestJson(`/data-cleaning/api/workflow/tasks/${encodeURIComponent(latest.id)}/artifacts`, 'POST', {
2654
+ expectedRevision: latest.revision,
2655
+ headers: runtime.headers,
2656
+ rows,
2657
+ baseName: latest.title || taskTitle,
2658
+ summary: {
2659
+ total: rows.length,
2660
+ completed: rows.length,
2661
+ unchanged: 0,
2662
+ failed: 0,
2663
+ },
2664
+ });
2665
+ latest = cacheWorkflowTask(actions, activeSessionId, response.task);
2666
+ loadWorkflowTasks(actions);
2667
+ return latest;
2668
+ };
2669
+
2670
+ const downloadArtifact = async (artifact) => {
2671
+ if (!artifact || busy) return;
2672
+ actions.setBusy(true);
2673
+ actions.setError(null);
2674
+ try {
2675
+ const latest = workflowTaskBySession.get(String(activeSessionId || 'unassigned')) ?? cachedTask;
2676
+ const response = await fetch(`/data-cleaning/api/workflow/tasks/${encodeURIComponent(latest.id)}/artifacts/${encodeURIComponent(artifact.id)}`, {
2677
+ method: 'GET', credentials: 'same-origin', headers: { accept: artifact.mediaType || 'application/octet-stream' },
2678
+ });
2679
+ if (!response.ok) {
2680
+ let payload = null;
2681
+ try { payload = await response.json(); } catch {}
2682
+ throw new Error(payload?.message || `下载失败(HTTP ${response.status})`);
2683
+ }
2684
+ const blob = await response.blob();
2685
+ const href = URL.createObjectURL(blob);
2686
+ const anchor = document.createElement('a');
2687
+ anchor.href = href;
2688
+ anchor.download = artifact.fileName || `export.${artifact.format || 'bin'}`;
2689
+ anchor.click();
2690
+ URL.revokeObjectURL(href);
2691
+ } catch (downloadError) {
2692
+ actions.setError(downloadError instanceof Error ? downloadError.message : String(downloadError));
2693
+ } finally {
2694
+ actions.setBusy(false);
2695
+ }
2696
+ };
2697
+
2698
+ const createAndDownload = async (kind, format) => {
2699
+ if (busy) return;
2700
+ actions.setBusy(true);
2701
+ actions.setError(null);
2702
+ try {
2703
+ const latest = await createArtifactBundle();
2704
+ const artifact = (latest.artifacts || []).find((item) => item.kind === kind && item.format === format);
2705
+ if (!artifact) throw new Error('Host 未生成所选格式的制品。');
2706
+ actions.setBusy(false);
2707
+ await downloadArtifact(artifact);
2708
+ } catch (downloadError) {
2709
+ actions.setError(downloadError instanceof Error ? downloadError.message : String(downloadError));
2710
+ actions.setBusy(false);
2711
+ }
1843
2712
  };
1844
2713
 
2714
+ const displayStatValue = (value) => {
2715
+ if (typeof value === 'string' || typeof value === 'number') return value;
2716
+ if (typeof value === 'bigint' || typeof value === 'boolean') return String(value);
2717
+ return '—';
2718
+ };
2719
+ // 浏览器运行时只保留当前会话的原始明细;进程重启后,已完成任务应从
2720
+ // Host 持久化摘要恢复计数,不能因 qccRun 仅存在于内存而显示“—”。
2721
+ const qccEnrichedCount = qccRun
2722
+ ? qccRun.summary?.enriched ?? 0
2723
+ : cachedTask?.enrichmentSummary?.completed;
2724
+ const qccReviewCount = qccRun
2725
+ ? qccRun.summary?.ambiguous ?? 0
2726
+ : cachedTask?.enrichmentSummary?.reviewRequired
2727
+ ?? cachedTask?.matchSummary?.reviewRequired;
1845
2728
  const stat = (label, value, tone) => h('div', { className: 'dcAgentCard' },
1846
2729
  h('span', null, label),
1847
- h('b', { className: tone ? `is-${tone}` : null }, value)
2730
+ h('b', { className: tone ? `is-${tone}` : null }, displayStatValue(value))
1848
2731
  );
2732
+ const uiFieldGroups = Array.isArray(workflowContract?.fieldCatalog)
2733
+ ? workflowContract.fieldCatalog.map((group) => [group.id, group.label, (group.fields || []).map((field) => [field.id, field.label])])
2734
+ : FIELD_GROUPS;
2735
+ const targetFields = uiFieldGroups.flatMap(([, , fields]) => fields);
1849
2736
 
1850
2737
  let pane;
1851
2738
  if (step === 'history') {
1852
2739
  pane = h('div', { className: 'dcAgentPane' },
1853
- h('p', { className: 'dcAgentHint' }, '查看当前 Host 的数据清洗后台任务。任务数据仅在本机保存,不进入模型上下文。'),
2740
+ h('p', { className: 'dcAgentHint' }, '查看当前 Host 的数据清洗补全任务。Host 只保存任务元数据和摘要,不保存原始企业名单。'),
1854
2741
  h('div', { className: 'dcAgentRow' },
1855
2742
  h('button', {
1856
2743
  type: 'button',
1857
2744
  className: 'dcAgentButton',
1858
2745
  disabled: busy,
1859
2746
  'aria-label': '刷新任务历史',
1860
- onClick: () => pollJobsOnce(actions),
2747
+ onClick: () => loadWorkflowTasks(actions),
1861
2748
  }, '刷新任务'),
1862
2749
  h('button', {
1863
2750
  type: 'button',
1864
2751
  className: 'dcAgentButton is-primary',
1865
- onClick: () => actions.setStep('upload'),
2752
+ onClick: startNewWorkflow,
1866
2753
  }, '新建清洗任务'),
1867
2754
  ),
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 || '数据清洗任务'),
2755
+ workflowTasks.length ? h('div', { className: 'dcAgentPane', 'aria-label': '任务历史列表' },
2756
+ workflowTasks.map((task) => h('button', { key: task.id, type: 'button', className: 'dcAgentHistoryTask', onClick: () => resumeWorkflowTask(task) },
2757
+ h('h3', null, task.title || task.id || '数据清洗任务'),
1871
2758
  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() : ''),
2759
+ h('span', { className: 'dcAgentJobsPill', 'data-state': task.state ?? 'idle' }, WORKFLOW_STATE_LABELS[task.state] ?? task.state ?? '未知'),
2760
+ h('span', { className: 'dcAgentHint' }, `${task.source?.rowCount ?? 0} 行 · ${task.updatedAt ? new Date(task.updatedAt).toLocaleString() : ''}`),
1874
2761
  ),
1875
2762
  )),
1876
2763
  ) : h('section', { className: 'dcAgentSection' },
@@ -1878,6 +2765,58 @@ window.__ModuleLoader__.load({
1878
2765
  h('p', { className: 'dcAgentHint' }, '上传企业名单并开始处理后,任务状态会显示在这里。'),
1879
2766
  ),
1880
2767
  );
2768
+ } else if (step === 'rules') {
2769
+ pane = h('div', { className: 'dcAgentPane' },
2770
+ h('p', { className: 'dcAgentHint' }, '确认任务名称、字段映射、匹配规则和输出字段。保存草稿不会调用企查查;确认规则后才可进入质量体检。'),
2771
+ h('label', { className: 'dcAgentFormField' },
2772
+ h('span', null, '任务名称'),
2773
+ h('input', { className: 'dcAgentField', value: taskTitle, maxLength: 120, onChange: (event) => actions.setTaskTitle(event.target.value) }),
2774
+ ),
2775
+ h('section', { className: 'dcAgentSection' },
2776
+ h('h3', null, '字段映射'),
2777
+ h('p', { className: 'dcAgentHint' }, '至少映射企业名称、统一社会信用代码或注册号其中一项。未映射列会原样保留,但不参与匹配。'),
2778
+ (dataset?.headers || []).map((sourceField) => h('label', { className: 'dcAgentMappingRow', key: sourceField },
2779
+ h('span', { title: sourceField }, sourceField),
2780
+ h('b', { 'aria-hidden': 'true' }, '→'),
2781
+ h('select', {
2782
+ className: 'dcAgentField',
2783
+ value: mappings.find((mapping) => mapping.sourceField === sourceField)?.targetField || '',
2784
+ 'aria-label': `${sourceField} 字段映射`,
2785
+ onChange: (event) => actions.setMapping(sourceField, event.target.value),
2786
+ },
2787
+ h('option', { value: '' }, '不参与匹配 / 补全'),
2788
+ targetFields.map(([id, label]) => h('option', { key: id, value: id }, label)),
2789
+ ),
2790
+ )),
2791
+ ),
2792
+ h('section', { className: 'dcAgentSection' },
2793
+ h('h3', null, '任务目标'),
2794
+ h('div', { className: 'dcAgentChips' }, CLEANING_OPTIONS.map(([key, label]) => h('label', { key, className: `dcAgentChip${objectives.includes(key) ? ' is-selected' : ''}` },
2795
+ h('input', { type: 'checkbox', checked: objectives.includes(key), onChange: () => actions.toggleObjective(key) }), ` ${label}`,
2796
+ ))),
2797
+ ),
2798
+ h('section', { className: 'dcAgentSection' },
2799
+ h('h3', null, '匹配规则'),
2800
+ h('div', { className: 'dcAgentRulesGrid' },
2801
+ [['normalizeNames', '匹配前规范企业名称'], ['preferCreditNo', '信用代码优先精确匹配'], ['deduplicate', '重复主体合并处理'], ['manualReviewAmbiguous', '多候选必须人工确认']].map(([key, label]) => h('label', { key, className: 'dcAgentCheck' },
2802
+ h('input', { type: 'checkbox', checked: matchRules[key], onChange: (event) => actions.setMatchRule(key, event.target.checked) }), label,
2803
+ )),
2804
+ ),
2805
+ ),
2806
+ h('section', { className: 'dcAgentSection' },
2807
+ h('h3', null, `输出字段 · 已选 ${fieldSelection.length}`),
2808
+ uiFieldGroups.map(([groupId, label, fields]) => h('div', { key: groupId, className: 'dcAgentFieldGroup' },
2809
+ h('b', null, label),
2810
+ h('div', { className: 'dcAgentChips' }, fields.map(([key, fieldLabel]) => h('label', { key, className: `dcAgentChip${fieldSelection.includes(key) ? ' is-selected' : ''}` },
2811
+ h('input', { type: 'checkbox', checked: fieldSelection.includes(key), onChange: () => actions.toggleField(key) }), ` ${fieldLabel}`,
2812
+ ))),
2813
+ )),
2814
+ ),
2815
+ h('div', { className: 'dcAgentRow' },
2816
+ h('button', { type: 'button', className: 'dcAgentButton', disabled: busy, onClick: saveTaskSettings }, '保存草稿'),
2817
+ h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: busy || !hasData, 'aria-label': '确认规则并运行质量体检', onClick: confirmRuleSettings }, busy ? '保存中…' : '确认规则并运行质量体检'),
2818
+ ),
2819
+ );
1881
2820
  } else if (step === 'profile') {
1882
2821
  pane = h('div', { className: 'dcAgentPane' },
1883
2822
  h('p', { className: 'dcAgentHint' }, '基于已解析数据生成本地质量画像(缺失率、去重值、金额分布)。本步骤不发起企查查调用。'),
@@ -1905,45 +2844,33 @@ window.__ModuleLoader__.load({
1905
2844
  ))),
1906
2845
  ) : null,
1907
2846
  h('div', { className: 'dcAgentRow' },
1908
- h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: busy, onClick: () => actions.setStep('review') }, '下一步:匹配核验'),
2847
+ h('button', {
2848
+ type: 'button',
2849
+ className: 'dcAgentButton is-primary',
2850
+ disabled: busy,
2851
+ onClick: () => actions.setStep(requiresQcc ? 'match' : 'enrich'),
2852
+ }, requiresQcc ? '下一步:匹配核验' : '下一步:本地清洗补全'),
1909
2853
  ),
1910
2854
  ) : h('div', { className: 'dcAgentRow' },
1911
2855
  h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: busy || !hasData, 'aria-label': '生成体检报告', onClick: runProfile }, busy ? '体检中…' : '生成体检报告'),
1912
2856
  ),
1913
2857
  );
1914
- } else if (step === 'review') {
2858
+ } else if (step === 'match') {
1915
2859
  pane = h('div', { className: 'dcAgentPane' },
1916
- h('p', { className: 'dcAgentHint' }, '先执行本地确定性清洗,再按真实 ToolRuntime 状态进行主体匹配。多候选始终由人工选择;界面不生成虚构置信度。'),
2860
+ h('p', { className: 'dcAgentHint' }, '通过当前用户连接的企查查 MCP 定位企业主体并补全基础工商字段。多候选始终由人工选择;界面不生成虚构置信度。'),
1917
2861
  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,
2862
+ h('h3', null, '匹配依据'),
2863
+ 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}`))),
2864
+ h('p', { className: 'dcAgentHint' }, '统一社会信用代码等强标识优先;名称匹配不明确时进入人工核验队列。'),
1929
2865
  ),
1930
2866
  h('section', { className: 'dcAgentSection' },
1931
- h('h3', null, '企查查三域能力与调用范围'),
2867
+ h('h3', null, '企查查基础企业能力'),
1932
2868
  h('div', { className: 'dcAgentRow' },
1933
2869
  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
- )),
2870
+ qccCapabilities ? h('span', { className: 'dcAgentHint' }, qccCapabilities.capabilities?.ready ? '主体查询与工商信息工具已就绪' : '连接未就绪或基础工具不完整') : null,
1944
2871
  ),
1945
2872
  h('div', { className: 'dcAgentRow' },
1946
- h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !selectedDomains.length, onClick: estimateQcc }, '估算调用量'),
2873
+ h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !runtime.rows.length, onClick: estimateQcc }, '估算调用量'),
1947
2874
  ),
1948
2875
  qccEstimate ? h('div', null,
1949
2876
  h('div', { className: 'dcAgentGrid' },
@@ -1954,10 +2881,10 @@ window.__ModuleLoader__.load({
1954
2881
  ),
1955
2882
  h('label', { className: 'dcAgentCheck' },
1956
2883
  h('input', { type: 'checkbox', checked: paidConfirmed, 'aria-label': '确认使用当前用户的企查查账号额度', onChange: (event) => actions.setPaidConfirmed(event.target.checked) }),
1957
- '我已核对企业数量、所选域及调用上界,并确认使用自己连接的企查查 MCP 账号;额度或费用由该账号自行承担',
2884
+ '我已核对企业数量和调用上界,并确认使用自己连接的企查查 MCP 账号;额度或费用由该账号自行承担',
1958
2885
  ),
1959
2886
  h('div', { className: 'dcAgentRow' },
1960
- h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: busy || !paidConfirmed || !qccEstimate.withinLimit, onClick: runQcc }, busy ? '执行中…' : '开始匹配与补全'),
2887
+ h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: busy || !paidConfirmed || !qccEstimate.withinLimit, onClick: runQcc }, busy ? '执行中…' : '开始主体匹配'),
1961
2888
  ),
1962
2889
  ) : null,
1963
2890
  ),
@@ -1965,11 +2892,21 @@ window.__ModuleLoader__.load({
1965
2892
  h('h3', null, `任务 ${qccRun.runId} · ${qccRun.state}`),
1966
2893
  h('div', { className: 'dcAgentGrid' },
1967
2894
  stat('已补全', qccRun.summary?.enriched ?? 0, 'good'),
1968
- stat('部分成功', qccRun.summary?.partial ?? 0, (qccRun.summary?.partial ?? 0) > 0 ? 'warn' : null),
1969
2895
  stat('待核验', qccRun.summary?.ambiguous ?? 0, (qccRun.summary?.ambiguous ?? 0) > 0 ? 'warn' : null),
2896
+ stat('未匹配', qccRun.summary?.unresolved ?? 0, (qccRun.summary?.unresolved ?? 0) > 0 ? 'warn' : null),
1970
2897
  stat('失败', qccRun.summary?.failed ?? 0, (qccRun.summary?.failed ?? 0) > 0 ? 'bad' : null),
1971
- stat('实际调用', qccRun.summary?.actualCalls ?? 0),
1972
2898
  ),
2899
+ Array.isArray(qccRun.rows) && qccRun.rows.length ? h('div', { className: 'dcAgentTableWrap' },
2900
+ h('table', { className: 'dcAgentTable', 'aria-label': '匹配补全结果预览' },
2901
+ h('thead', null, h('tr', null,
2902
+ Object.keys(qccRun.rows[0] || {}).slice(0, 6).map((key) => h('th', { key }, key)),
2903
+ )),
2904
+ h('tbody', null, qccRun.rows.slice(0, 8).map((row, index) => h('tr', { key: `${index}-${row?.qcc_credit_no || row?.credit_no || ''}` },
2905
+ Object.keys(qccRun.rows[0] || {}).slice(0, 6).map((key) => h('td', { key }, String(row?.[key] ?? ''))),
2906
+ ))),
2907
+ ),
2908
+ h('p', { className: 'dcAgentHint' }, `显示前 ${Math.min(8, qccRun.rows.length)} 行;完整结果在下载阶段生成耐久制品。`),
2909
+ ) : null,
1973
2910
  (qccRun.reviewQueue || []).map((item) => h('div', { key: item.companyName, className: 'dcAgentSection' },
1974
2911
  h('h3', null, `待核验:${item.companyName}`),
1975
2912
  item.candidates.map((candidate) => h('div', { key: candidate.creditNo, className: 'dcAgentCandidate' },
@@ -1979,28 +2916,64 @@ window.__ModuleLoader__.load({
1979
2916
  )),
1980
2917
  )),
1981
2918
  (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
- ),
2919
+ ) : null,
1988
2920
  );
1989
2921
  } else if (step === 'enrich') {
1990
2922
  pane = h('div', { className: 'dcAgentPane' },
1991
- h('p', { className: 'dcAgentHint' }, '导出本地清洗结果、企查查三域补全结果和待核验清单。每个三域值均保留 sourceTool 与上游原值。'),
2923
+ h('p', { className: 'dcAgentHint' }, '执行本地确定性清洗并核对本任务所选补全字段。外部字段以当前用户企查查 MCP 的真实返回为准,缺失值不会被编造。'),
2924
+ h('section', { className: 'dcAgentSection' },
2925
+ h('h3', null, `已选补全字段 · ${fieldSelection.length}`),
2926
+ h('div', { className: 'dcAgentChips' }, fieldSelection.map((id) => h('span', { className: 'dcAgentChip', key: id }, ENRICHMENT_OPTIONS.find(([fieldId]) => fieldId === id)?.[1] || id))),
2927
+ ),
2928
+ h('section', { className: 'dcAgentSection' },
2929
+ h('h3', null, '本地清洗预处理'),
2930
+ h('div', { className: 'dcAgentRow' },
2931
+ h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !hasData, 'aria-label': '执行清洗', onClick: runClean }, busy ? '处理中…' : '执行确定性清洗'),
2932
+ h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !hasData, 'aria-label': '执行补全', onClick: runComplete }, busy ? '处理中…' : '本地规则补全'),
2933
+ ),
2934
+ clean ? h('div', { className: 'dcAgentGrid' },
2935
+ stat('总数', clean.total), stat('保留', clean.kept, 'good'), stat('剔除', clean.dropped, 'bad'),
2936
+ stat('缺失关键字段', clean.badMissing, clean.badMissing > 0 ? 'bad' : null),
2937
+ stat('重复', clean.badDuplicate, clean.badDuplicate > 0 ? 'warn' : null),
2938
+ ) : null,
2939
+ ),
1992
2940
  h('div', { className: 'dcAgentGrid' },
1993
2941
  stat('输入行数', dataset ? dataset.rowCount : '—'),
1994
2942
  stat('清洗保留', clean ? clean.kept : '—', clean && clean.kept > 0 ? 'good' : null),
1995
2943
  stat('本地补全', complete ? complete.completed : '—', complete && complete.completed > 0 ? 'good' : null),
1996
- stat('QCC 已补全', qccRun ? qccRun.summary?.enriched ?? 0 : '—', qccRun && qccRun.summary?.enriched > 0 ? 'good' : null),
1997
- stat('待核验', qccRun ? qccRun.summary?.ambiguous ?? 0 : '—', qccRun && qccRun.summary?.ambiguous > 0 ? 'warn' : null),
2944
+ stat('QCC 已补全', qccEnrichedCount, qccEnrichedCount > 0 ? 'good' : null),
2945
+ stat('待核验', qccReviewCount, qccReviewCount > 0 ? 'warn' : null),
1998
2946
  ),
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'),
2947
+ h('div', { className: 'dcAgentRow' }, h('button', { type: 'button', className: 'dcAgentButton is-primary', onClick: () => actions.setStep('download') }, '进入下载数据')),
2948
+ );
2949
+ } else if (step === 'download') {
2950
+ const availableArtifacts = cachedTask?.artifacts || [];
2951
+ const canCreateArtifacts = Boolean(exportRows()?.length) && ['rules_confirmed', 'diagnosed', 'export_ready', 'partial'].includes(cachedTask?.state);
2952
+ pane = h('div', { className: 'dcAgentPane' },
2953
+ h('p', { className: 'dcAgentHint' }, '结果与异常清单由 Host 生成 CSV/XLSX 四件套并耐久保存。任务或插件重启后,可从任务历史继续下载;无需再次调用企查查。'),
2954
+ h('div', { className: 'dcAgentGrid' },
2955
+ stat('输入行数', dataset ? dataset.rowCount : workflowTask?.source?.rowCount ?? '—'),
2956
+ stat('匹配补全', qccEnrichedCount, qccEnrichedCount > 0 ? 'good' : null),
2957
+ stat('待核验', qccReviewCount, qccReviewCount > 0 ? 'warn' : null),
2958
+ stat('任务状态', WORKFLOW_STATE_LABELS[cachedTask?.state] || cachedTask?.state || '进行中'),
2959
+ ),
2960
+ availableArtifacts.length ? h('div', { className: 'dcAgentArtifactList' },
2961
+ availableArtifacts.map((artifact) => h('button', {
2962
+ key: artifact.id,
2963
+ type: 'button',
2964
+ className: `dcAgentButton${artifact.kind === 'complete' && artifact.format === 'xlsx' ? ' is-primary' : ''}`,
2965
+ disabled: busy,
2966
+ onClick: () => downloadArtifact(artifact),
2967
+ 'aria-label': `下载 ${artifact.fileName}`,
2968
+ }, `${artifact.kind === 'review' ? '异常清单' : '清洗补全结果'} ${String(artifact.format).toUpperCase()} · ${artifact.rowCount} 行`)),
2969
+ ) : h('div', null,
2970
+ h('div', { className: 'dcAgentRow' },
2971
+ h('button', { type: 'button', className: 'dcAgentButton is-primary', disabled: busy || !canCreateArtifacts, onClick: () => createAndDownload('complete', 'xlsx') }, '生成并下载结果 XLSX'),
2972
+ h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !canCreateArtifacts, onClick: () => createAndDownload('complete', 'csv') }, '生成并下载结果 CSV'),
2973
+ h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !canCreateArtifacts, onClick: () => createAndDownload('review', 'xlsx') }, '生成并下载异常清单 XLSX'),
2974
+ h('button', { type: 'button', className: 'dcAgentButton', disabled: busy || !canCreateArtifacts, onClick: () => createAndDownload('review', 'csv') }, '生成并下载异常清单 CSV'),
2975
+ ),
2976
+ !canCreateArtifacts ? h('p', { className: 'dcAgentHint' }, '请先完成规则确认与清洗补全;历史任务若尚未生成制品,需要重新上传源文件。') : null,
2004
2977
  ),
2005
2978
  );
2006
2979
  } else {
@@ -2026,15 +2999,15 @@ window.__ModuleLoader__.load({
2026
2999
  (dataset.headers || []).slice(0, 12).map((name) => h('span', { key: name, className: 'dcAgentChip' }, name)),
2027
3000
  (dataset.headers || []).length > 12 ? h('span', { className: 'dcAgentChip' }, `+${dataset.headers.length - 12} 列`) : null,
2028
3001
  ) : 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)),
3002
+ dataset ? h('div', { className: 'dcAgentPreviewTable', 'aria-label': '数据预览' },
3003
+ h('table', { className: 'dcAgentTable' },
3004
+ h('thead', null, h('tr', null, (dataset.headers || []).slice(0, 8).map((name) => h('th', { key: name }, name)))),
3005
+ 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
3006
  ),
2034
3007
  ) : null,
2035
3008
  h('div', { className: 'dcAgentRow' },
2036
3009
  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,
3010
+ hasData ? h('button', { type: 'button', className: 'dcAgentButton', onClick: () => actions.setStep('rules') }, '下一步:字段映射与规则') : null,
2038
3011
  ),
2039
3012
  );
2040
3013
  }
@@ -2068,7 +3041,7 @@ window.__ModuleLoader__.load({
2068
3041
  h('span', { className: 'dcAgentWbIcon', 'aria-hidden': 'true' }, '🧹'),
2069
3042
  h('div', null,
2070
3043
  h('b', null, '数据清洗补全'),
2071
- h('small', null, '本地确定性清洗 · 可选企查查 MCP 补全'),
3044
+ h('small', null, cachedTask ? `${WORKFLOW_STATE_LABELS[cachedTask.state] || cachedTask.state} · ${cachedTask.id.slice(0, 18)}` : '正在创建 Host taskId…'),
2072
3045
  ),
2073
3046
  ),
2074
3047
  h('span', {
@@ -2128,6 +3101,7 @@ window.__ModuleLoader__.load({
2128
3101
  try {
2129
3102
  const workbenchStore = createWorkbenchStore();
2130
3103
  ctx.effect(() => installUiStyles(), 'data-cleaning-agent: UI styles');
3104
+ ctx.effect(() => installSessionOwnershipBridge(ctx), 'data-cleaning-agent: session ownership');
2131
3105
 
2132
3106
  // 右侧工作台:additive overlay,不替换 DSH 单占位 details 面板。
2133
3107
  ctx.slots.inject('shell.overlay', () => ctx.slots.register({
@@ -2135,6 +3109,9 @@ window.__ModuleLoader__.load({
2135
3109
  id: 'data-cleaning-agent',
2136
3110
  order: 200,
2137
3111
  store: workbenchStore,
3112
+ inject: () => ({
3113
+ sendSessionCommand: (sessionId, prompt) => sendQccAgentCommand(ctx, sessionId, prompt),
3114
+ }),
2138
3115
  }, WorkbenchDrawer));
2139
3116
 
2140
3117
  // 左栏:footer 只托管生命周期和 Portal 降级;实际入口显示在工作区列表前。
@@ -2190,7 +3167,22 @@ window.__ModuleLoader__.load({
2190
3167
  exports.apply = apply;
2191
3168
  exports.inject = inject;
2192
3169
  // 测试用纯函数,不构成 Host / DSH 稳定 API。
2193
- exports.__testing = { buildTaskPrompt, extractPromptEntries, markCleaningSession };
3170
+ exports.__testing = {
3171
+ buildTaskPrompt,
3172
+ clearCleaningDraft,
3173
+ entriesToDataset,
3174
+ extractPromptEntries,
3175
+ guessMappings,
3176
+ deactivateCleaningSession,
3177
+ installSessionOwnershipBridge,
3178
+ isCleaningSession,
3179
+ isKnownCleaningDraft,
3180
+ markCleaningSession,
3181
+ qualitySummaryFor,
3182
+ rewriteHeroChrome,
3183
+ ensureWorkflowTask,
3184
+ queueWorkflowOperation,
3185
+ };
2194
3186
  return module.exports;
2195
3187
  },
2196
3188
  });