cleaning.vue.phase5a 91 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170
  1. <template>
  2. <div :class="prefixCls" class="case-data-cleaning-page">
  3. <div class="page-header">
  4. <div class="page-header-main">
  5. <div class="page-title-area">
  6. <Button type="text" class="back-btn" @click="handleBack">←</Button>
  7. <div class="page-title-block">
  8. <div class="page-title">案件数据清洗</div>
  9. </div>
  10. </div>
  11. <div class="page-header-actions">
  12. <Button @click="toggleLeft">{{ leftCollapsed ? '展开左侧' : '收起左侧' }}</Button>
  13. <Button type="primary" :loading="importingData" @click="handleImportData">导入数据</Button>
  14. </div>
  15. </div>
  16. </div>
  17. <div class="main-layout" :style="{ gridTemplateColumns: layoutTemplateColumns }">
  18. <aside v-show="!leftCollapsed" class="left-panel">
  19. <div class="tree-header">
  20. <span class="label">文件分类</span>
  21. <div class="actions">
  22. <Button size="small" @click="sortMode = sortMode === 'asc' ? '' : 'asc'">升序</Button>
  23. <Button size="small" @click="sortMode = sortMode === 'desc' ? '' : 'desc'">降序</Button>
  24. </div>
  25. </div>
  26. <div class="tree-wrap">
  27. <BasicTree
  28. class="cleaning-file-tree"
  29. :tree-data="treeData"
  30. :tree-data-simple-mode="false"
  31. :field-names="{ key: 'key', title: 'title', children: 'children' }"
  32. :default-expand-all="true"
  33. v-model:selectedKeys="selectedKeys"
  34. @select="handleSelect"
  35. >
  36. <template #title="node">
  37. <div v-if="isTreeFileNode(node.raw)" class="tree-file-row" :title="getTreeFileName(node.raw)">
  38. <span class="tree-file-name">{{ getTreeFileName(node.raw) }}</span>
  39. </div>
  40. <div
  41. v-else
  42. class="tree-sheet-row"
  43. :class="{
  44. 'is-success': getSheetNodeVisualState(node.raw) === 'success',
  45. 'is-fail': getSheetNodeVisualState(node.raw) === 'fail',
  46. 'is-analyzing': getSheetNodeVisualState(node.raw) === 'analyzing',
  47. }"
  48. :title="getTreeSheetTitle(node.raw)"
  49. >
  50. <span class="tree-sheet-status">
  51. <span
  52. v-if="getSheetNodeVisualState(node.raw) === 'analyzing'"
  53. class="tree-sheet-status-spinner"
  54. ></span>
  55. <template v-else>
  56. {{ getSheetNodeVisualState(node.raw) === 'success' ? '✓' : '✕' }}
  57. </template>
  58. </span>
  59. <span class="tree-sheet-name">{{ getTreeSheetName(node.raw) }}</span>
  60. </div>
  61. </template>
  62. </BasicTree>
  63. </div>
  64. <div class="legend">
  65. <div v-for="item in iconTips" :key="item.key" class="legend-item">
  66. <span class="legend-icon" :class="`is-${item.key}`">
  67. <span v-if="item.key === 'matched' || item.key === 'unmatched'" class="legend-dot"></span>
  68. <template v-else>{{ item.icon }}</template>
  69. </span>
  70. <span class="legend-text">{{ item.label }}</span>
  71. </div>
  72. </div>
  73. </aside>
  74. <div v-show="!leftCollapsed" class="panel-resizer" @mousedown="startResize('left', $event)"></div>
  75. <section class="middle-panel">
  76. <div class="template-match-bar" :class="{ 'is-empty': !hasTemplateSelected }">
  77. <div class="template-match-main">
  78. <span class="template-match-icon"></span>
  79. <div class="template-match-copy">
  80. <div class="template-match-label">匹配模板</div>
  81. <div class="template-match-name">
  82. {{ currentTemplateDisplayName }}
  83. <span v-if="!hasTemplateSelected" class="template-match-badge is-pending">待配置</span>
  84. <span v-else-if="templateDetailFromMock" class="template-match-badge">示例</span>
  85. </div>
  86. </div>
  87. </div>
  88. <Button type="link" class="template-select-trigger" @click="openTemplateModal">选择模板</Button>
  89. </div>
  90. <template v-if="hasTemplateSelected">
  91. <div class="middle-panel-body">
  92. <FieldMappingCard />
  93. <div
  94. class="template-foot-panel"
  95. :class="{
  96. 'is-collapsed': showRecognitionPanel && !recognitionPanelExpanded,
  97. 'is-compact': !showRecognitionPanel,
  98. }"
  99. >
  100. <RecognitionRulePanel />
  101. <ChildTemplatePanel />
  102. </div>
  103. </div>
  104. </template>
  105. <MiddleEmptyState v-else />
  106. </section>
  107. <div class="panel-resizer" @mousedown="startResize('middle', $event)"></div>
  108. <section class="right-panel">
  109. <div class="panel-header">
  110. <div class="panel-title-wrap">
  111. <div class="panel-title">数据清洗区</div>
  112. </div>
  113. </div>
  114. <div class="toolbar toolbar--column-actions">
  115. <div class="toolbar-head">
  116. <span class="toolbar-label">列操作</span>
  117. </div>
  118. <div class="toolbar-actions">
  119. <Button size="small" type="primary" @click="openAddColumn">新增列</Button>
  120. <Button size="small" @click="openSplitColumn">拆分列</Button>
  121. <Button size="small" @click="openEditColumn">编辑列</Button>
  122. <Button size="small" @click="openDeleteColumn">删除列</Button>
  123. </div>
  124. <div class="toolbar-meta toolbar-meta--header-row">
  125. <span class="meta-label">表头行</span>
  126. <Input
  127. v-model:value="headerRowInput"
  128. size="small"
  129. class="meta-input"
  130. placeholder="输入行号"
  131. @pressEnter="applyHeaderRowInput"
  132. @blur="applyHeaderRowInput"
  133. />
  134. </div>
  135. </div>
  136. <div class="right-panel-body">
  137. <div ref="tableMockRef" class="table-mock">
  138. <div class="mock-head" :style="{ gridTemplateColumns: mockGridTemplate }">
  139. <span class="row-index-cell row-index-head">表头行</span>
  140. <span
  141. v-for="(header, colIndex) in rightTableHeaders"
  142. :key="header"
  143. :data-preview-col="colIndex"
  144. :class="{
  145. 'is-added': addedColumns.includes(header),
  146. active: selectedAddedColumn === header,
  147. 'is-current-col': currentColumn === colIndex,
  148. 'is-first-col': colIndex === 0,
  149. 'is-last-col': colIndex === rightTableHeaders.length - 1,
  150. }"
  151. @click="onClickHeader(header)"
  152. >
  153. <span>{{ header }}</span>
  154. <span
  155. v-if="getColumnRuleBadgeState(colIndex)"
  156. class="rule-badge"
  157. :class="`is-${getColumnRuleBadgeState(colIndex)}`"
  158. :title="getColumnRuleBadgeTitle(colIndex)"
  159. ></span>
  160. </span>
  161. </div>
  162. <div class="mock-match-row" :style="{ gridTemplateColumns: mockGridTemplate }">
  163. <span class="row-index-cell match-row-index">目标字段</span>
  164. <span
  165. v-for="(header, colIndex) in rightTableHeaders"
  166. :key="`mapping-${header}`"
  167. :data-preview-col="colIndex"
  168. :class="{
  169. 'is-current-col': currentColumn === colIndex,
  170. 'is-first-col': colIndex === 0,
  171. 'is-last-col': colIndex === rightTableHeaders.length - 1,
  172. 'is-empty': !getMatchedTargetFieldText(header),
  173. }"
  174. @click="onClickHeader(header)"
  175. >
  176. <span class="match-field-content">
  177. <span class="match-field-text">{{ getMatchedTargetFieldText(header) || '-' }}</span>
  178. <span class="match-status-group">
  179. <span
  180. class="match-status-badge"
  181. :class="getMatchedHeaderStatusClass(header)"
  182. :title="getMatchedHeaderStatusTitle(header) || undefined"
  183. >
  184. {{ getMatchedHeaderStatusText(header) }}
  185. </span>
  186. <span
  187. v-if="hasHeaderFormatError(header)"
  188. class="match-status-badge is-formatError"
  189. :title="getHeaderFormatErrorTitle(header) || undefined"
  190. >
  191. !
  192. </span>
  193. </span>
  194. </span>
  195. </span>
  196. </div>
  197. <div
  198. class="mock-row"
  199. :style="{ gridTemplateColumns: mockGridTemplate }"
  200. :class="{
  201. 'is-ignored-row': ignoredRowsSet.has(rowIndex + 1),
  202. 'is-current-row': selectedRowIndex === rowIndex,
  203. 'is-header-row': headerRowIndex === rowIndex,
  204. }"
  205. v-for="(row, rowIndex) in tablePreviewRows"
  206. :key="`row-${rowIndex}`"
  207. >
  208. <span class="row-index-cell" @click="selectedRowIndex = rowIndex">
  209. {{ rowIndex + 1 }}
  210. </span>
  211. <span
  212. v-for="(header, colIndex) in rightTableHeaders"
  213. :key="`${header}-${rowIndex}`"
  214. :data-preview-col="colIndex"
  215. :class="{
  216. 'is-current-col': currentColumn === colIndex,
  217. 'is-first-col': colIndex === 0,
  218. 'is-last-col': colIndex === rightTableHeaders.length - 1,
  219. }"
  220. @click="onClickCell(rowIndex, header, colIndex)"
  221. >
  222. {{ row[header] || '' }}
  223. </span>
  224. </div>
  225. </div>
  226. <CleaningRulePanel />
  227. </div>
  228. </section>
  229. </div>
  230. <AddColumnModal />
  231. <SplitColumnModal />
  232. <Modal v-model:open="deleteColumnDialogOpen" title="删除列" :width="520" :centered="true" @ok="confirmDeleteColumn">
  233. <div>确认删除列「{{ selectedAddedColumn || '-' }}」?</div>
  234. </Modal>
  235. <TemplateSelectModal />
  236. </div>
  237. </template>
  238. <script lang="ts" setup name="ViewsDataCleaning">
  239. import { computed, nextTick, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue';
  240. import { DownOutlined, UpOutlined } from '@ant-design/icons-vue';
  241. import { Button, Checkbox, Input, InputNumber, Modal, Popover, Radio, Select, Switch, Tooltip } from 'ant-design-vue';
  242. import { EmptyState } from '@/core/components/EmptyState';
  243. import { BasicTree } from '@/core/components/Tree';
  244. import { useDesign } from '@/core/hooks/web/useDesign';
  245. import { useGo, useQuery } from '@/core/hooks/web/usePage';
  246. import { PageEnum } from '@/core/enums/pageEnum';
  247. import { useMessage } from '@/core/hooks/web/useMessage';
  248. import type { FileInfo } from '@/types/api';
  249. import { tplCategoryList, tplCheckTableName, tplDetail, tplPage } from '@/plat/api/tpl/tplApi';
  250. import { rawPreview } from '@/case/api/govern/governApi';
  251. import type { TableInfo } from '@/plat/api/tpl/types/dto';
  252. import {
  253. FunAbs,
  254. FunDate,
  255. FunDateTime,
  256. FunExtra,
  257. FunMultiplier,
  258. FunRadix,
  259. FunRegular,
  260. FunReplace,
  261. FunSpExtra,
  262. FunTime,
  263. MappingEnum,
  264. RuleMapping,
  265. RuleSplit,
  266. } from '../../../dm/types/Func';
  267. import {
  268. buildRemoveSpecialRegex,
  269. groupColumnsFnValue,
  270. normalizeDecimalBaseLabel,
  271. runCleaningPreviewPipeline,
  272. type CleaningRuleKey as RulePipelineKey,
  273. } from './cleaningRuleUtils';
  274. import { resetImportProgressSession } from './importProgressSession';
  275. import { FileStatusEnum } from '../../types/enum';
  276. import { FILE_INFO_STORAGE_KEY, IMPORT_FILE_INFO_STORAGE_KEY, dateTypeOptions, decimalBaseOptions, defaultRightTableHeaders, delimiterOptions, iconTips, mockDataColumnWidth, mockIndexColumnWidth, replaceModeOptions, ruleExampleMap, ruleNameMap, rulesList } from './cleaning/constants';
  277. import type { CleaningRuleItem, CleaningRuleKey, FileTreeNode, FormatValidationType, GroupingColumnDraft, HeaderFormatValidationState, MappingTargetField, NodeCacheState, PreviewColumnSnapshot, PreviewFileDataset, PreviewStateSnapshot, RecognitionBasicInfoRow, RecognitionDirectionComparableMap, RecognitionDirectionField, RecognitionDirectionItem, RecognitionRuleTabKey, SelectedRuleItem, SheetNodeVisualState, TemplateListItem, TemplateTab, TreeFileInfo, TreeStoredColumnRule, TreeStoredFunRule, TreeTableRuleInfo } from './cleaning/types';
  278. import { createGroupingColumnDraft, getFilledGroupingFieldCount, normalizeGroupingColumnsDraft, normalizeSplitColumnMode, normalizeSplitColumnValue, resolveSplitColumnLength, splitSourceValueByConfig } from './cleaning/utils/columnSplit';
  279. import { getVisibleOriginOptionList, scrollSelectedTreeNodeIntoView } from './cleaning/utils/domScroll';
  280. import { buildFormatValidationTitle, isValidAcctCardNoValue, isValidDateOnlyValue, isValidDateTimeValue, isValidIdCardNumberValue, isValidNumberValue, isValidPhoneValue, isValidTimeOnlyValue, normalizeCallTypeCoreValue, normalizeFormatValidationType, normalizeLoanFlagCoreValue, shouldSkipFormatValidationByTarget } from './cleaning/utils/formatValidate';
  281. import { buildHeaderLabelsByRow, collectMatchedSheetsWithoutHeaderRow, pickPreviewRowByHeaders } from './cleaning/utils/headerRow';
  282. import { loadStoredFileList, mockFileList, mockTemplateList, mockTemplateMap } from './cleaning/utils/mockData';
  283. import { clearRecognitionBasicInfoFilledContent, hasOwnRecognitionBasicInfoConfig, hasRecognitionBasicInfoFilledContent, isSameRecognitionBasicInfoConfig, mergeRecognitionDirectionKeywords, normalizeRecognitionBasicInfoText, normalizeRecognitionDirectionGroup, parseRecognitionBasicInfoRows, parseRecognitionDirectionEntries, resolveRecognitionBasicInfoTargetField, resolveRecognitionDirectionTab, splitRecognitionDirectionKeywords } from './cleaning/utils/recognition';
  284. import { buildStoredColumnRulePayload, defaultRuleConfig, deserializeStoredRule, getSplitColumnRuleIdentity, isCleaningRuleEnabledInUi, sanitizeFileInfoTree, sanitizeSelectedRuleGroups, sanitizeSelectedRuleList, serializeStoredFunRules } from './cleaning/utils/ruleSerialize';
  285. import { buildTargetFieldsFromRuleList, buildTargetFieldsFromTemplateInfo, findTransAmountTableRule, findTransAmountTemplateFieldMeta, getOrderedTemplateFields, getRawRuleFlagValue, getRecordZfValue, getTableRuleRequiredFlag, getTableRuleTargetField, getTableRuleTargetKey, getTableRuleTargetLabel, getTemplateFieldRequiredFlag, getTemplateFieldTargetKey, getTemplateFieldTargetLabel, getZfFlag, isLoanFlagTargetField, isTransAmountTargetField, normalizeTableRuleList, pickFirstDefinedRuleFlagValue, resolveStoredZfFlag } from './cleaning/utils/tableRuleBuild';
  286. import { buildStoredLineNo, cloneDeepSafe, findBestMatchedHeader, getRequestErrorMessage, normalizeColumnNameForCompare, normalizeFieldMatchText, normalizeFormatValue, normalizeGeneratedColumnHeaderRowIndex, normalizeRecognitionConfigText, normalizeRuleText, normalizeSingleLineText, normalizeStoredLineNoToRowIndex, normalizeTemplateClassifyText, normalizeTemplateHeaders, normalizeTemplateId, normalizeTemplateNameKey, renameRecordKeys, resolveTemplateNameCheckResult, toRuleBoolean } from './cleaning/utils/text';
  287. import { buildTreeFromFlatList, findFirstSelectableKey, findTreeNodeByKey, getFileStatusVisualState, getTreeFileName, getTreeNodeKey, getTreeSheetName, getTreeSheetTitle, isFileRootItem, isTemplateMatchedFailStatus, isTreeFileNode, isTreeSheetNode, toTreeNodeDeep } from './cleaning/utils/treeUtils';
  288. import AddColumnModal from './cleaning/components/AddColumnModal.vue';
  289. import SplitColumnModal from './cleaning/components/SplitColumnModal.vue';
  290. import TemplateSelectModal from './cleaning/components/TemplateSelectModal.vue';
  291. import { CleaningContextKey } from './cleaning/context';
  292. import CleaningRulePanel from './cleaning/components/CleaningRulePanel.vue';
  293. import { useCleaningRules } from './cleaning/composables/useCleaningRules';
  294. import FieldMappingCard from './cleaning/components/FieldMappingCard.vue';
  295. import OriginFieldPopover from './cleaning/components/OriginFieldPopover.vue';
  296. import RecognitionRulePanel from './cleaning/components/RecognitionRulePanel.vue';
  297. import ChildTemplatePanel from './cleaning/components/ChildTemplatePanel.vue';
  298. import MiddleEmptyState from './cleaning/components/MiddleEmptyState.vue';
  299. import { useFieldMapping } from './cleaning/composables/useFieldMapping';
  300. import { useRecognitionRules } from './cleaning/composables/useRecognitionRules';
  301. import { useTemplateLibrary } from './cleaning/composables/useTemplateLibrary';
  302. import { useCaseDataCleaningShared } from './cleaning/composables/useCaseDataCleaningShared';
  303. const { prefixCls } = useDesign('case-data-cleaning');
  304. const go = useGo();
  305. const pageQuery = useQuery();
  306. const { createMessage } = useMessage();
  307. const importingData = ref(false);
  308. const leftCollapsed = ref(false);
  309. const leftPanelWidth = ref(300);
  310. const middlePanelWidth = ref(520);
  311. const minLeftWidth = 220;
  312. const maxLeftWidth = 520;
  313. const minMiddleWidth = 360;
  314. const maxMiddleWidth = 900;
  315. const minRightWidth = 560;
  316. const resizeState = ref<{ type: 'left' | 'middle' | ''; startX: number; startLeft: number; startMiddle: number }>({
  317. type: '',
  318. startX: 0,
  319. startLeft: 300,
  320. startMiddle: 520,
  321. });
  322. const pendingClientX = ref<number | null>(null);
  323. const rafId = ref<number | null>(null);
  324. const layoutTemplateColumns = computed(() => {
  325. if (leftCollapsed.value) {
  326. return `${middlePanelWidth.value}px 6px minmax(${minRightWidth}px, 1fr)`;
  327. }
  328. return `${leftPanelWidth.value}px 6px ${middlePanelWidth.value}px 6px minmax(${minRightWidth}px, 1fr)`;
  329. });
  330. const resolveSelectableTreeKey = (key: string) => {
  331. const selectedNode = findTreeNodeByKey(treeData.value, key);
  332. if (!selectedNode) return '';
  333. if (selectedNode.selectable !== false) return selectedNode.key;
  334. return findFirstSelectableKey(selectedNode.children || []);
  335. };
  336. const buildNodeStatusSignature = (state: NodeCacheState) =>
  337. JSON.stringify({
  338. selectedTemplateId: state.selectedTemplateId,
  339. fieldMapping: state.fieldMapping,
  340. rightTableHeaders: state.rightTableHeaders,
  341. addedColumns: state.addedColumns,
  342. headerRowIndex: state.headerRowIndex,
  343. selectRules: state.selectRules.map((rules) =>
  344. rules.map((rule) => ({
  345. key: rule.key,
  346. name: rule.name,
  347. enabled: rule.enabled,
  348. rules: rule.rules,
  349. })),
  350. ),
  351. selectedRules: state.selectedRules.map((rule) => ({
  352. key: rule.key,
  353. name: rule.name,
  354. enabled: rule.enabled,
  355. rules: rule.rules,
  356. })),
  357. addedColumnConfig: state.addedColumnConfig,
  358. splitColumnConfig: state.splitColumnConfig,
  359. generatedColumnHeaderRowMap: state.generatedColumnHeaderRowMap,
  360. recognitionDirectionFields: (state.recognitionDirectionFields || []).map((item) => ({
  361. key: item.key,
  362. label: item.label,
  363. tab: item.tab,
  364. entries: item.entries.map((entry) => ({
  365. label: entry.label,
  366. value: entry.value,
  367. })),
  368. })),
  369. recognitionBasicInfoBaseFuncRegx: state.recognitionBasicInfoBaseFuncRegx,
  370. recognitionBasicInfoManualVisible: state.recognitionBasicInfoManualVisible,
  371. recognitionBasicInfoRows: state.recognitionBasicInfoRows.map((item) => ({
  372. headerMatch: item.headerMatch,
  373. targetField: item.targetField,
  374. endFlag: item.endFlag,
  375. })),
  376. recognitionAmountZfEnabled: state.recognitionAmountZfEnabled,
  377. childTemplateName: state.childTemplateName,
  378. });
  379. const applyNodeCacheState = (state: NodeCacheState) => {
  380. selectedTemplateId.value = state.selectedTemplateId;
  381. fieldMapping.value = cloneDeepSafe(state.fieldMapping);
  382. rightTableHeaders.value = [...state.rightTableHeaders];
  383. addedColumns.value = [...state.addedColumns];
  384. selectedAddedColumn.value = state.selectedAddedColumn;
  385. sourceTableRows.value = cloneDeepSafe(state.sourceTableRows);
  386. selectRules.value = sanitizeSelectedRuleGroups(cloneDeepSafe(state.selectRules));
  387. selectedRules.value = sanitizeSelectedRuleList(cloneDeepSafe(state.selectedRules));
  388. activeRules.value = state.activeRules;
  389. currentColumn.value = state.currentColumn;
  390. selectedRowIndex.value = state.selectedRowIndex;
  391. headerRowIndex.value = state.headerRowIndex;
  392. headerRowInput.value = state.headerRowInput;
  393. currentRuleTitle.value = state.currentRuleTitle;
  394. omitLineInput.value = state.omitLineInput;
  395. dateConfigType.value = state.dateConfigType;
  396. extractDataBefore.value = state.extractDataBefore;
  397. extractDataAfter.value = state.extractDataAfter;
  398. syncExtractDataMode();
  399. extractSegmentValue.value = state.extractSegmentValue;
  400. extractSegmentRow.value = state.extractSegmentRow;
  401. absoluteValueChecked.value = state.absoluteValueChecked;
  402. decimalEnabled.value = state.decimalEnabled;
  403. decimalSourceBase.value = state.decimalSourceBase;
  404. decimalTargetBase.value = state.decimalTargetBase;
  405. replaceEnabled.value = state.replaceEnabled;
  406. replaceMode.value = state.replaceMode;
  407. replaceSource.value = state.replaceSource;
  408. replaceTarget.value = state.replaceTarget;
  409. removeSpaceChecked.value = state.removeSpaceChecked;
  410. removeTabChecked.value = state.removeTabChecked;
  411. removeCustomChecked.value = state.removeCustomChecked;
  412. removeCustomText.value = state.removeCustomText;
  413. groupingEnabled.value = state.groupingEnabled;
  414. groupingColumnsDraft.value = normalizeGroupingColumnsDraft(cloneDeepSafe(state.groupingColumnsDraft));
  415. magnificationValue.value = state.magnificationValue;
  416. addedColumnConfig.value = cloneDeepSafe(state.addedColumnConfig);
  417. splitColumnConfig.value = cloneDeepSafe(state.splitColumnConfig);
  418. generatedColumnHeaderRowMap.value = buildGeneratedColumnHeaderRowMap(
  419. state.addedColumns,
  420. state.sourceTableRows,
  421. state.generatedColumnHeaderRowMap,
  422. state.headerRowIndex,
  423. );
  424. recognitionDirectionFields.value = cloneDeepSafe(state.recognitionDirectionFields || []);
  425. recognitionBasicInfoBaseFuncRegx.value = state.recognitionBasicInfoBaseFuncRegx;
  426. recognitionBasicInfoManualVisible.value = state.recognitionBasicInfoManualVisible;
  427. recognitionBasicInfoExpanded.value = state.recognitionBasicInfoExpanded;
  428. recognitionBasicInfoRows.value = cloneDeepSafe(state.recognitionBasicInfoRows);
  429. recognitionAmountZfEnabled.value = !!state.recognitionAmountZfEnabled;
  430. childTemplateName.value = state.childTemplateName;
  431. if (!selectedRules.value.some((item) => item.active) && currentColumn.value !== null) {
  432. const currentColumnRules = selectRules.value[currentColumn.value] || [];
  433. if (!currentColumnRules.some((item) => item.active)) {
  434. currentRuleTitle.value = '';
  435. }
  436. }
  437. runRulePipeline();
  438. };
  439. const resetNodeWorkState = () => {
  440. selectedTemplateId.value = 0;
  441. templateDetail.value = null;
  442. templateDetailFromMock.value = false;
  443. activeTemplateItem.value = null;
  444. fieldMapping.value = {};
  445. activeMappingTargetKey.value = '';
  446. rightTableHeaders.value = [];
  447. addedColumns.value = [];
  448. selectedAddedColumn.value = '';
  449. sourceTableRows.value = [];
  450. selectRules.value = [];
  451. selectedRules.value = [];
  452. activeRules.value = 0;
  453. currentColumn.value = null;
  454. selectedRowIndex.value = null;
  455. headerRowIndex.value = null;
  456. headerRowInput.value = '';
  457. currentRuleTitle.value = '';
  458. omitLineInput.value = '';
  459. dateConfigType.value = 1;
  460. extractDataMode.value = 'before';
  461. extractDataBefore.value = '';
  462. extractDataAfter.value = '';
  463. extractSegmentValue.value = '';
  464. extractSegmentRow.value = 1;
  465. absoluteValueChecked.value = false;
  466. decimalEnabled.value = false;
  467. decimalSourceBase.value = '10进制';
  468. decimalTargetBase.value = '16进制';
  469. replaceEnabled.value = true;
  470. replaceMode.value = 0;
  471. replaceSource.value = '';
  472. replaceTarget.value = '';
  473. removeSpaceChecked.value = false;
  474. removeTabChecked.value = false;
  475. removeCustomChecked.value = false;
  476. removeCustomText.value = '';
  477. groupingEnabled.value = false;
  478. groupingColumnsDraft.value = normalizeGroupingColumnsDraft();
  479. magnificationValue.value = 1;
  480. addedColumnConfig.value = {};
  481. splitColumnConfig.value = {};
  482. generatedColumnHeaderRowMap.value = {};
  483. recognitionDirectionFields.value = [];
  484. recognitionBasicInfoBaseFuncRegx.value = '';
  485. recognitionBasicInfoManualVisible.value = false;
  486. recognitionBasicInfoExpanded.value = false;
  487. recognitionBasicInfoRows.value = [];
  488. recognitionAmountZfEnabled.value = false;
  489. childTemplateName.value = '';
  490. runRulePipeline();
  491. };
  492. const switchNodeState = async (nextKey: string) => {
  493. if (!nextKey) return;
  494. if (activeNodeKey.value === nextKey) return;
  495. saveCurrentNodeState();
  496. setNodeAnalyzing(nextKey, true);
  497. activeNodeKey.value = nextKey;
  498. const currentNode = findTreeNodeByKey(treeData.value, nextKey)?.raw || null;
  499. structureSyncSuspended.value = true;
  500. try {
  501. const cached = nodeStateCache.get(nextKey);
  502. if (cached) {
  503. applyNodeCacheState(cached);
  504. await loadTemplateDetail(cached.selectedTemplateId, { silent: true, requestDetail: false });
  505. touchNodeCache(nextKey, cached);
  506. return;
  507. }
  508. resetNodeWorkState();
  509. const shouldLoadNodeTemplate = !isTemplateMatchedFailStatus(currentNode?.fileStatus);
  510. await loadTemplateDetail(shouldLoadNodeTemplate ? currentNode?.templateId : undefined, {
  511. silent: true,
  512. requestDetail: false,
  513. });
  514. await applyNodePreset(currentNode);
  515. const initialState = buildNodeCacheState();
  516. touchNodeCache(nextKey, initialState);
  517. if (!initialNodeStatusSignatureMap.has(nextKey)) {
  518. initialNodeStatusSignatureMap.set(nextKey, buildNodeStatusSignature(initialState));
  519. }
  520. } finally {
  521. structureSyncSuspended.value = false;
  522. syncActiveNodeStructure();
  523. setNodeAnalyzing(nextKey, false);
  524. }
  525. };
  526. const persistFileListToSessionStorage = () => {
  527. try {
  528. sessionStorage.setItem(FILE_INFO_STORAGE_KEY, JSON.stringify(sanitizeFileInfoTree(fileList.value)));
  529. } catch (error) {
  530. // 忽略序列化异常,避免影响当前清洗编辑流程。
  531. }
  532. };
  533. const applyPreviewDataset = (dataset: PreviewFileDataset) => {
  534. rightTableHeaders.value = [...dataset.headers];
  535. addedColumns.value = [];
  536. generatedColumnHeaderRowMap.value = {};
  537. selectedAddedColumn.value = '';
  538. sourceTableRows.value = dataset.rows.length ? dataset.rows : [];
  539. tablePreviewRows.value = cloneDeepSafe(sourceTableRows.value);
  540. return true;
  541. };
  542. const insertHeaderAt = (header: string, targetIndex?: number | null) => {
  543. if (!header) return;
  544. if (!rightTableHeaders.value.includes(header)) {
  545. const maxIndex = rightTableHeaders.value.length;
  546. const nextIndex =
  547. typeof targetIndex === 'number' && Number.isInteger(targetIndex)
  548. ? Math.min(Math.max(targetIndex, 0), maxIndex)
  549. : maxIndex;
  550. rightTableHeaders.value.splice(nextIndex, 0, header);
  551. }
  552. if (!addedColumns.value.includes(header)) {
  553. addedColumns.value.push(header);
  554. }
  555. if (!Object.prototype.hasOwnProperty.call(generatedColumnHeaderRowMap.value, header)) {
  556. generatedColumnHeaderRowMap.value[header] = headerRowIndex.value;
  557. }
  558. sourceTableRows.value = sourceTableRows.value.map((row) => ({
  559. ...row,
  560. [header]: String(row[header] ?? ''),
  561. }));
  562. };
  563. const buildColumnRulePayload = (header: string): TreeStoredColumnRule | null => {
  564. const splitConfig = splitColumnConfig.value[header];
  565. if (splitConfig) {
  566. const splitNameIndex = rightTableHeaders.value.findIndex((item) => item === splitConfig.targetName1);
  567. const splitNameIndex2 = rightTableHeaders.value.findIndex((item) => item === splitConfig.targetName2);
  568. const splitMode = normalizeSplitColumnMode(splitConfig.delimiterType);
  569. const splitValue = normalizeSplitColumnValue(splitConfig.delimiterType, splitConfig.delimiterValue);
  570. const splitLength = resolveSplitColumnLength(splitConfig.delimiterType, splitConfig.delimiterValue);
  571. const splitRule = new RuleSplit();
  572. splitRule.refIndex = rightTableHeaders.value.findIndex((item) => item === splitConfig.source);
  573. splitRule.mappingEnum = splitMode === 'length' ? MappingEnum.SP_LEN : MappingEnum.SP_CHAR;
  574. splitRule.splitNameIndex = splitNameIndex >= 0 ? splitNameIndex : undefined;
  575. splitRule.splitNameIndex2 = splitNameIndex2 >= 0 ? splitNameIndex2 : undefined;
  576. splitRule.len = splitMode === 'length' && splitLength > 0 ? splitLength : undefined;
  577. splitRule.splitChar = splitMode === 'special' && splitValue ? splitValue : undefined;
  578. return buildStoredColumnRulePayload(splitRule);
  579. }
  580. const addedConfig = addedColumnConfig.value[header];
  581. if (!addedConfig) return null;
  582. const mappingRule = new RuleMapping();
  583. mappingRule.refIndex = addedConfig.contentFields
  584. .map((field) => rightTableHeaders.value.findIndex((item) => item === field))
  585. .filter((index) => index >= 0);
  586. mappingRule.mappingEnum =
  587. addedConfig.mode === 'custom'
  588. ? addedConfig.customContent
  589. ? MappingEnum.NEW_DATA
  590. : MappingEnum.BLANK
  591. : mappingRule.refIndex.length
  592. ? MappingEnum.REF
  593. : MappingEnum.BLANK;
  594. mappingRule.content = addedConfig.mode === 'custom' ? addedConfig.customContent || '' : '';
  595. return buildStoredColumnRulePayload(mappingRule);
  596. };
  597. const restoreStoredColumnRules = (list?: TreeTableRuleInfo[] | null) => {
  598. const headerSnapshot = [...rightTableHeaders.value];
  599. const normalizedRules = normalizeTableRuleList(list);
  600. const splitRuleGroups = normalizedRules.reduce((acc, rule) => {
  601. const header = normalizeRuleText(rule.fileColName);
  602. const columnRule = rule.columnRule;
  603. const className = normalizeRuleText(columnRule?.['@class']);
  604. const mode = normalizeRuleText(columnRule?.mode);
  605. if (!header || (!className.endsWith('RuleSplit') && mode !== 'split')) {
  606. return acc;
  607. }
  608. const fileColIndex = Number(rule.fileColIndex);
  609. const identity = getSplitColumnRuleIdentity(columnRule);
  610. const prev = acc.get(identity) || [];
  611. prev.push({
  612. header,
  613. fileColIndex: Number.isInteger(fileColIndex) && fileColIndex >= 0 ? fileColIndex : Number.MAX_SAFE_INTEGER,
  614. });
  615. acc.set(identity, prev);
  616. return acc;
  617. }, new Map<string, Array<{ header: string; fileColIndex: number }>>());
  618. normalizedRules.forEach((rule) => {
  619. const header = normalizeRuleText(rule.fileColName);
  620. if (!header) return;
  621. const columnRule = rule.columnRule;
  622. const fileColIndex = Number(rule.fileColIndex);
  623. const targetIndex = Number.isInteger(fileColIndex) && fileColIndex >= 0 ? fileColIndex : undefined;
  624. if (columnRule) {
  625. insertHeaderAt(header, targetIndex);
  626. }
  627. const className = normalizeRuleText(columnRule?.['@class']);
  628. const mode = normalizeRuleText(columnRule?.mode);
  629. if (className.endsWith('RuleSplit') || mode === 'split') {
  630. const refIndex = Number(columnRule?.refIndex);
  631. const source =
  632. Number.isInteger(refIndex) && refIndex >= 0
  633. ? headerSnapshot[refIndex] || rightTableHeaders.value[refIndex] || ''
  634. : '';
  635. if (!source) return;
  636. const splitChar = String(columnRule?.splitChar ?? '');
  637. const splitLength = Number(columnRule?.len);
  638. const splitMappingEnum = Number(columnRule?.mappingEnum);
  639. const isLengthSplit =
  640. splitMappingEnum === MappingEnum.SP_LEN ||
  641. (!Number.isInteger(splitMappingEnum) && Number.isInteger(splitLength) && splitLength > 0);
  642. const splitNameIndex = Number(columnRule?.splitNameIndex);
  643. const splitNameIndex2 = Number(columnRule?.splitNameIndex2);
  644. const groupedTargetItems = (splitRuleGroups.get(getSplitColumnRuleIdentity(columnRule)) || [])
  645. .slice()
  646. .sort((a, b) => a.fileColIndex - b.fileColIndex);
  647. const groupedTargets = groupedTargetItems.map((item) => item.header).filter(Boolean);
  648. const uniqueTargets = Array.from(new Set(groupedTargets));
  649. const resolveGroupedTargetByIndex = (index: number) =>
  650. Number.isInteger(index) && index >= 0
  651. ? groupedTargetItems.find((item) => item.fileColIndex === index)?.header || ''
  652. : '';
  653. let targetName1 = resolveGroupedTargetByIndex(splitNameIndex);
  654. let targetName2 = resolveGroupedTargetByIndex(splitNameIndex2);
  655. if (!targetName1 && !targetName2) {
  656. targetName1 = uniqueTargets[0] || header;
  657. targetName2 = uniqueTargets.find((item) => item !== targetName1) || '';
  658. }
  659. if (targetName1 && targetName2 && targetName1 === targetName2) {
  660. if (Number.isInteger(fileColIndex) && fileColIndex === splitNameIndex2) {
  661. targetName1 = '';
  662. } else {
  663. targetName2 = '';
  664. }
  665. }
  666. splitColumnConfig.value[header] = {
  667. source,
  668. delimiterType: isLengthSplit ? 'length' : 'special',
  669. delimiterValue: isLengthSplit ? String(splitLength > 0 ? splitLength : '') : splitChar,
  670. targetName1,
  671. targetName2,
  672. };
  673. return;
  674. }
  675. if (className.endsWith('RuleMapping') || mode === 'ref' || mode === 'custom') {
  676. const refIndexList = Array.isArray(columnRule?.refIndex)
  677. ? columnRule.refIndex.map((item) => Number(item)).filter((item) => Number.isInteger(item) && item >= 0)
  678. : [];
  679. const mappingEnum = Number(columnRule?.mappingEnum);
  680. const isRefMapping = mappingEnum === MappingEnum.REF || (!Number.isFinite(mappingEnum) && mode === 'ref');
  681. addedColumnConfig.value[header] = {
  682. mode: isRefMapping ? 'ref' : 'custom',
  683. contentFields: refIndexList
  684. .map((index) => headerSnapshot[index] || rightTableHeaders.value[index])
  685. .filter(Boolean),
  686. customContent: String(columnRule?.content ?? ''),
  687. };
  688. }
  689. });
  690. };
  691. const restoreStoredFunRules = (list?: TreeTableRuleInfo[] | null) => {
  692. selectedRules.value = [];
  693. selectRules.value = rightTableHeaders.value.map(() => []);
  694. const sharedRuleKeySet = new Set<string>();
  695. const mergeColumnRule = (
  696. columnRules: SelectedRuleItem[],
  697. nextRule: SelectedRuleItem,
  698. rawRule: TreeStoredFunRule,
  699. ) => {
  700. if (nextRule.key !== 'extractData') {
  701. columnRules.push(nextRule);
  702. return;
  703. }
  704. const lastRule = columnRules[columnRules.length - 1];
  705. if (!lastRule || lastRule.key !== 'extractData') {
  706. columnRules.push(nextRule);
  707. return;
  708. }
  709. const direction = normalizeRuleText(rawRule?.metaDirection) || (rawRule?.opened === false ? 'after' : 'before');
  710. if (direction === 'after') {
  711. lastRule.rules.nextModel = nextRule.rules.nextModel;
  712. } else {
  713. lastRule.rules.beforeModel = nextRule.rules.beforeModel;
  714. }
  715. };
  716. normalizeTableRuleList(list).forEach((tableRule) => {
  717. const header = normalizeRuleText(tableRule.fileColName);
  718. const columnIndex = header ? rightTableHeaders.value.findIndex((item) => item === header) : -1;
  719. const columnRules: SelectedRuleItem[] = [];
  720. (tableRule.funList || [])
  721. .slice()
  722. .sort((a, b) => Number(a?.sort ?? 0) - Number(b?.sort ?? 0))
  723. .forEach((rule) => {
  724. const nextRule = deserializeStoredRule(rule);
  725. if (!nextRule) return;
  726. if (nextRule.key === 'ignoreLine') {
  727. if (!sharedRuleKeySet.has(nextRule.key)) {
  728. selectedRules.value.push(nextRule);
  729. sharedRuleKeySet.add(nextRule.key);
  730. }
  731. return;
  732. }
  733. mergeColumnRule(columnRules, nextRule, rule);
  734. });
  735. if (columnIndex >= 0 && columnRules.length) {
  736. selectRules.value[columnIndex] = sanitizeSelectedRuleList(columnRules);
  737. }
  738. });
  739. selectedRules.value = sanitizeSelectedRuleList(selectedRules.value);
  740. };
  741. const restoreFileLevelRules = (item?: TreeFileInfo | null) => {
  742. const ignoreLines = Array.isArray(item?.ignoreLines)
  743. ? item.ignoreLines.map((line) => Number(line)).filter((line) => Number.isInteger(line) && line > 0)
  744. : [];
  745. if (ignoreLines.length) {
  746. const existingIgnoreRule = selectedRules.value.find((rule) => rule.key === 'ignoreLine');
  747. const nextIgnoreRules = {
  748. ...defaultRuleConfig(),
  749. ...(existingIgnoreRule?.rules || {}),
  750. omitLine: ignoreLines.join(','),
  751. };
  752. if (existingIgnoreRule) {
  753. existingIgnoreRule.rules = nextIgnoreRules;
  754. existingIgnoreRule.enabled = true;
  755. } else {
  756. selectedRules.value = [
  757. ...selectedRules.value,
  758. {
  759. name: ruleNameMap.ignoreLine,
  760. key: 'ignoreLine',
  761. active: selectedRules.value.length === 0,
  762. enabled: true,
  763. rules: nextIgnoreRules,
  764. },
  765. ];
  766. }
  767. }
  768. };
  769. const buildTableRuleListFromState = (list?: TreeTableRuleInfo[] | null) => {
  770. const previousRules = normalizeTableRuleList(list);
  771. const currentTemplateTableId = normalizeTemplateId(
  772. templateDetail.value?.id ??
  773. activeTemplateItem.value?.id ??
  774. selectedTemplateId.value ??
  775. activeNodeInfo.value?.templateId,
  776. );
  777. const previousRuleMap = previousRules.reduce(
  778. (acc, item, index) => {
  779. const key = getTableRuleTargetKey(item, index);
  780. if (key) acc[key] = item;
  781. return acc;
  782. },
  783. {} as Record<string, TreeTableRuleInfo>,
  784. );
  785. const sharedFunRules = serializeStoredFunRules(selectedRules.value);
  786. const directionConfMap = recognitionDirectionFields.value.reduce(
  787. (acc, item) => {
  788. acc[item.key] = buildRecognitionDirectionConf(item.entries);
  789. return acc;
  790. },
  791. {} as Record<string, string>,
  792. );
  793. return fixedTargetFields.value.map((field, index) => {
  794. const previous = previousRuleMap[field.key];
  795. const requiredMarker = getTableRuleRequiredFlag(previous, field.required) || field.required;
  796. const mappedHeader = normalizeRuleText(effectiveFieldMapping.value[field.key]);
  797. const matchedByRecognitionRule = isTargetHandledByRecognitionRule(field.key);
  798. const fileColIndex = mappedHeader ? rightTableHeaders.value.findIndex((item) => item === mappedHeader) : -1;
  799. const columnFunRules =
  800. fileColIndex >= 0 ? serializeStoredFunRules(selectRules.value[fileColIndex] || [], sharedFunRules.length) : [];
  801. const templateField = resolveTemplateFieldMeta(field, index);
  802. const templateFieldRecord = (templateField || {}) as Record<string, any>;
  803. // 交易金额字段以「正负识别」开关为准(开关本身已从节点/模板的 zf 初始化);
  804. // 其余字段沿用模板字段 / 上一条规则的 zf 并兜底 0,保证每条规则都带整数 zf
  805. // ——后端 TableRuleDTO.zf 是 Integer,清洗器 `rule.getZf() == 1` 会自动拆箱,缺字段必 NPE。
  806. const zf = isTransAmountTargetField(field)
  807. ? recognitionAmountZfEnabled.value
  808. ? 1
  809. : 0
  810. : resolveStoredZfFlag(templateFieldRecord, previous);
  811. const requiredFlagValue = pickFirstDefinedRuleFlagValue(
  812. getRawRuleFlagValue(templateFieldRecord, 'required'),
  813. getRawRuleFlagValue(previous, 'required'),
  814. requiredMarker ? 1 : 0,
  815. );
  816. const matchedFlagValue =
  817. matchedByRecognitionRule || mappedHeader
  818. ? 1
  819. : pickFirstDefinedRuleFlagValue(
  820. getRawRuleFlagValue(templateFieldRecord, 'matched'),
  821. getRawRuleFlagValue(previous, 'matched'),
  822. requiredMarker ? 1 : 0,
  823. );
  824. const matchedRequiredFlagValue = pickFirstDefinedRuleFlagValue(getRawRuleFlagValue(previous, 'matchedRequired'));
  825. const directionConf =
  826. directionConfMap[field.key] ||
  827. normalizeRecognitionConfigText(previous?.directionConf) ||
  828. normalizeRecognitionConfigText(templateFieldRecord.directionConf ?? templateFieldRecord.direction_conf);
  829. const fileRuleList = [...(index === 0 ? sharedFunRules : []), ...columnFunRules];
  830. return {
  831. id: previous?.id ?? null,
  832. tableId: currentTemplateTableId || normalizeTemplateId(previous?.tableId) || null,
  833. fieldNameCn: field.label,
  834. fieldNameEn: field.key,
  835. fieldType: normalizeRuleText(templateField?.fieldType) || normalizeRuleText(previous?.fieldType) || 'string',
  836. required: requiredFlagValue,
  837. fieldLen: previous?.fieldLen ?? null,
  838. matched: matchedFlagValue,
  839. matchedRequired: matchedRequiredFlagValue,
  840. directionConf: directionConf || null,
  841. zf,
  842. fieldSort: index + 1,
  843. fileColIndex: fileColIndex >= 0 ? fileColIndex : null,
  844. fileColName: mappedHeader || null,
  845. funList: fileRuleList,
  846. columnRule: mappedHeader ? buildColumnRulePayload(mappedHeader) : null,
  847. } as TreeTableRuleInfo;
  848. });
  849. };
  850. const syncNodeStructure = (node?: TreeFileInfo | null) => {
  851. if (!isTreeSheetNode(node)) return;
  852. const templateId = normalizeTemplateId(
  853. templateDetail.value?.id ?? activeTemplateItem.value?.id ?? selectedTemplateId.value ?? node.templateId,
  854. );
  855. const mainId = getSelectedTemplateMainId(node.mainId);
  856. const tableName =
  857. normalizeRuleText(templateDetail.value?.tableNameCn) ||
  858. normalizeRuleText(activeTemplateItem.value?.tableNameCn) ||
  859. normalizeRuleText(node.tableName);
  860. const baseHeaders = rightTableHeaders.value.filter((header) => !addedColumns.value.includes(header));
  861. const existingHeaders = normalizeTemplateHeaders(node.header);
  862. const hasRealHeaderConfig =
  863. headerRowIndex.value !== null ||
  864. !!existingHeaders.length ||
  865. addedColumns.value.length > 0 ||
  866. Object.values(effectiveFieldMapping.value).some(Boolean) ||
  867. baseHeaders.length > 0;
  868. const shouldPersistRecognitionBasicInfo = hasRecognitionBasicInfoFilledContent(recognitionBasicInfoRows.value);
  869. const ignoreLines = Array.from(ignoredRowsSet.value).sort((a, b) => a - b);
  870. const groupColumns = buildEnabledGroupingColumnsPayload();
  871. node.templateId = templateId || node.templateId || null;
  872. node.mainId = mainId || null;
  873. node.tableName = tableName || node.tableName || null;
  874. if (headerRowIndex.value !== null) {
  875. node.lineNo = buildStoredLineNo(headerRowIndex.value);
  876. }
  877. if (hasRealHeaderConfig) {
  878. node.header = baseHeaders.length ? [...baseHeaders] : [];
  879. }
  880. node.ignoreLines = ignoreLines;
  881. node.groupColumns = groupColumns;
  882. node.tableRuleList = buildTableRuleListFromState(node.tableRuleList);
  883. node.childTemplateName = normalizeRuleText(childTemplateName.value) || null;
  884. node.hasNew = !!node.childTemplateName;
  885. if (shouldPersistRecognitionBasicInfo) {
  886. node.funcRegx = buildRecognitionBasicInfoFuncRegx();
  887. } else if (Object.prototype.hasOwnProperty.call(node, 'funcRegx')) {
  888. node.funcRegx = null;
  889. }
  890. persistFileListToSessionStorage();
  891. };
  892. const hasHeaderFormatError = (header: string) => !!headerFormatValidationMap.value[header]?.invalidTargets.length;
  893. const getHeaderFormatErrorTitle = (header: string) => headerFormatValidationMap.value[header]?.title || '';
  894. const getMatchedTargetFieldText = (header: string) =>
  895. matchedTargetFieldsByHeader.value[header]?.map((item) => item.label).join('、') || '';
  896. const hasMatchedTargetField = (header: string) => !!matchedTargetFieldsByHeader.value[header]?.length;
  897. const syncHeaderRowInput = () => {
  898. headerRowInput.value = headerRowIndex.value === null ? '' : String(headerRowIndex.value + 1);
  899. };
  900. const applyHeaderRow = (rowIndex: number, options: { silent?: boolean; autoMatch?: boolean } = {}) => {
  901. const { silent = false, autoMatch = true } = options;
  902. if (rowIndex < 0 || rowIndex >= sourceTableRows.value.length) {
  903. syncHeaderRowInput();
  904. if (!silent) {
  905. createMessage.warning('请输入正确的表头行号');
  906. }
  907. return;
  908. }
  909. const previousHeaders = [...rightTableHeaders.value];
  910. const headerSourceRow = sourceTableRows.value[rowIndex] || ({} as Record<string, string>);
  911. const nextHeaders = buildHeaderLabelsByRow(previousHeaders, headerSourceRow, addedColumns.value);
  912. if (
  913. headerRowIndex.value === rowIndex &&
  914. nextHeaders.length === previousHeaders.length &&
  915. nextHeaders.every((header, index) => header === previousHeaders[index])
  916. ) {
  917. selectedRowIndex.value = rowIndex;
  918. syncHeaderRowInput();
  919. return;
  920. }
  921. const headerKeyMap = previousHeaders.reduce(
  922. (acc, header, index) => {
  923. acc[header] = nextHeaders[index];
  924. return acc;
  925. },
  926. {} as Record<string, string>,
  927. );
  928. const fixedHeaderSet = new Set(addedColumns.value);
  929. sourceTableRows.value = sourceTableRows.value.map((row, index) => {
  930. const nextRow: Record<string, string> = {};
  931. previousHeaders.forEach((header, colIndex) => {
  932. const nextHeader = nextHeaders[colIndex];
  933. if (fixedHeaderSet.has(header)) {
  934. nextRow[nextHeader] = String(row[header] ?? '');
  935. return;
  936. }
  937. nextRow[nextHeader] = index === rowIndex ? nextHeader : String(row[header] ?? '');
  938. });
  939. return nextRow;
  940. });
  941. rightTableHeaders.value = nextHeaders;
  942. generatedColumnHeaderRowMap.value = addedColumns.value.reduce(
  943. (acc, item) => {
  944. const nextKey = headerKeyMap[item] || item;
  945. acc[nextKey] = getGeneratedColumnHeaderRowIndex(item);
  946. return acc;
  947. },
  948. {} as Record<string, number | null>,
  949. );
  950. addedColumns.value = addedColumns.value.map((item) => headerKeyMap[item] || item);
  951. selectedAddedColumn.value = selectedAddedColumn.value ? headerKeyMap[selectedAddedColumn.value] || '' : '';
  952. fieldMapping.value = Object.entries(fieldMapping.value).reduce(
  953. (acc, [key, value]) => {
  954. acc[key] = headerKeyMap[value] || value;
  955. return acc;
  956. },
  957. {} as Record<string, string>,
  958. );
  959. addedColumnConfig.value = Object.entries(renameRecordKeys(addedColumnConfig.value, headerKeyMap)).reduce(
  960. (acc, [key, value]) => {
  961. acc[key] = {
  962. ...value,
  963. contentFields: value.contentFields.map((field) => headerKeyMap[field] || field),
  964. };
  965. return acc;
  966. },
  967. {} as Record<string, { mode: 'ref' | 'custom'; contentFields: string[]; customContent: string }>,
  968. );
  969. splitColumnConfig.value = Object.entries(renameRecordKeys(splitColumnConfig.value, headerKeyMap)).reduce(
  970. (acc, [key, value]) => {
  971. acc[key] = {
  972. ...value,
  973. source: headerKeyMap[value.source] || value.source,
  974. targetName1: headerKeyMap[value.targetName1] || value.targetName1,
  975. targetName2: headerKeyMap[value.targetName2] || value.targetName2,
  976. };
  977. return acc;
  978. },
  979. {} as Record<
  980. string,
  981. { source: string; delimiterType: string; delimiterValue: string; targetName1: string; targetName2: string }
  982. >,
  983. );
  984. selectRules.value = remapSelectRuleHeaderRefs(selectRules.value, headerKeyMap);
  985. groupingColumnsDraft.value = remapGroupingColumnRules(groupingColumnsDraft.value, headerKeyMap);
  986. if (splitColumnForm.value.source) {
  987. splitColumnForm.value.source = headerKeyMap[splitColumnForm.value.source] || splitColumnForm.value.source;
  988. }
  989. headerRowIndex.value = rowIndex;
  990. selectedRowIndex.value = rowIndex;
  991. syncHeaderRowInput();
  992. rebuildGeneratedColumnsInSourceRows();
  993. runRulePipeline();
  994. if (autoMatch) {
  995. autoMatchFields(false);
  996. }
  997. if (!silent) {
  998. createMessage.success(`已将第${rowIndex + 1}行设为表头行`);
  999. }
  1000. };
  1001. const applyHeaderRowInput = () => {
  1002. const rawValue = headerRowInput.value.trim();
  1003. if (!rawValue) {
  1004. syncHeaderRowInput();
  1005. return;
  1006. }
  1007. const rowNumber = Number(rawValue);
  1008. if (!Number.isInteger(rowNumber)) {
  1009. syncHeaderRowInput();
  1010. createMessage.warning('请输入正确的表头行号');
  1011. return;
  1012. }
  1013. applyHeaderRow(rowNumber - 1);
  1014. };
  1015. const ensureCurrentHeaderRowConfigured = (warningText = '请先填写表头所在行,再进行模板匹配') => {
  1016. if (headerRowIndex.value !== null) return true;
  1017. const rawValue = headerRowInput.value.trim();
  1018. if (!rawValue) {
  1019. createMessage.warning(warningText);
  1020. return false;
  1021. }
  1022. const rowNumber = Number(rawValue);
  1023. if (!Number.isInteger(rowNumber) || rowNumber <= 0 || rowNumber > sourceTableRows.value.length) {
  1024. createMessage.warning('请输入正确的表头行号');
  1025. return false;
  1026. }
  1027. applyHeaderRow(rowNumber - 1, { silent: true, autoMatch: false });
  1028. return headerRowIndex.value !== null;
  1029. };
  1030. const getColumnRuleBadgeState = (colIndex: number) => {
  1031. if (colIndex < 0) return false;
  1032. const rules = sanitizeSelectedRuleList(selectRules.value[colIndex] || []);
  1033. const hasEnabledRules = rules.some((rule) => rule && rule.key && rule.enabled);
  1034. if (hasEnabledRules) return 'active';
  1035. return rules.some((rule) => rule && rule.key) ? 'draft' : '';
  1036. };
  1037. const resolveTargetKeyByTableRule = (rule: TreeTableRuleInfo) => {
  1038. const fieldNameEn = normalizeRuleText(rule.fieldNameEn);
  1039. if (fieldNameEn) {
  1040. const matchedByKey = fixedTargetFields.value.find((item) => item.key === fieldNameEn);
  1041. if (matchedByKey) return matchedByKey.key;
  1042. }
  1043. const fieldNameCn = normalizeRuleText(rule.fieldNameCn);
  1044. if (fieldNameCn) {
  1045. const matchedByLabel =
  1046. fixedTargetFields.value.find((item) => item.label === fieldNameCn) ||
  1047. fixedTargetFields.value.find((item) => item.label.includes(fieldNameCn) || fieldNameCn.includes(item.label));
  1048. if (matchedByLabel) return matchedByLabel.key;
  1049. }
  1050. return '';
  1051. };
  1052. const applyTableRuleMapping = (list?: TreeTableRuleInfo[] | null) => {
  1053. const tableRules = normalizeTableRuleList(list);
  1054. if (!tableRules.length || !fixedTargetFields.value.length) return false;
  1055. const headerSet = new Set(rightTableHeaders.value);
  1056. const nextMapping = tableRules.reduce(
  1057. (acc, item) => {
  1058. const targetKey = resolveTargetKeyByTableRule(item);
  1059. const sourceField = normalizeRuleText(item.fileColName);
  1060. if (!targetKey || !sourceField || !headerSet.has(sourceField)) return acc;
  1061. acc[targetKey] = sourceField;
  1062. return acc;
  1063. },
  1064. {} as Record<string, string>,
  1065. );
  1066. if (!Object.keys(nextMapping).length) return false;
  1067. fieldMapping.value = { ...fieldMapping.value, ...nextMapping };
  1068. return true;
  1069. };
  1070. const isTargetMatchedByRecognitionBasicInfo = (targetKey: string) =>
  1071. recognitionBasicInfoMatchedTargetKeySet.value.has(targetKey);
  1072. const isTargetHandledByRecognitionRule = (targetKey: string) =>
  1073. recognitionRuleHandledTargetKeySet.value.has(targetKey);
  1074. const closeMappingPopoverPreview = () => {
  1075. mappingPopoverOpenKey.value = '';
  1076. originFilterKeyword.value = '';
  1077. clearOriginHoverPreviewState();
  1078. };
  1079. const ensureChildTemplateNamesAvailable = async (nodes: TreeFileInfo[] = []) => {
  1080. const childTemplateNames = collectChildTemplateNames(nodes);
  1081. if (!childTemplateNames.length) return true;
  1082. const duplicateNames = findDuplicateChildTemplateNames(childTemplateNames);
  1083. if (duplicateNames.length) {
  1084. showDuplicateChildTemplateWarning(duplicateNames);
  1085. return false;
  1086. }
  1087. const uniqueNameMap = new Map<string, string>();
  1088. childTemplateNames.forEach((name) => {
  1089. const key = normalizeTemplateNameKey(name);
  1090. if (key && !uniqueNameMap.has(key)) {
  1091. uniqueNameMap.set(key, name);
  1092. }
  1093. });
  1094. for (const name of uniqueNameMap.values()) {
  1095. const available = await checkTemplateNameAvailable(name);
  1096. if (!available) {
  1097. showChildTemplateExistsWarning(name);
  1098. return false;
  1099. }
  1100. }
  1101. return true;
  1102. };
  1103. const requiredTotal = computed(() => fixedTargetFields.value.filter((item) => isMappingTargetRequired(item)).length);
  1104. const requiredMatchedCount = computed(
  1105. () => fixedTargetFields.value.filter((item) => isMappingTargetRequired(item) && isTargetMatched(item.key)).length,
  1106. );
  1107. const matchedFieldCount = computed(() => fixedTargetFields.value.filter((item) => isTargetMatched(item.key)).length);
  1108. const applyRecognitionDirectionState = (node?: TreeFileInfo | null, options: { preferTemplate?: boolean } = {}) => {
  1109. recognitionDirectionFields.value = resolveRecognitionDirectionFields(node, options);
  1110. };
  1111. const resolveRecognitionBasicInfoSource = (
  1112. node?: TreeFileInfo | null,
  1113. options: { preferTemplate?: boolean } = {},
  1114. ) => {
  1115. const templateFuncRegx = normalizeRecognitionConfigText(
  1116. templateDetail.value?.funcRegx || activeTemplateItem.value?.funcRegx,
  1117. );
  1118. const hasNodeFuncRegx = hasOwnRecognitionBasicInfoConfig(node);
  1119. const nodeFuncRegx = normalizeRecognitionConfigText(node?.funcRegx);
  1120. if (options.preferTemplate) {
  1121. return templateFuncRegx;
  1122. }
  1123. if (hasNodeFuncRegx) {
  1124. return nodeFuncRegx;
  1125. }
  1126. return templateFuncRegx;
  1127. };
  1128. const applyRecognitionBasicInfoState = (node?: TreeFileInfo | null, options: { preferTemplate?: boolean } = {}) => {
  1129. const templateId = normalizeTemplateId(
  1130. node?.templateId ?? templateDetail.value?.id ?? activeTemplateItem.value?.id ?? selectedTemplateId.value,
  1131. );
  1132. const parentTemplateId = getSelectedTemplateMainId(node?.mainId);
  1133. const parentTemplate = parentTemplateId
  1134. ? resolveTemplateInfoById(parentTemplateId)
  1135. : resolveParentTemplateInfoById(templateId);
  1136. const source = resolveRecognitionBasicInfoSource(node, options);
  1137. const hasNodeFuncRegx =
  1138. hasOwnRecognitionBasicInfoConfig(node) &&
  1139. !isSameRecognitionBasicInfoConfig(
  1140. node?.funcRegx,
  1141. templateDetail.value?.funcRegx || activeTemplateItem.value?.funcRegx,
  1142. ) &&
  1143. !isSameRecognitionBasicInfoConfig(node?.funcRegx, parentTemplate?.funcRegx);
  1144. const shouldClearFilledContent = options.preferTemplate || !hasNodeFuncRegx;
  1145. recognitionBasicInfoBaseFuncRegx.value = source;
  1146. const parsedRows = parseRecognitionBasicInfoRows(source, parentTemplate?.funcRegx);
  1147. recognitionBasicInfoRows.value = shouldClearFilledContent
  1148. ? clearRecognitionBasicInfoFilledContent(parsedRows)
  1149. : parsedRows;
  1150. recognitionBasicInfoExpanded.value = !!recognitionBasicInfoRows.value.length;
  1151. };
  1152. const resolveRecognitionAmountZfEnabled = (
  1153. node?: TreeFileInfo | null,
  1154. options: { preferTemplate?: boolean } = {},
  1155. ) => {
  1156. const tableRule = findTransAmountTableRule(node?.tableRuleList);
  1157. const tableRuleZf = getRecordZfValue(tableRule);
  1158. const templateField = findTransAmountTemplateFieldMeta(templateDetail.value || activeTemplateItem.value);
  1159. const templateZf = getRecordZfValue(templateField);
  1160. if (options.preferTemplate) {
  1161. return templateZf !== undefined
  1162. ? getZfFlag(templateZf)
  1163. : tableRuleZf !== undefined
  1164. ? getZfFlag(tableRuleZf)
  1165. : false;
  1166. }
  1167. return tableRuleZf !== undefined
  1168. ? getZfFlag(tableRuleZf)
  1169. : templateZf !== undefined
  1170. ? getZfFlag(templateZf)
  1171. : false;
  1172. };
  1173. const applyRecognitionAmountZfState = (node?: TreeFileInfo | null, options: { preferTemplate?: boolean } = {}) => {
  1174. recognitionAmountZfEnabled.value = resolveRecognitionAmountZfEnabled(node, options);
  1175. };
  1176. const buildRecognitionBasicInfoFuncRegx = () => {
  1177. if (!recognitionBasicInfoRows.value.length) {
  1178. return recognitionBasicInfoBaseFuncRegx.value;
  1179. }
  1180. if (!hasRecognitionBasicInfoFilledContent(recognitionBasicInfoRows.value)) {
  1181. return '';
  1182. }
  1183. const payload = recognitionBasicInfoRows.value.reduce(
  1184. (acc, item) => {
  1185. const targetField = resolveRecognitionBasicInfoTargetField(item.targetField);
  1186. if (!targetField) return acc;
  1187. acc[targetField] = [
  1188. normalizeRecognitionBasicInfoText(item.headerMatch),
  1189. normalizeRecognitionBasicInfoText(item.endFlag),
  1190. ];
  1191. return acc;
  1192. },
  1193. {} as Record<string, string[]>,
  1194. );
  1195. return Object.keys(payload).length ? JSON.stringify(payload) : '';
  1196. };
  1197. const applyNodePreset = async (item?: TreeFileInfo | null) => {
  1198. const previewDataset = await loadPreviewDatasetFromFile(item);
  1199. const hasPresetHeaders = previewDataset ? applyPreviewDataset(previewDataset) : applyPresetHeadersFromNode(item);
  1200. restoreStoredColumnRules(item?.tableRuleList);
  1201. applyRecognitionDirectionState(item);
  1202. applyRecognitionBasicInfoState(item);
  1203. applyRecognitionAmountZfState(item);
  1204. childTemplateName.value = normalizeRuleText(item?.childTemplateName);
  1205. const headerRow = normalizeStoredLineNoToRowIndex(item?.lineNo);
  1206. if (headerRow !== null && !hasPresetHeaders) {
  1207. applyHeaderRow(headerRow, { silent: true, autoMatch: false });
  1208. } else {
  1209. headerRowIndex.value = headerRow;
  1210. selectedRowIndex.value = headerRow;
  1211. syncHeaderRowInput();
  1212. }
  1213. if (!applyTableRuleMapping(item?.tableRuleList)) {
  1214. autoMatchFields(false);
  1215. }
  1216. restoreStoredFunRules(item?.tableRuleList);
  1217. restoreFileLevelRules(item);
  1218. runRulePipeline();
  1219. };
  1220. const DEFAULT_TEMPLATE_CATEGORY = '全部分类';
  1221. const templateModalOpen = ref(false);
  1222. const activeTemplateTab = ref<TemplateTab>('template');
  1223. const activeCategory = ref(DEFAULT_TEMPLATE_CATEGORY);
  1224. const templateKeyword = ref('');
  1225. const templateSource = ref<number>(1);
  1226. const modalTemplateDetail = ref<TableInfo | null>(null);
  1227. const templatePreviewLoading = ref(false);
  1228. let templatePreviewRequestToken = 0;
  1229. const categories = computed(() => {
  1230. const set = new Set<string>(
  1231. templateCategoryOptions.value.length ? templateCategoryOptions.value : [DEFAULT_TEMPLATE_CATEGORY],
  1232. );
  1233. templateList.value.forEach((t) => set.add(t.classify || FALLBACK_TEMPLATE_CATEGORY));
  1234. return Array.from(set);
  1235. });
  1236. const showTemplateTabSwitch = computed(
  1237. () => activeCategory.value === DEFAULT_TEMPLATE_CATEGORY || pluginCategorySet.value.has(activeCategory.value),
  1238. );
  1239. const resolveNodeTargetFields = (item?: TreeFileInfo | null, preferredTemplateId?: string | number | null) => {
  1240. const tableRules = normalizeTableRuleList(item?.tableRuleList);
  1241. const templateId = normalizeTemplateId(preferredTemplateId ?? item?.templateId);
  1242. const template = (templateId && (getTemplateListItemById(templateId) || mockTemplateMap[templateId])) || null;
  1243. const baseFields = tableRules.length
  1244. ? buildTargetFieldsFromRuleList(tableRules)
  1245. : buildTargetFieldsFromTemplateInfo(template);
  1246. return applyTableRuleRequiredFlags(baseFields, tableRules);
  1247. };
  1248. const getRequiredMatchStats = (
  1249. fields: MappingTargetField[],
  1250. mapping: Record<string, string>,
  1251. recognitionRows: RecognitionBasicInfoRow[] = [],
  1252. amountZfEnabled = false,
  1253. ) => {
  1254. const matchedByRecognitionBasicInfo = buildRecognitionBasicInfoMatchedTargetKeySet(recognitionRows, fields);
  1255. const handledTargetKeySet = new Set(matchedByRecognitionBasicInfo);
  1256. buildAmountZfHandledTargetKeySet(fields, amountZfEnabled).forEach((key) => {
  1257. handledTargetKeySet.add(key);
  1258. });
  1259. const effectiveMapping = buildEffectiveFieldMapping(mapping, handledTargetKeySet);
  1260. const requiredFields = fields.filter((item) => item.required);
  1261. const requiredTotal = requiredFields.length;
  1262. const requiredMatched = requiredFields.filter(
  1263. (item) => handledTargetKeySet.has(item.key) || !!effectiveMapping[item.key],
  1264. ).length;
  1265. const matchedCount = fields.filter(
  1266. (item) => handledTargetKeySet.has(item.key) || !!effectiveMapping[item.key],
  1267. ).length;
  1268. return { requiredTotal, requiredMatched, matchedCount };
  1269. };
  1270. const getSheetNodeVisualState = (item?: TreeFileInfo | null): SheetNodeVisualState => {
  1271. if (!isTreeSheetNode(item)) return 'fail';
  1272. const nodeKey = getTreeNodeKey(item);
  1273. if (analyzingNodeKeys.value.includes(nodeKey)) {
  1274. return 'analyzing';
  1275. }
  1276. const defaultState = getFileStatusVisualState(item.fileStatus) || (isSheetMatchComplete(item) ? 'success' : 'fail');
  1277. const initialSignature = initialNodeStatusSignatureMap.get(nodeKey);
  1278. if (activeNodeKey.value === nodeKey) {
  1279. const currentState = buildNodeCacheState();
  1280. const hasEditedState = !initialSignature || buildNodeStatusSignature(currentState) !== initialSignature;
  1281. if (!hasEditedState) {
  1282. return defaultState;
  1283. }
  1284. return isMatchSuccessful(requiredTotal.value, requiredMatchedCount.value, matchedFieldCount.value)
  1285. ? 'success'
  1286. : 'fail';
  1287. }
  1288. const cached = nodeStateCache.get(nodeKey);
  1289. if (cached) {
  1290. const hasEditedState = !initialSignature || buildNodeStatusSignature(cached) !== initialSignature;
  1291. if (!hasEditedState) {
  1292. return defaultState;
  1293. }
  1294. const {
  1295. requiredTotal: cachedRequiredTotal,
  1296. requiredMatched: cachedRequiredMatched,
  1297. matchedCount: cachedMatchedCount,
  1298. } = getRequiredMatchStats(
  1299. resolveNodeTargetFields(item, cached.selectedTemplateId),
  1300. cached.fieldMapping,
  1301. cached.recognitionBasicInfoRows,
  1302. cached.recognitionAmountZfEnabled,
  1303. );
  1304. return isMatchSuccessful(cachedRequiredTotal, cachedRequiredMatched, cachedMatchedCount) ? 'success' : 'fail';
  1305. }
  1306. return defaultState;
  1307. };
  1308. const syncModalTemplateItem = (preferredId?: string | number | null) => {
  1309. const currentBaseList = filteredTemplateList.value;
  1310. if (activeTemplateTab.value === 'plugin') {
  1311. const currentPluginId = modalTemplateItem.value?.id;
  1312. const matchedPlugin = currentBaseList.find((item) => String(item.id) === String(currentPluginId)) || null;
  1313. modalTemplateItem.value = matchedPlugin;
  1314. return modalTemplateItem.value;
  1315. }
  1316. const preferredTemplateId = normalizeTemplateId(preferredId);
  1317. const currentTemplateId = normalizeTemplateId(modalTemplateItem.value?.id);
  1318. if (preferredTemplateId) {
  1319. const matched = currentBaseList.find((item) => normalizeTemplateId(item.id) === preferredTemplateId) || null;
  1320. if (matched) {
  1321. modalTemplateItem.value = matched;
  1322. return matched;
  1323. }
  1324. }
  1325. if (currentTemplateId) {
  1326. const currentMatched = currentBaseList.find((item) => normalizeTemplateId(item.id) === currentTemplateId) || null;
  1327. if (currentMatched) {
  1328. modalTemplateItem.value = currentMatched;
  1329. return currentMatched;
  1330. }
  1331. }
  1332. modalTemplateItem.value = currentBaseList[0] || null;
  1333. return modalTemplateItem.value;
  1334. };
  1335. const resolveTemplateInfoById = (templateId?: string | number | null) => {
  1336. const id = normalizeTemplateId(templateId);
  1337. if (!id) return null;
  1338. const activeDetail = normalizeTemplateId(templateDetail.value?.id) === id ? templateDetail.value : null;
  1339. const activeItem = normalizeTemplateId(activeTemplateItem.value?.id) === id ? activeTemplateItem.value : null;
  1340. const cachedDetail = templateDetailCache.get(id) || null;
  1341. const listItem = getTemplateListItemById(id) || mockTemplateMap[id] || null;
  1342. return (
  1343. mergeTemplateInfo(activeItem || listItem || activeDetail, activeDetail || cachedDetail) ||
  1344. activeDetail ||
  1345. cachedDetail ||
  1346. activeItem ||
  1347. listItem ||
  1348. null
  1349. );
  1350. };
  1351. const getSelectedTemplateMainId = (fallbackMainId?: string | number | null) => {
  1352. if (templateDetail.value && Object.prototype.hasOwnProperty.call(templateDetail.value, 'mainId')) {
  1353. return normalizeTemplateId(templateDetail.value.mainId);
  1354. }
  1355. if (activeTemplateItem.value && Object.prototype.hasOwnProperty.call(activeTemplateItem.value, 'mainId')) {
  1356. return normalizeTemplateId(activeTemplateItem.value.mainId);
  1357. }
  1358. return normalizeTemplateId(fallbackMainId);
  1359. };
  1360. const syncModalTemplatePreviewState = (
  1361. template?: TemplateListItem | null,
  1362. options: { preferAppliedDetail?: boolean } = {},
  1363. ) => {
  1364. modalTemplateItem.value = template || null;
  1365. modalTemplateDetail.value = null;
  1366. templatePreviewLoading.value = false;
  1367. templatePreviewRequestToken += 1;
  1368. const id = normalizeTemplateId(template?.id);
  1369. if (!id || Number(template?.id) < 0) return null;
  1370. if (options.preferAppliedDetail && normalizeTemplateId(templateDetail.value?.id) === id && templateDetail.value) {
  1371. modalTemplateDetail.value = templateDetail.value;
  1372. return modalTemplateDetail.value;
  1373. }
  1374. const cachedDetail = templateDetailCache.get(id);
  1375. if (cachedDetail) {
  1376. modalTemplateDetail.value = cachedDetail;
  1377. return modalTemplateDetail.value;
  1378. }
  1379. return null;
  1380. };
  1381. const loadTemplatePreviewDetail = async (
  1382. template?: TemplateListItem | null,
  1383. options: { preferAppliedDetail?: boolean } = {},
  1384. ) => {
  1385. const existingDetail = syncModalTemplatePreviewState(template, options);
  1386. if (existingDetail) {
  1387. return existingDetail;
  1388. }
  1389. const id = normalizeTemplateId(template?.id);
  1390. if (!id || Number(template?.id) < 0) return null;
  1391. const requestToken = ++templatePreviewRequestToken;
  1392. templatePreviewLoading.value = true;
  1393. try {
  1394. const detail = await tplDetail({ id });
  1395. if (requestToken !== templatePreviewRequestToken) return null;
  1396. if (detail) {
  1397. templateDetailCache.set(id, detail);
  1398. modalTemplateDetail.value = detail;
  1399. mergeTemplateListItem(mergeTemplateInfo(template, detail));
  1400. modalTemplateItem.value = getTemplateListItemById(id) || template || null;
  1401. }
  1402. return modalTemplateDetail.value;
  1403. } catch (error) {
  1404. if (requestToken === templatePreviewRequestToken) {
  1405. modalTemplateDetail.value = null;
  1406. }
  1407. return null;
  1408. } finally {
  1409. if (requestToken === templatePreviewRequestToken) {
  1410. templatePreviewLoading.value = false;
  1411. }
  1412. }
  1413. };
  1414. const closeTemplateModal = () => {
  1415. templateModalOpen.value = false;
  1416. templatePreviewRequestToken += 1;
  1417. templatePreviewLoading.value = false;
  1418. modalTemplateDetail.value = null;
  1419. modalTemplateItem.value = null;
  1420. };
  1421. const loadTemplateDetail = async (
  1422. templateId?: string | number | null,
  1423. options: { silent?: boolean; requestDetail?: boolean } = {},
  1424. ) => {
  1425. const id = normalizeTemplateId(templateId ?? selectedTemplateId.value);
  1426. if (!id) {
  1427. syncSelectedTemplateState(id);
  1428. return templateDetail.value;
  1429. }
  1430. if (!templateListLoaded.value && !templateListLoading.value) {
  1431. await loadTemplateList({ silent: true, preferredId: id });
  1432. }
  1433. const { matchedListItem, cachedDetail } = syncSelectedTemplateState(id);
  1434. if (!id || !options.requestDetail || cachedDetail) {
  1435. return templateDetail.value;
  1436. }
  1437. try {
  1438. const resp = await tplDetail({ id });
  1439. const detail = resp || null;
  1440. if (detail) {
  1441. templateDetailCache.set(id, detail);
  1442. }
  1443. templateDetail.value = detail
  1444. ? mergeTemplateInfo(matchedListItem, detail)
  1445. : matchedListItem || mockTemplateMap[id] || null;
  1446. templateDetailFromMock.value = !detail && !matchedListItem && !!mockTemplateMap[id];
  1447. if (templateDetail.value) {
  1448. mergeTemplateListItem(templateDetail.value);
  1449. activeTemplateItem.value = getTemplateListItemById(id) || (templateDetail.value as TemplateListItem);
  1450. }
  1451. } catch (error) {
  1452. templateDetail.value = matchedListItem || mockTemplateMap[id] || null;
  1453. templateDetailFromMock.value = !matchedListItem && !!mockTemplateMap[id];
  1454. activeTemplateItem.value = (matchedListItem || (templateDetail.value as TemplateListItem | null)) ?? null;
  1455. if (!options.silent) {
  1456. createMessage.warning('模板详情接口暂不可用,已使用当前列表匹配结果');
  1457. }
  1458. }
  1459. return templateDetail.value;
  1460. };
  1461. const openTemplateModal = async () => {
  1462. templateModalOpen.value = true;
  1463. activeTemplateTab.value = 'template';
  1464. activeCategory.value = DEFAULT_TEMPLATE_CATEGORY;
  1465. templateKeyword.value = '';
  1466. templateSource.value = 0;
  1467. await Promise.all([
  1468. loadTemplateCategories(),
  1469. loadTemplateList({ silent: true, preferredId: selectedTemplateId.value }),
  1470. ]);
  1471. const previewTarget = syncModalTemplateItem(selectedTemplateId.value);
  1472. await loadTemplatePreviewDetail(previewTarget, { preferAppliedDetail: true });
  1473. };
  1474. watch([activeCategory, templateSource, templateKeyword, activeTemplateTab], async () => {
  1475. if (!templateModalOpen.value) return;
  1476. if (!showTemplateTabSwitch.value) {
  1477. activeTemplateTab.value = 'template';
  1478. }
  1479. const previewTarget = syncModalTemplateItem(
  1480. activeTemplateTab.value === 'plugin' ? undefined : selectedTemplateId.value,
  1481. );
  1482. await loadTemplatePreviewDetail(previewTarget, {
  1483. preferAppliedDetail:
  1484. activeTemplateTab.value !== 'plugin' && normalizeTemplateId(previewTarget?.id) === selectedTemplateId.value,
  1485. });
  1486. });
  1487. watch(activeTemplateTab, (tab) => {
  1488. if (!templateModalOpen.value) return;
  1489. if (tab === 'plugin') {
  1490. modalTemplateDetail.value = null;
  1491. templatePreviewLoading.value = false;
  1492. templatePreviewRequestToken += 1;
  1493. }
  1494. });
  1495. watch(categories, (nextCategories) => {
  1496. if (!nextCategories.includes(activeCategory.value)) {
  1497. activeCategory.value = DEFAULT_TEMPLATE_CATEGORY;
  1498. }
  1499. });
  1500. const activeNodeInfo = computed(() => {
  1501. return activeNodeKey.value ? findTreeNodeByKey(treeData.value, activeNodeKey.value)?.raw || null : null;
  1502. });
  1503. // Phase 5:底层共享状态收进唯一 store。页面同名解构接住,原有代码与模板零改动。
  1504. const caseDataCleaningShared = useCaseDataCleaningShared({ activeNodeInfo, applyPreviewDataset, createMessage, getColumnRuleBadgeState, hasMatchedTargetField, pageQuery, requiredTotal, resolveSelectableTreeKey, resolveTemplateInfoById, switchNodeState, syncNodeStructure });
  1505. const { addColumnDialogOpen, addColumnForm, addColumnMode, addedColumns, analyzingNodeKeys, applyPresetHeadersFromNode, availableSourceHeaders, buildRecognitionDirectionConf, childTemplateName, collectChildTemplateNames, currentColumn, deleteColumnDialogOpen, dragOverContentIndex, draggingContentIndex, findDuplicateChildTemplateNames, getColumnRuleBadgeTitle, getFocusedNodeKeyFromQuery, getMatchedHeaderStatusClass, getMatchedHeaderStatusText, getMatchedHeaderStatusTitle, handleSelect, hasDuplicateHeaderName, headerRowIndex, initialNodeStatusSignatureMap, isMatchSuccessful, isSheetMatchComplete, loadPreviewDatasetFromFile, mappingPopoverOpenKey, mockGridTemplate, openAddColumn, openDeleteColumn, openSplitColumn, originFilterKeyword, recognitionBasicInfoBaseFuncRegx, recognitionBasicInfoManualVisible, remapGroupingColumnRules, remapSelectRuleHeaderRefs, resolveParentTemplateInfoById, rightTableHeaders, selectedAddedColumn, selectedContentIndex, selectedKeys, selectedRowIndex, selectedSourceField, setNodeAnalyzing, showDuplicateChildTemplateWarning, sourceTableRows, splitColumnDialogOpen, splitColumnForm, structureSyncSuspended, syncActiveNodeStructure, tablePreviewRows } = caseDataCleaningShared;
  1506. // 既有域:cleaningRules
  1507. const cleaningRules = useCleaningRules({ addedColumns, createMessage, currentColumn, headerRowIndex, rightTableHeaders, sourceTableRows, tablePreviewRows });
  1508. const { absoluteValueChecked, activeNodeKey, activeNodeTableRuleMap, activeRules, activeTemplateItem, addedColumnConfig, applyTableRuleRequiredFlags, buildAmountZfHandledTargetKeySet, buildEffectiveFieldMapping, buildGeneratedColumnHeaderRowMap, buildRecognitionBasicInfoMatchedTargetKeySet, currentRuleTitle, dateConfigType, decimalEnabled, decimalSourceBase, decimalTargetBase, effectiveFieldMapping, extractDataAfter, extractDataBefore, extractDataMode, extractSegmentRow, extractSegmentValue, fieldMapping, fileList, fixedTargetFields, generatedColumnHeaderRowMap, getEnabledGroupingColumnsForHeader, getGeneratedColumnHeaderRowIndex, groupingColumnsDraft, groupingEnabled, ignoredRowsSet, magnificationValue, matchedTargetFieldsByHeader, omitLineInput, rebuildGeneratedColumnsInSourceRows, recognitionAmountZfEnabled, recognitionBasicInfoMatchedTargetKeySet, recognitionBasicInfoRows, recognitionDirectionFields, recognitionRuleHandledTargetKeySet, removeCustomChecked, removeCustomText, removeSpaceChecked, removeTabChecked, replaceEnabled, replaceMode, replaceSource, replaceTarget, resolveTemplateFieldMeta, runRulePipeline, selectRules, selectedRules, selectedTemplateId, setGeneratedColumnCell, sortMode, splitColumnConfig, syncExtractDataMode, templateDetail, treeData } = cleaningRules;
  1509. // 既有域:recognitionApi
  1510. const recognitionApi = useRecognitionRules({ absoluteValueChecked, activeNodeKey, activeRules, addedColumnConfig, addedColumns, buildGeneratedColumnHeaderRowMap, childTemplateName, createMessage, currentColumn, currentRuleTitle, dateConfigType, decimalEnabled, decimalSourceBase, decimalTargetBase, extractDataAfter, extractDataBefore, extractSegmentRow, extractSegmentValue, fieldMapping, fixedTargetFields, generatedColumnHeaderRowMap, groupingColumnsDraft, groupingEnabled, headerRowIndex, magnificationValue, mappingPopoverOpenKey, omitLineInput, originFilterKeyword, recognitionAmountZfEnabled, recognitionBasicInfoBaseFuncRegx, recognitionBasicInfoManualVisible, recognitionBasicInfoRows, recognitionDirectionFields, removeCustomChecked, removeCustomText, removeSpaceChecked, removeTabChecked, replaceEnabled, replaceMode, replaceSource, replaceTarget, rightTableHeaders, runRulePipeline, selectRules, selectedAddedColumn, selectedRowIndex, selectedRules, selectedTemplateId, sourceTableRows, splitColumnConfig, syncActiveNodeStructure });
  1511. const { activeRecognitionRule, buildNodeCacheState, headerRowInput, nodeStateCache, recognitionBasicInfoExpanded, recognitionPanelExpanded, recognitionRuleTabs, saveCurrentNodeState, showRecognitionPanel, touchNodeCache } = recognitionApi;
  1512. // 既有域:templateApi
  1513. const templateApi = useTemplateLibrary({ DEFAULT_TEMPLATE_CATEGORY, activeCategory, activeNodeInfo, activeTemplateItem, activeTemplateTab, addedColumnConfig, addedColumns, childTemplateName, createMessage, fieldMapping, headerRowIndex, recognitionAmountZfEnabled, recognitionBasicInfoBaseFuncRegx, recognitionBasicInfoManualVisible, recognitionBasicInfoRows, recognitionDirectionFields, rightTableHeaders, selectRules, selectedRules, selectedTemplateId, splitColumnConfig, syncActiveNodeStructure, templateDetail, templateKeyword, templateSource });
  1514. const { FALLBACK_TEMPLATE_CATEGORY, checkTemplateNameAvailable, currentTemplateDisplayName, currentTemplateName, filteredTemplateList, getTemplateListItemById, hasTemplateSelected, isSystemTemplateRecord, loadTemplateCategories, loadTemplateList, mergeTemplateInfo, mergeTemplateListItem, modalTemplateItem, pluginCategorySet, showChildTemplateExistsWarning, syncSelectedTemplateState, templateCategoryOptions, templateDetailCache, templateDetailFromMock, templateList, templateListLoaded, templateListLoading, templateSearchBaseList } = templateApi;
  1515. // 既有域:mappingApi
  1516. const mappingApi = useFieldMapping({ activeNodeInfo, activeNodeKey, activeNodeTableRuleMap, activeTemplateItem, addedColumns, buildRecognitionDirectionConf, createMessage, currentColumn, effectiveFieldMapping, fieldMapping, fixedTargetFields, headerRowIndex, ignoredRowsSet, isTargetHandledByRecognitionRule, mappingPopoverOpenKey, matchedTargetFieldsByHeader, originFilterKeyword, recognitionDirectionFields, resolveTemplateFieldMeta, rightTableHeaders, selectedAddedColumn, selectedRowIndex, sourceTableRows, tablePreviewRows, templateDetail, treeData });
  1517. const { activeMappingTargetKey, autoMatchFields, clearOriginHoverPreviewState, focusPreviewColumn, headerFormatValidationMap, isMappingTargetRequired, isTargetMatched, resolveRecognitionDirectionFields, tableMockRef } = mappingApi;
  1518. const activeRecognitionRuleDesc = computed(() => {
  1519. const templateName = currentTemplateName.value === '-' ? '当前模板' : currentTemplateName.value;
  1520. const ruleLabel = activeRecognitionRule.value || recognitionRuleTabs.value[0] || '识别规则';
  1521. return `围绕「${templateName}」预置 ${ruleLabel} 的识别配置,后续可结合导入样本继续沉淀成子模板。`;
  1522. });
  1523. watch(
  1524. recognitionRuleTabs,
  1525. (tabs) => {
  1526. if (!tabs.length) {
  1527. activeRecognitionRule.value = '';
  1528. return;
  1529. }
  1530. if (!tabs.includes(activeRecognitionRule.value)) {
  1531. activeRecognitionRule.value = tabs[0];
  1532. }
  1533. },
  1534. { immediate: true },
  1535. );
  1536. watch(
  1537. fixedTargetFields,
  1538. (fields) => {
  1539. if (activeMappingTargetKey.value && !fields.some((item) => item.key === activeMappingTargetKey.value)) {
  1540. activeMappingTargetKey.value = '';
  1541. }
  1542. },
  1543. { deep: true },
  1544. );
  1545. const startResize = (type: 'left' | 'middle', event: MouseEvent) => {
  1546. event.preventDefault();
  1547. resizeState.value = {
  1548. type,
  1549. startX: event.clientX,
  1550. startLeft: leftPanelWidth.value,
  1551. startMiddle: middlePanelWidth.value,
  1552. };
  1553. document.body.classList.add('is-resizing-layout');
  1554. document.body.style.cursor = 'col-resize';
  1555. };
  1556. const applyResize = () => {
  1557. if (!resizeState.value.type || pendingClientX.value === null) return;
  1558. const deltaX = pendingClientX.value - resizeState.value.startX;
  1559. if (resizeState.value.type === 'left' && !leftCollapsed.value) {
  1560. const next = resizeState.value.startLeft + deltaX;
  1561. leftPanelWidth.value = Math.min(maxLeftWidth, Math.max(minLeftWidth, next));
  1562. }
  1563. if (resizeState.value.type === 'middle') {
  1564. const next = resizeState.value.startMiddle + deltaX;
  1565. middlePanelWidth.value = Math.min(maxMiddleWidth, Math.max(minMiddleWidth, next));
  1566. }
  1567. rafId.value = null;
  1568. };
  1569. const onResizeMouseMove = (event: MouseEvent) => {
  1570. if (!resizeState.value.type) return;
  1571. event.preventDefault();
  1572. pendingClientX.value = event.clientX;
  1573. if (rafId.value === null) {
  1574. rafId.value = window.requestAnimationFrame(applyResize);
  1575. }
  1576. };
  1577. const stopResize = () => {
  1578. resizeState.value.type = '';
  1579. resizeState.value.startX = 0;
  1580. pendingClientX.value = null;
  1581. if (rafId.value !== null) {
  1582. window.cancelAnimationFrame(rafId.value);
  1583. rafId.value = null;
  1584. }
  1585. document.body.classList.remove('is-resizing-layout');
  1586. document.body.style.cursor = '';
  1587. };
  1588. const openEditColumn = () => {
  1589. if (!selectedAddedColumn.value) {
  1590. createMessage.warning('请先选中一个新增列');
  1591. return;
  1592. }
  1593. addColumnMode.value = 'edit';
  1594. addColumnDialogOpen.value = true;
  1595. const config = addedColumnConfig.value[selectedAddedColumn.value];
  1596. addColumnForm.value = {
  1597. name: selectedAddedColumn.value,
  1598. mode: config?.mode || 'ref',
  1599. contentFields: (config?.contentFields || []).filter((field) => availableSourceHeaders.value.includes(field)),
  1600. customContent: config?.customContent || '',
  1601. };
  1602. selectedSourceField.value = availableSourceHeaders.value[0] || '';
  1603. selectedContentIndex.value = -1;
  1604. draggingContentIndex.value = null;
  1605. dragOverContentIndex.value = null;
  1606. };
  1607. const buildEnabledGroupingColumnsPayload = () =>
  1608. rightTableHeaders.value.reduce(
  1609. (acc, header) => {
  1610. if (!addedColumns.value.includes(header)) {
  1611. return acc;
  1612. }
  1613. const groupColumns = getEnabledGroupingColumnsForHeader(header);
  1614. if (!groupColumns.length) {
  1615. return acc;
  1616. }
  1617. acc.push({
  1618. targetHeader: header,
  1619. groupFlag: true,
  1620. groupColumns: cloneDeepSafe(groupColumns),
  1621. });
  1622. return acc;
  1623. },
  1624. [] as Array<{
  1625. targetHeader: string;
  1626. groupFlag: boolean;
  1627. groupColumns: Array<Record<string, any>>;
  1628. }>,
  1629. );
  1630. const confirmDeleteColumn = () => {
  1631. const name = selectedAddedColumn.value;
  1632. if (!name) return;
  1633. rightTableHeaders.value = rightTableHeaders.value.filter((h) => h !== name);
  1634. addedColumns.value = addedColumns.value.filter((h) => h !== name);
  1635. delete addedColumnConfig.value[name];
  1636. const deletedSplitConfig = splitColumnConfig.value[name];
  1637. delete splitColumnConfig.value[name];
  1638. if (deletedSplitConfig) {
  1639. [deletedSplitConfig.targetName1, deletedSplitConfig.targetName2].filter(Boolean).forEach((targetName) => {
  1640. const targetConfig = splitColumnConfig.value[targetName];
  1641. if (targetName === name || !targetConfig) return;
  1642. splitColumnConfig.value[targetName] = {
  1643. ...targetConfig,
  1644. targetName1: targetConfig.targetName1 === name ? '' : targetConfig.targetName1,
  1645. targetName2: targetConfig.targetName2 === name ? '' : targetConfig.targetName2,
  1646. };
  1647. });
  1648. }
  1649. delete generatedColumnHeaderRowMap.value[name];
  1650. sourceTableRows.value.forEach((row) => {
  1651. delete row[name];
  1652. });
  1653. Object.keys(fieldMapping.value).forEach((key) => {
  1654. if (fieldMapping.value[key] === name) {
  1655. fieldMapping.value[key] = '';
  1656. }
  1657. });
  1658. selectRules.value = remapSelectRuleHeaderRefs(selectRules.value, {}, new Set([name]));
  1659. groupingColumnsDraft.value = remapGroupingColumnRules(groupingColumnsDraft.value, {}, new Set([name]));
  1660. selectedAddedColumn.value = addedColumns.value[0] || '';
  1661. currentColumn.value = selectedAddedColumn.value
  1662. ? rightTableHeaders.value.findIndex((h) => h === selectedAddedColumn.value)
  1663. : null;
  1664. selectedRowIndex.value = null;
  1665. runRulePipeline();
  1666. deleteColumnDialogOpen.value = false;
  1667. createMessage.success('删除列成功');
  1668. };
  1669. const onClickHeader = (header: string) => {
  1670. const index = rightTableHeaders.value.findIndex((h) => h === header);
  1671. if (index < 0) return;
  1672. closeMappingPopoverPreview();
  1673. focusPreviewColumn(header, index, { resetSelectedRow: true });
  1674. };
  1675. const onClickCell = (rowIndex: number, header: string, colIndex: number) => {
  1676. closeMappingPopoverPreview();
  1677. selectedRowIndex.value = rowIndex;
  1678. focusPreviewColumn(header, colIndex, { resetSelectedRow: false });
  1679. };
  1680. const toggleLeft = () => {
  1681. leftCollapsed.value = !leftCollapsed.value;
  1682. };
  1683. const handleImportData = async () => {
  1684. saveCurrentNodeState();
  1685. syncActiveNodeStructure();
  1686. const sanitizedFileInfos = sanitizeFileInfoTree(fileList.value, { keepRuleKey: false });
  1687. if (!sanitizedFileInfos.length) {
  1688. createMessage.warning('未找到可导入的数据,请先上传并完成模板匹配');
  1689. return;
  1690. }
  1691. const matchedSheetsWithoutHeaderRow = collectMatchedSheetsWithoutHeaderRow(sanitizedFileInfos);
  1692. if (matchedSheetsWithoutHeaderRow.length) {
  1693. const previewNames = matchedSheetsWithoutHeaderRow.slice(0, 3).join('、');
  1694. const suffix =
  1695. matchedSheetsWithoutHeaderRow.length > 3 ? ` 等 ${matchedSheetsWithoutHeaderRow.length} 个工作表` : '';
  1696. createMessage.warning(`已匹配模板的工作表必须先填写表头所在行:${previewNames}${suffix}`);
  1697. return;
  1698. }
  1699. try {
  1700. const childTemplateNameValid = await ensureChildTemplateNamesAvailable(sanitizedFileInfos);
  1701. if (!childTemplateNameValid) {
  1702. return;
  1703. }
  1704. } catch (error) {
  1705. createMessage.error(getRequestErrorMessage(error, '模板名称校验失败'));
  1706. return;
  1707. }
  1708. sessionStorage.setItem(FILE_INFO_STORAGE_KEY, JSON.stringify(sanitizedFileInfos));
  1709. importingData.value = true;
  1710. try {
  1711. const resolveMappedHeaderByTargetField = (item?: TreeFileInfo | null, targetField?: string) => {
  1712. const normalizedTarget = normalizeFieldMatchText(targetField);
  1713. if (!normalizedTarget) return '';
  1714. const matchedRule = normalizeTableRuleList(item?.tableRuleList).find((rule, index) => {
  1715. const targetKey = normalizeFieldMatchText(getTableRuleTargetKey(rule, index));
  1716. const targetLabel = normalizeFieldMatchText(getTableRuleTargetLabel(rule, index));
  1717. return normalizedTarget === targetKey || normalizedTarget === targetLabel;
  1718. });
  1719. return normalizeRecognitionConfigText(matchedRule?.fileColName);
  1720. };
  1721. const buildImportRecognitionBasicInfoFuncRegx = (item?: TreeFileInfo | null) => {
  1722. const source = normalizeRecognitionConfigText(item?.funcRegx);
  1723. if (!source) return '';
  1724. const rows = parseRecognitionBasicInfoRows(source);
  1725. if (!rows.length) return source;
  1726. if (!hasRecognitionBasicInfoFilledContent(rows)) return '';
  1727. const payload = rows.reduce(
  1728. (acc, row) => {
  1729. const targetField = resolveRecognitionBasicInfoTargetField(row.targetField);
  1730. const headerMatch = normalizeRecognitionBasicInfoText(row.headerMatch);
  1731. const endFlag = normalizeRecognitionBasicInfoText(row.endFlag);
  1732. const mappedHeader = resolveMappedHeaderByTargetField(item, targetField);
  1733. const nextKey = headerMatch || mappedHeader || targetField;
  1734. if (!nextKey) return acc;
  1735. acc[nextKey] = [headerMatch, endFlag];
  1736. return acc;
  1737. },
  1738. {} as Record<string, string[]>,
  1739. );
  1740. return Object.keys(payload).length ? JSON.stringify(payload) : '';
  1741. };
  1742. const buildImportFileInfoPayload = (item?: TreeFileInfo | null): TreeFileInfo | null => {
  1743. if (!item) return null;
  1744. const nextItem = cloneDeepSafe(item) as TreeFileInfo;
  1745. const children = Array.isArray(nextItem.children)
  1746. ? nextItem.children
  1747. .map((child) => buildImportFileInfoPayload(child))
  1748. .filter((child): child is TreeFileInfo => Boolean(child))
  1749. : nextItem.children;
  1750. if (isFileRootItem(nextItem)) {
  1751. const rootPayload = {
  1752. ...nextItem,
  1753. children,
  1754. } as TreeFileInfo & { childTemplateName?: string | null };
  1755. delete rootPayload.childTemplateName;
  1756. return rootPayload;
  1757. }
  1758. const childName = normalizeRuleText(nextItem.childTemplateName);
  1759. const hasNew = !!childName || !!nextItem.hasNew;
  1760. const importTemplateId = normalizeTemplateId(
  1761. nextItem.templateId ?? templateDetail.value?.id ?? activeTemplateItem.value?.id ?? selectedTemplateId.value,
  1762. );
  1763. const currentTemplate = resolveTemplateInfoById(importTemplateId);
  1764. const parentTemplateId = normalizeTemplateId(nextItem.mainId) || normalizeTemplateId(currentTemplate?.mainId);
  1765. const parentTemplate = (parentTemplateId && resolveTemplateInfoById(parentTemplateId)) || currentTemplate;
  1766. const currentFuncRegx = normalizeRecognitionConfigText(nextItem.funcRegx);
  1767. const importFuncRegx = currentFuncRegx ? buildImportRecognitionBasicInfoFuncRegx(nextItem) : '';
  1768. const sheetPayload = {
  1769. ...nextItem,
  1770. tableName: hasNew ? childName : nextItem.tableName,
  1771. tableNameEn: hasNew
  1772. ? normalizeRuleText(parentTemplate?.tableNameEn) ||
  1773. normalizeRuleText(currentTemplate?.tableNameEn) ||
  1774. normalizeRuleText(nextItem.tableNameEn) ||
  1775. null
  1776. : nextItem.tableNameEn,
  1777. hasNew,
  1778. classify: hasNew
  1779. ? normalizeRuleText(parentTemplate?.classify) ||
  1780. normalizeRuleText(currentTemplate?.classify) ||
  1781. normalizeRuleText(nextItem.classify) ||
  1782. null
  1783. : nextItem.classify,
  1784. funcRegx: importFuncRegx || null,
  1785. children,
  1786. } as TreeFileInfo & { childTemplateName?: string | null };
  1787. return sheetPayload;
  1788. };
  1789. const importFileInfos = sanitizedFileInfos
  1790. .map((item) => buildImportFileInfoPayload(item))
  1791. .filter((item): item is TreeFileInfo => Boolean(item));
  1792. sessionStorage.setItem(IMPORT_FILE_INFO_STORAGE_KEY, JSON.stringify(importFileInfos));
  1793. resetImportProgressSession();
  1794. await go(PageEnum.BASE_CLEAN_PROGRESS);
  1795. } catch {
  1796. createMessage.error('导入数据失败');
  1797. } finally {
  1798. importingData.value = false;
  1799. }
  1800. };
  1801. const handleBack = () => {
  1802. // 返回上一级:文件上传页
  1803. go(PageEnum.BASE_UPLOAD);
  1804. };
  1805. const loadFileTree = async () => {
  1806. const storedList = loadStoredFileList();
  1807. fileList.value = storedList.length ? storedList : sanitizeFileInfoTree(cloneDeepSafe(mockFileList));
  1808. if (!treeData.value.length) return;
  1809. const focusedKey = resolveSelectableTreeKey(getFocusedNodeKeyFromQuery());
  1810. const initialKey =
  1811. focusedKey ||
  1812. (selectedKeys.value.length
  1813. ? resolveSelectableTreeKey(String(selectedKeys.value[0]))
  1814. : findFirstSelectableKey(treeData.value));
  1815. if (!initialKey) return;
  1816. selectedKeys.value = [initialKey];
  1817. await switchNodeState(initialKey);
  1818. if (focusedKey) {
  1819. scrollSelectedTreeNodeIntoView();
  1820. }
  1821. };
  1822. const initializePage = async () => {
  1823. void Promise.all([loadTemplateCategories(), loadTemplateList({ silent: true })]);
  1824. runRulePipeline();
  1825. await loadFileTree();
  1826. };
  1827. onMounted(() => {
  1828. initializePage();
  1829. window.addEventListener('mousemove', onResizeMouseMove);
  1830. window.addEventListener('mouseup', stopResize);
  1831. });
  1832. onBeforeUnmount(() => {
  1833. saveCurrentNodeState();
  1834. window.removeEventListener('mousemove', onResizeMouseMove);
  1835. window.removeEventListener('mouseup', stopResize);
  1836. document.body.classList.remove('is-resizing-layout');
  1837. document.body.style.cursor = '';
  1838. });
  1839. // Phase 2:把 Modal 子组件需要的页面级共享状态/方法 provide 下去(子组件内部按名取用)
  1840. provide(CleaningContextKey, { DEFAULT_TEMPLATE_CATEGORY, FALLBACK_TEMPLATE_CATEGORY, activeCategory, activeNodeInfo, activeNodeKey, activeTemplateItem, activeTemplateTab, addColumnDialogOpen, addColumnForm, addColumnMode, addedColumnConfig, addedColumns, applyHeaderRow, applyPresetHeadersFromNode, applyPreviewDataset, applyRecognitionAmountZfState, applyRecognitionBasicInfoState, applyRecognitionDirectionState, autoMatchFields, availableSourceHeaders, categories, childTemplateName, cleaningRules, closeTemplateModal, createMessage, currentColumn, dragOverContentIndex, draggingContentIndex, fieldMapping, filteredTemplateList, fixedTargetFields, generatedColumnHeaderRowMap, getGeneratedColumnHeaderRowIndex, groupingColumnsDraft, hasDuplicateHeaderName, headerRowIndex, headerRowInput, isSystemTemplateRecord, isTargetHandledByRecognitionRule, loadPreviewDatasetFromFile, loadTemplateDetail, loadTemplatePreviewDetail, mappingApi, mappingPopoverOpenKey, mergeTemplateInfo, modalTemplateDetail, modalTemplateItem, nodeStateCache, openTemplateModal, originFilterKeyword, recognitionAmountZfEnabled, recognitionApi, recognitionBasicInfoRows, remapGroupingColumnRules, remapSelectRuleHeaderRefs, resetNodeWorkState, rightTableHeaders, runRulePipeline, saveCurrentNodeState, selectRules, selectedAddedColumn, selectedContentIndex, selectedRowIndex, selectedSourceField, selectedTemplateId, setGeneratedColumnCell, showTemplateTabSwitch, sourceTableRows, splitColumnConfig, splitColumnDialogOpen, splitColumnForm, structureSyncSuspended, syncHeaderRowInput, syncModalTemplatePreviewState, templateApi, templateDetailCache, templateKeyword, templateModalOpen, templatePreviewLoading, templateSearchBaseList, templateSource , caseDataCleaningShared });
  1841. </script>
  1842. <style lang="less">
  1843. // 样式已按域拆分到 ./cleaning/styles/ 下,由此处引入(保持全局,非 scoped)。
  1844. @import './cleaning/styles/index.less';
  1845. </style>