upload.vue 49 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578
  1. <template>
  2. <div :class="prefixCls" class="case-upload-page">
  3. <!-- 清洗流程公共顶栏:品牌 / 案件 / 模板库 / 下载管理 / 窗口控制 -->
  4. <CaseFlowTopBar />
  5. <div class="page-body">
  6. <div class="page-header">
  7. <div class="page-header__left">
  8. <Tooltip title="返回" placement="bottom" :mouseEnterDelay="0.5">
  9. <button type="button" class="back-btn" aria-label="返回" @click="handleBack">
  10. <LeftOutlined />
  11. </button>
  12. </Tooltip>
  13. <span class="page-header__divider" aria-hidden="true"></span>
  14. <div class="page-title-block">
  15. <div class="page-title">文件上传</div>
  16. <div class="page-subtitle">
  17. 支持 xls / xlsx / csv 自动解析;智能清洗结合模板与 AI 识别,未能匹配的文件待人工确认。
  18. </div>
  19. </div>
  20. </div>
  21. <div class="page-header__actions">
  22. <Button
  23. type="primary"
  24. class="next-btn"
  25. :loading="smartCleaning"
  26. :disabled="!hasCleanableSheet"
  27. @click="handleSmartClean"
  28. >
  29. 智能清洗
  30. </Button>
  31. <Button class="next-btn" :disabled="!hasCleanableSheet" @click="handleManualClean">手动清洗</Button>
  32. </div>
  33. </div>
  34. <div class="upload-layout">
  35. <aside class="left-panel">
  36. <div class="upload-picker">
  37. <div class="upload-mode-switch" role="tablist" aria-label="上传类型切换">
  38. <button
  39. type="button"
  40. :class="['upload-mode-switch__item', { 'is-active': uploadMode === 'single' }]"
  41. :disabled="isUploading"
  42. @click="handleUploadModeChange('single')"
  43. >
  44. 选择文件
  45. </button>
  46. <button
  47. type="button"
  48. :class="['upload-mode-switch__item', { 'is-active': uploadMode === 'folder' }]"
  49. :disabled="isUploading"
  50. @click="handleUploadModeChange('folder')"
  51. >
  52. 选择文件夹
  53. </button>
  54. </div>
  55. </div>
  56. <!-- 隐藏的文件输入 -->
  57. <input ref="fileInputRef" type="file" multiple style="display: none" @change="handleFileInputChange" />
  58. <input
  59. ref="folderInputRef"
  60. type="file"
  61. webkitdirectory
  62. directory
  63. style="display: none"
  64. @change="handleFolderInputChange"
  65. />
  66. <div
  67. class="upload-dropzone"
  68. :class="{ 'is-dragover': dragOver }"
  69. @dragover.prevent="handleDragOver"
  70. @dragleave="handleDragLeave"
  71. @drop.prevent="handleDrop"
  72. >
  73. <div class="upload-dragger-content">
  74. <div class="upload-dragger-icon">⬆</div>
  75. <div class="upload-dragger-title">
  76. {{ uploadMode === 'single' ? '拖拽文件到这里,或点击选择文件' : '拖拽文件夹到这里,或点击选择文件夹' }}
  77. </div>
  78. <div class="upload-dragger-desc">
  79. {{
  80. uploadMode === 'single'
  81. ? '支持批量选择任意文件;xls / xlsx / csv 会自动解析,其余格式仅登记为「不支持解析」'
  82. : '支持选择整个文件夹,系统会自动读取符合条件的文件'
  83. }}
  84. </div>
  85. <Button type="primary" class="upload-action-btn" :disabled="isUploading" @click="handleTriggerFileSelect">
  86. {{ uploadMode === 'single' ? '选择文件' : '选择文件夹' }}
  87. </Button>
  88. </div>
  89. </div>
  90. <!-- 已选文件列表(加入后自动上传,成功即移除) -->
  91. <div v-if="selectedFiles.length" class="upload-file-list">
  92. <div class="upload-file-list__header">
  93. <span>待上传 {{ selectedFiles.length }} 个文件</span>
  94. <Button type="link" size="small" :disabled="isUploading" @click="clearSelectedFiles"> 清空 </Button>
  95. </div>
  96. <div class="upload-file-list__body">
  97. <div
  98. v-for="file in selectedFiles"
  99. :key="file.name + file.size + file.lastModified"
  100. class="upload-file-item"
  101. >
  102. <div class="upload-file-item__info">
  103. <span class="upload-file-item__name">{{ file.name }}</span>
  104. <span class="upload-file-item__size">{{ formatFileSize(file.size) }}</span>
  105. </div>
  106. <div class="upload-file-item__status">
  107. <template v-if="uploadStatuses.get(file) === 'error'">
  108. <span class="upload-file-item__error">✗</span>
  109. </template>
  110. <template v-else-if="uploadStatuses.get(file) === 'uploading'">
  111. <span class="upload-file-item__progress-text">{{ uploadProgressMap.get(file) || 0 }}%</span>
  112. </template>
  113. <template v-else>
  114. <span class="upload-file-item__pending">待上传</span>
  115. </template>
  116. </div>
  117. <button
  118. v-if="!isUploading && (uploadStatuses.get(file) || 'pending') === 'pending'"
  119. type="button"
  120. class="upload-file-item__remove"
  121. aria-label="移除文件"
  122. @click="removeSelectedFile(file)"
  123. >
  124. <CloseOutlined />
  125. </button>
  126. </div>
  127. </div>
  128. <!-- 整体进度 -->
  129. <div v-if="isUploading" class="upload-overall-progress">
  130. <div class="upload-overall-progress__bar">
  131. <div class="upload-overall-progress__fill" :style="{ width: overallProgress + '%' }"></div>
  132. </div>
  133. <span class="upload-overall-progress__text">总进度 {{ overallProgress }}%</span>
  134. </div>
  135. </div>
  136. <div v-else class="upload-file-list upload-file-list--empty">
  137. <div class="upload-empty">暂无待上传文件,选择文件后将自动上传解析</div>
  138. </div>
  139. </aside>
  140. <section class="right-panel">
  141. <div class="panel-header">
  142. <div>
  143. <div class="panel-title">解析结果(实时)</div>
  144. <div class="panel-desc">每完成一份文件解析,结果树会实时追加到下方</div>
  145. </div>
  146. <div class="panel-header__actions">
  147. <Button size="small" :disabled="!expandableCount" @click="toggleExpandAllRows">
  148. {{ allExpanded ? '全部收起' : '全部展开' }}
  149. </Button>
  150. </div>
  151. <div class="panel-stats">已解析 {{ resultTrees.length }} 个文件</div>
  152. </div>
  153. <div class="table-wrapper">
  154. <Table
  155. class="result-tree-table"
  156. :columns="resultTableColumns"
  157. :data-source="tableDataSource"
  158. :loading="tableLoading"
  159. :pagination="false"
  160. row-key="id"
  161. :children-column-name="'children'"
  162. :expanded-row-keys="expandedRowKeys"
  163. size="small"
  164. :scroll="{ x: 840 }"
  165. @expandedRowsChange="onExpandedRowsChange"
  166. />
  167. </div>
  168. </section>
  169. </div>
  170. </div>
  171. </div>
  172. </template>
  173. <script lang="ts" setup name="ViewsDataUpload">
  174. import { computed, h, onMounted, ref } from 'vue';
  175. import { onBeforeRouteLeave } from 'vue-router';
  176. import { CloseOutlined, LeftOutlined } from '@ant-design/icons-vue';
  177. import { Button, Modal, Table, Tooltip, type TableColumnsType } from 'ant-design-vue';
  178. import { useDesign } from '@/core/hooks/web/useDesign';
  179. import { useGo, useQuery } from '@/core/hooks/web/usePage';
  180. import { PageEnum } from '@/core/enums/pageEnum';
  181. import { useMessage } from '@/core/hooks/web/useMessage';
  182. import { useEmitter } from '@/core/store/modules/case';
  183. import { clearUploadBatch, deletedFileList, preFileUpload, smartClean } from '@/case/api/govern/governApi';
  184. import { FileStatusEnum } from '@/case/types/enum';
  185. import CaseFlowTopBar from './components/CaseFlowTopBar.vue';
  186. import { resetImportProgressSession, setCleanFlowSource } from './importProgressSession';
  187. import type { CleanFlowSource } from './importProgressSession';
  188. import type { FileInfo } from '@/types/api';
  189. const { prefixCls } = useDesign('case-upload');
  190. const go = useGo();
  191. const pageQuery = useQuery();
  192. const emitter = useEmitter();
  193. const { createMessage } = useMessage();
  194. const FILE_INFO_STORAGE_KEY = 'case_clean_file_infos';
  195. const IMPORT_FILE_INFO_STORAGE_KEY = 'case_clean_import_file_infos';
  196. const RECLEAN_CONTEXT_STORAGE_KEY = 'case_clean_reclean_context';
  197. const CASE_DATA_REFRESH_EVENT = 'case-data:refresh';
  198. type UploadMode = 'single' | 'folder';
  199. /** 进入上传页即生成批次 id,本页所有文件统一使用该批次(Long 型,上限约 1.7e18,安全) */
  200. const createUploadBatchId = () => Date.now() * 1000000 + Math.floor(Math.random() * 1000000);
  201. const uploadBatchId = ref(createUploadBatchId());
  202. const modeFromQuery = computed<UploadMode>(() => (pageQuery.value?.mode === 'folder' ? 'folder' : 'single'));
  203. const uploadMode = ref<UploadMode>(modeFromQuery.value);
  204. // 文件选择与上传状态(以 File 对象为键,上传成功移除文件时状态映射不会错乱)
  205. const fileInputRef = ref<HTMLInputElement | null>(null);
  206. const folderInputRef = ref<HTMLInputElement | null>(null);
  207. const selectedFiles = ref<File[]>([]);
  208. const uploadProgressMap = ref<Map<File, number>>(new Map());
  209. const uploadStatuses = ref<Map<File, 'pending' | 'uploading' | 'done' | 'error'>>(new Map());
  210. const isUploading = ref(false);
  211. const overallProgress = ref(0);
  212. const dragOver = ref(false);
  213. // 解析结果(右侧 treetable 数据源,实时追加)
  214. const resultTrees = ref<FileInfo[]>([]);
  215. const allFileInfos = ref<Recordable[]>([]);
  216. const tableLoading = ref(false);
  217. // 受控展开:默认全部展开,新解析的文件根节点自动展开
  218. // (键类型与 ant-design-vue Table 的 expandedRowKeys 保持一致:string | number)
  219. const expandedRowKeys = ref<(string | number)[]>([]);
  220. const expandableCount = computed(() => resultTrees.value.filter((root) => (root.children || []).length > 0).length);
  221. const allExpanded = computed(
  222. () => expandableCount.value > 0 && expandedRowKeys.value.length >= expandableCount.value,
  223. );
  224. const collectExpandableKeys = () =>
  225. resultTrees.value.filter((root) => (root.children || []).length > 0).map((root) => String(root.id ?? ''));
  226. const expandAllRows = () => {
  227. expandedRowKeys.value = collectExpandableKeys();
  228. };
  229. const toggleExpandAllRows = () => {
  230. expandedRowKeys.value = allExpanded.value ? [] : collectExpandableKeys();
  231. };
  232. // 用户手动展开/收起时同步受控 keys
  233. const onExpandedRowsChange = (keys: (string | number)[]) => {
  234. expandedRowKeys.value = keys;
  235. };
  236. // 表格数据源:递归剥离空 children。
  237. // 后端 FileInfo.children 恒为空数组(非 undefined),ant-design-vue 树形模式下
  238. // 只要 record.children 为真值就渲染展开图标,空数组也算,必须逐层剥掉
  239. const stripEmptyChildren = (node: Recordable): Recordable => {
  240. const children = node.children || [];
  241. if (!children.length) {
  242. return { ...node, children: undefined };
  243. }
  244. return { ...node, children: children.map(stripEmptyChildren) };
  245. };
  246. const tableDataSource = computed(() => resultTrees.value.map(stripEmptyChildren));
  247. // 离开放行标记:点「下一步」或已确认清理后的程序化跳转置为 true
  248. const allowedToLeave = ref(false);
  249. // 放弃标记:确认离开后中断上传循环,避免组件卸载后继续写结果
  250. const isAbandoned = ref(false);
  251. const batchIdReady = computed(() => !!uploadBatchId.value);
  252. const hasActivity = computed(
  253. () => isUploading.value || selectedFiles.value.length > 0 || resultTrees.value.length > 0,
  254. );
  255. /**
  256. * 是否存在可清洗的工作表(「智能」与「手动清洗」的可用条件)。
  257. *
  258. * 不能沿用 `resultTrees.length` —— 「无法读取」的失败节点(txt / md / doc / docx / pdf)
  259. * 也会进 resultTrees,但它没有 children(没有解析出任何 sheet)。
  260. * 若只按长度判断,用户传一个 docx 后「手动清洗」按钮会亮起,点进去却是一个空的手动清洗页。
  261. */
  262. const hasCleanableSheet = computed(() => resultTrees.value.some((root) => (root.children || []).length > 0));
  263. /** 「智能」是异步发起:请求本身要等后端建批次,按钮要给出进行中反馈 */
  264. const smartCleaning = ref(false);
  265. const formatFileSize = (bytes?: number | null) => {
  266. if (bytes == null || bytes === 0) return '0 B';
  267. const k = 1024;
  268. const sizes = ['B', 'KB', 'MB', 'GB'];
  269. const i = Math.floor(Math.log(bytes) / Math.log(k));
  270. return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
  271. };
  272. const handleDragOver = () => {
  273. dragOver.value = true;
  274. };
  275. const handleDragLeave = () => {
  276. dragOver.value = false;
  277. };
  278. const traverseDirectory = async (entry: FileSystemDirectoryEntry): Promise<File[]> => {
  279. const files: File[] = [];
  280. const reader = entry.createReader();
  281. const getEntries = (): Promise<FileSystemEntry[]> => new Promise((resolve) => reader.readEntries(resolve));
  282. let entries = await getEntries();
  283. while (entries.length) {
  284. for (const e of entries) {
  285. if (e.isFile) {
  286. const file = await new Promise<File>((resolve) => (e as FileSystemFileEntry).file(resolve));
  287. files.push(file);
  288. } else if (e.isDirectory) {
  289. files.push(...(await traverseDirectory(e as FileSystemDirectoryEntry)));
  290. }
  291. }
  292. entries = await getEntries();
  293. }
  294. return files;
  295. };
  296. const collectFilesFromDataTransfer = async (items: DataTransferItemList): Promise<File[]> => {
  297. const files: File[] = [];
  298. for (let i = 0; i < items.length; i++) {
  299. const item = items[i];
  300. const entry = item.webkitGetAsEntry?.();
  301. if (!entry) {
  302. const file = item.getAsFile();
  303. if (file) files.push(file);
  304. continue;
  305. }
  306. if (entry.isFile) {
  307. const file = await new Promise<File>((resolve) => (entry as FileSystemFileEntry).file(resolve));
  308. files.push(file);
  309. } else if (entry.isDirectory) {
  310. files.push(...(await traverseDirectory(entry as FileSystemDirectoryEntry)));
  311. }
  312. }
  313. return files;
  314. };
  315. /** 文件加入上传列表并自动开始上传(不按后缀初筛:能不能解析由后端判定并回写状态) */
  316. const addFiles = (files: File[]) => {
  317. if (!files.length || isAbandoned.value) return;
  318. selectedFiles.value = [...selectedFiles.value, ...files];
  319. void startUpload();
  320. };
  321. const handleDrop = async (event: DragEvent) => {
  322. dragOver.value = false;
  323. if (!event.dataTransfer) return;
  324. const files = await collectFilesFromDataTransfer(event.dataTransfer.items);
  325. addFiles(files);
  326. };
  327. const handleFileInputChange = (event: Event) => {
  328. const target = event.target as HTMLInputElement;
  329. if (!target.files?.length) return;
  330. addFiles(Array.from(target.files));
  331. target.value = '';
  332. };
  333. const handleFolderInputChange = (event: Event) => {
  334. const target = event.target as HTMLInputElement;
  335. if (!target.files?.length) return;
  336. const files = Array.from(target.files).filter((f) => f.webkitRelativePath);
  337. addFiles(files);
  338. target.value = '';
  339. };
  340. const handleTriggerFileSelect = () => {
  341. if (uploadMode.value === 'folder') {
  342. folderInputRef.value?.click();
  343. } else {
  344. fileInputRef.value?.click();
  345. }
  346. };
  347. const handleUploadModeChange = (mode: UploadMode) => {
  348. if (uploadMode.value === mode || isUploading.value) return;
  349. uploadMode.value = mode;
  350. clearSelectedFiles();
  351. };
  352. const clearSelectedFiles = () => {
  353. selectedFiles.value = [];
  354. uploadProgressMap.value = new Map();
  355. uploadStatuses.value = new Map();
  356. overallProgress.value = 0;
  357. };
  358. /** 移除单个待上传文件(上传解析中不允许移除) */
  359. const removeSelectedFile = (target: File) => {
  360. if (isUploading.value) return;
  361. const index = selectedFiles.value.indexOf(target);
  362. if (index === -1) return;
  363. // 仅允许移除待上传文件
  364. if ((uploadStatuses.value.get(target) || 'pending') !== 'pending') {
  365. return;
  366. }
  367. selectedFiles.value.splice(index, 1);
  368. uploadProgressMap.value.delete(target);
  369. uploadStatuses.value.delete(target);
  370. if (!selectedFiles.value.length) {
  371. overallProgress.value = 0;
  372. }
  373. };
  374. const persistFileInfos = (infos: Recordable[]) => {
  375. sessionStorage.setItem(FILE_INFO_STORAGE_KEY, JSON.stringify(infos));
  376. };
  377. /**
  378. * 从 storage 恢复已解析结果(从清洗页返回时右侧表格还原)
  379. * 同时把批次号恢复为原批次,保证后续上传/删除/清理与原批次数据一致
  380. */
  381. const restoreParsedResults = () => {
  382. try {
  383. const raw = sessionStorage.getItem(FILE_INFO_STORAGE_KEY);
  384. if (!raw) return;
  385. const parsed = JSON.parse(raw);
  386. if (!Array.isArray(parsed) || !parsed.length) return;
  387. allFileInfos.value = parsed;
  388. resultTrees.value = parsed;
  389. // 恢复后默认全部展开
  390. expandAllRows();
  391. const storedBatchId = parsed[0]?.batchId;
  392. if (storedBatchId !== null && storedBatchId !== undefined && String(storedBatchId).trim() !== '') {
  393. uploadBatchId.value = storedBatchId as number;
  394. }
  395. } catch {
  396. // 无效缓存忽略,保持新批次
  397. }
  398. };
  399. onMounted(() => {
  400. restoreParsedResults();
  401. });
  402. const isOwnBatchStorage = () => {
  403. const raw = sessionStorage.getItem(FILE_INFO_STORAGE_KEY);
  404. if (!raw) return true;
  405. try {
  406. const parsed = JSON.parse(raw);
  407. if (!Array.isArray(parsed) || !parsed.length) return true;
  408. return parsed.every((item) => String(item?.batchId ?? '') === String(uploadBatchId.value));
  409. } catch {
  410. return true;
  411. }
  412. };
  413. const clearStoredFileInfos = () => {
  414. if (isOwnBatchStorage()) {
  415. sessionStorage.removeItem(FILE_INFO_STORAGE_KEY);
  416. }
  417. };
  418. /**
  419. * 离开上传页时清空本流程的 sessionStorage 缓存,避免上一批已上传文件被后续流程复用:
  420. * - case_clean_file_infos 已上传文件的解析结果(右侧表格数据源)
  421. * - case_clean_import_file_infos 手动清洗页提交给清洗进度页的导入文件列表
  422. * - case_clean_reclean_context 重新清洗上下文(reClean=1 + fileId)
  423. * - case_clean_import_progress 清洗进度快照,连同运行时状态与 SSE 连接一起重置
  424. */
  425. const clearCleanFlowSessionCache = () => {
  426. clearStoredFileInfos();
  427. sessionStorage.removeItem(IMPORT_FILE_INFO_STORAGE_KEY);
  428. sessionStorage.removeItem(RECLEAN_CONTEXT_STORAGE_KEY);
  429. resetImportProgressSession();
  430. };
  431. /**
  432. * 自动上传队列:选择文件后自动触发,按序消费列表中的待上传文件;
  433. * 上传解析成功即从列表移除,失败保留(标记错误,可手动移除)。
  434. */
  435. const startUpload = async () => {
  436. if (isUploading.value || !batchIdReady.value) return;
  437. const currentBatchId = uploadBatchId.value;
  438. isUploading.value = true;
  439. isAbandoned.value = false;
  440. tableLoading.value = true;
  441. try {
  442. while (true) {
  443. if (isAbandoned.value) break;
  444. // 取当前第一个待上传文件
  445. const index = selectedFiles.value.findIndex((f) => (uploadStatuses.value.get(f) || 'pending') === 'pending');
  446. if (index === -1) break;
  447. const file = selectedFiles.value[index];
  448. uploadStatuses.value.set(file, 'uploading');
  449. const onProgress = (percent: number) => {
  450. uploadProgressMap.value.set(file, percent);
  451. // 总进度:全量待处理文件按(pending=0 / uploading=进度 / error=100)取平均
  452. const list = selectedFiles.value;
  453. const total = list.length || 1;
  454. const sum = list.reduce((acc, f) => {
  455. const st = uploadStatuses.value.get(f);
  456. if (st === 'uploading') return acc + (uploadProgressMap.value.get(f) || 0);
  457. if (st === 'error') return acc + 100;
  458. return acc;
  459. }, 0);
  460. overallProgress.value = Math.round(sum / total);
  461. };
  462. try {
  463. // defHttp.uploadFile 返回原始 AxiosResponse,业务数据在 res.data.data
  464. // 后端返回单个文件节点:文件根(含 children sheet 列表)
  465. const res = await preFileUpload(file, onProgress, { batchId: currentBatchId });
  466. if (isAbandoned.value) break;
  467. const fileNode: FileInfo | null = res?.data?.data ?? null;
  468. if (fileNode) {
  469. // 右侧 treetable 直接渲染树(文件根 → sheet 子节点)
  470. resultTrees.value = [...resultTrees.value, fileNode];
  471. // 新文件根自动展开(受控展开,默认全部展开)
  472. if ((fileNode.children || []).length > 0) {
  473. expandedRowKeys.value = [...expandedRowKeys.value, String(fileNode.id ?? '')];
  474. }
  475. // 持久化给 match/清洗流程的数据按后端返回的树结构存储(文件根 + children sheet)
  476. allFileInfos.value = [...allFileInfos.value, fileNode];
  477. persistFileInfos(allFileInfos.value);
  478. }
  479. // 上传成功:从列表移除该文件
  480. uploadProgressMap.value.delete(file);
  481. uploadStatuses.value.delete(file);
  482. const idx = selectedFiles.value.indexOf(file);
  483. if (idx !== -1) {
  484. selectedFiles.value.splice(idx, 1);
  485. }
  486. onProgress(100);
  487. } catch (error: any) {
  488. // 失败保留在列表,标记错误供用户查看/移除
  489. uploadStatuses.value.set(file, 'error');
  490. uploadProgressMap.value.set(file, 100);
  491. createMessage.error(`「${file.name}」上传解析失败:${error?.message || '未知错误'}`);
  492. }
  493. }
  494. overallProgress.value = 100;
  495. // 只有解析出工作表(children)的才算真正解析成功;「无法读取」的失败节点也占一个
  496. // resultTrees 名额,若直接按长度提示「成功解析 N 个文件」,对用户就是假消息。
  497. const parsedCount = resultTrees.value.filter((root) => (root.children || []).length > 0).length;
  498. const unreadableCount = resultTrees.value.length - parsedCount;
  499. if (!isAbandoned.value && parsedCount) {
  500. createMessage.success(
  501. unreadableCount
  502. ? `成功解析 ${parsedCount} 个文件;另有 ${unreadableCount} 个文件后缀不支持解析,已在右侧列表标出`
  503. : `成功解析 ${parsedCount} 个文件,可在右侧查看结果`,
  504. );
  505. } else if (!isAbandoned.value && unreadableCount) {
  506. createMessage.warning(`${unreadableCount} 个文件后缀不支持解析,未上传(见右侧列表)`);
  507. } else if (!isAbandoned.value && !selectedFiles.value.length) {
  508. createMessage.warning('未识别到可处理文件,请检查文件格式是否正确');
  509. }
  510. } catch (error) {
  511. createMessage.error('上传解析过程中发生错误');
  512. } finally {
  513. isUploading.value = false;
  514. tableLoading.value = false;
  515. }
  516. };
  517. /** 文件/工作表状态 → 中文标签(与 importList、cleaning 页保持一致) */
  518. const fileStatusLabelMap = Object.entries(FileStatusEnum).reduce<Record<string, string>>((map, [key, label]) => {
  519. map[key] = label;
  520. return map;
  521. }, {});
  522. /** 读取/处理成功,可进入匹配、清洗流程的状态 */
  523. const SUCCESS_FILE_STATUSES = new Set<string>([
  524. 'FILE_READ_COMPLETE',
  525. 'SHEET_TEMPLATE_MATCHED_SUCCESS',
  526. 'FILE_CLEAN_COMPLETE',
  527. 'FILE_GOVERN_COMPLETE',
  528. ]);
  529. /** 明确失败的状态(加密文件等中性状态不算失败) */
  530. const FAIL_FILE_STATUSES = new Set<string>([
  531. 'FILE_READ_FAIL',
  532. 'FILE_UNSUPPORTED_FAIL',
  533. 'SHEET_READ_FAIL',
  534. 'SHEET_TEMPLATE_MATCHED_FAIL',
  535. ]);
  536. const isSuccessStatus = (status?: string | null) => {
  537. const normalized = String(status || '').trim();
  538. return !!normalized && SUCCESS_FILE_STATUSES.has(normalized);
  539. };
  540. const mapStatus = (status?: string) => {
  541. const normalized = String(status || '').trim();
  542. if (!normalized) return { success: false, fail: false, label: '待处理' };
  543. return {
  544. success: SUCCESS_FILE_STATUSES.has(normalized),
  545. fail: FAIL_FILE_STATUSES.has(normalized),
  546. label: fileStatusLabelMap[normalized] || normalized,
  547. };
  548. };
  549. const isRootNode = (record: Recordable) => !record.pid || Number(record.pid) === 0;
  550. const resultTableColumns: TableColumnsType<Recordable> = [
  551. {
  552. title: '名称',
  553. key: 'name',
  554. width: 360,
  555. ellipsis: true,
  556. customRender: ({ record }: any) => {
  557. const root = isRootNode(record);
  558. const name = root ? record.fileName || '未命名文件' : record.sheetName || record.tableName || '未命名工作表';
  559. const extra = root && record.fileType ? ` · ${record.fileType}` : '';
  560. return h('span', { class: ['cell-name', { 'is-child': !root }] }, [name + extra]);
  561. },
  562. },
  563. {
  564. title: '大小',
  565. key: 'fileSize',
  566. width: 110,
  567. customRender: ({ record }: any) => (isRootNode(record) ? formatFileSize(record.fileSize) : '—'),
  568. },
  569. {
  570. title: '数据量',
  571. key: 'dataNum',
  572. width: 90,
  573. customRender: ({ record }: any) => record.dataNum ?? '—',
  574. },
  575. {
  576. title: '状态',
  577. key: 'status',
  578. width: 130,
  579. customRender: ({ record }: any) => {
  580. const mapped = mapStatus(record.fileStatus);
  581. const cls = mapped.success ? 'is-success' : mapped.fail ? 'is-fail' : 'is-other';
  582. return h('span', { class: ['status-tag', cls] }, [mapped.label]);
  583. },
  584. },
  585. {
  586. title: '操作',
  587. key: 'action',
  588. width: 150,
  589. customRender: ({ record }: any) => {
  590. // 文件根行:删除整个文件(级联全部工作表)
  591. if (isRootNode(record)) {
  592. return h('div', { class: 'cell-actions' }, [
  593. h(
  594. Button,
  595. {
  596. type: 'link',
  597. size: 'small',
  598. class: 'action-link is-danger',
  599. disabled: isUploading.value,
  600. onClick: () => handleRowDelete(record),
  601. },
  602. () => '删除',
  603. ),
  604. ]);
  605. }
  606. // 工作表行:手动清洗 + 删除
  607. return h('div', { class: 'cell-actions' }, [
  608. h(
  609. Button,
  610. {
  611. type: 'link',
  612. size: 'small',
  613. class: 'action-link',
  614. disabled: isUploading.value,
  615. onClick: () => handleRowManualClean(record),
  616. },
  617. () => '手动清洗',
  618. ),
  619. h(
  620. Button,
  621. {
  622. type: 'link',
  623. size: 'small',
  624. class: 'action-link is-danger',
  625. disabled: isUploading.value,
  626. onClick: () => handleRowDelete(record),
  627. },
  628. () => '删除',
  629. ),
  630. ]);
  631. },
  632. },
  633. ];
  634. /**
  635. * 返回目标:优先用跳入时带来的来源地址(redirect,如从工作台研判方案页进来的 /workbench?view=agent),
  636. * 回到出发界面;没有来源时保持历史逻辑回案件数据。仅接受站内绝对路径,防开放重定向。
  637. */
  638. const backTarget = () => {
  639. const redirect = String(pageQuery.value.redirect || '');
  640. return redirect.startsWith('/') && !redirect.startsWith('//') ? redirect : PageEnum.BASE_HOME;
  641. };
  642. /** 进入流程下一跳时把来源地址透传下去,保证清洗页返回上传页后仍能回到原界面 */
  643. const withRedirect = (query: Record<string, string> = {}) => {
  644. const redirect = String(pageQuery.value.redirect || '');
  645. return redirect ? { ...query, redirect } : query;
  646. };
  647. /**
  648. * 本次上传/清洗流程从哪个页面进入:
  649. * 智能工作台的「上传文件」入口固定带 redirect=/workbench?...,
  650. * 其余入口(案件数据页「上传数据」)都算自主页面。
  651. * 完成面板据此决定第二个按钮是「清洗结果」还是「开始分析」。
  652. */
  653. const resolveCleanFlowSource = (): CleanFlowSource =>
  654. /^\/workbench(\/|\?|$)/.test(String(pageQuery.value.redirect || '')) ? 'smart' : 'manual';
  655. const cleanupAndLeave = async () => {
  656. isAbandoned.value = true;
  657. allowedToLeave.value = true;
  658. try {
  659. await clearUploadBatch({ batchId: uploadBatchId.value });
  660. } catch (error: any) {
  661. createMessage.warning(error?.message || '清理本批次数据失败,可稍后在导入文件管理中删除');
  662. } finally {
  663. clearCleanFlowSessionCache();
  664. await go(backTarget(), true);
  665. emitter.emit(CASE_DATA_REFRESH_EVENT);
  666. }
  667. };
  668. const handleBack = () => {
  669. if (!hasActivity.value) {
  670. allowedToLeave.value = true;
  671. // 返回案件数据:清空已上传文件与清洗流程缓存,避免下次进入上传页还原上一批文件
  672. clearCleanFlowSessionCache();
  673. go(backTarget(), true);
  674. emitter.emit(CASE_DATA_REFRESH_EVENT);
  675. return;
  676. }
  677. Modal.confirm({
  678. title: isUploading.value ? '上传解析进行中' : '存在未处理文件',
  679. content: isUploading.value
  680. ? '当前仍有文件在上传解析,离开将中断本次上传,并清理本批次已上传文件与解析结果,是否继续?'
  681. : '离开将清理本批次已上传文件与解析结果(未进入清洗流程),是否继续?',
  682. okText: '离开并清理',
  683. cancelText: '留在本页',
  684. onOk: cleanupAndLeave,
  685. });
  686. };
  687. /**
  688. * 智能清洗:一次请求跑完「既有模板清洗 + 未匹配文件的 AI 清洗」,跳到进度页看两路结果。
  689. *
  690. * 可用条件是 hasCleanableSheet 而不是 hasSuccessResult:模板匹配失败的那批文件正是 AI 要接的活,
  691. * 按「有成功文件」判断等于在最需要这个按钮的场景里把它锁死。
  692. *
  693. * 接口立即返回 batchId(后台异步跑),所以要把 fileInfos 先写进 sessionStorage:
  694. * 进度页读它来展示文件与批次。发起失败时留在本页、不清缓存,用户可重试或转手动清洗。
  695. */
  696. const handleSmartClean = async () => {
  697. if (!hasCleanableSheet.value || smartCleaning.value) return;
  698. smartCleaning.value = true;
  699. try {
  700. const fileInfos = allFileInfos.value as unknown as FileInfo[];
  701. persistFileInfos(allFileInfos.value);
  702. const res = await smartClean({ fileInfos, reClean: 0, autoGovern: resolveCleanFlowSource() === 'smart' });
  703. if (!res?.batchId) {
  704. throw new Error('后端未返回批次号');
  705. }
  706. allowedToLeave.value = true;
  707. resetImportProgressSession();
  708. setCleanFlowSource(resolveCleanFlowSource());
  709. go({
  710. path: PageEnum.BASE_CLEAN_PROGRESS,
  711. query: { fused: '1', batchId: String(res.batchId) },
  712. });
  713. } catch (error: any) {
  714. createMessage.error(error?.message || '智能清洗发起失败,可改用手动清洗');
  715. } finally {
  716. smartCleaning.value = false;
  717. }
  718. };
  719. /** 手动清洗:进入手动清洗页逐个处理(含模板匹配失败的表) */
  720. const handleManualClean = () => {
  721. if (!hasCleanableSheet.value) return;
  722. allowedToLeave.value = true;
  723. setCleanFlowSource(resolveCleanFlowSource());
  724. go({ path: PageEnum.BASE_CLEANING, query: withRedirect() });
  725. };
  726. /** 行级手动清洗:定位到指定工作表进入手动清洗页 */
  727. const handleRowManualClean = (record: Recordable) => {
  728. const focusKey = String(record.id ?? '').trim();
  729. if (!focusKey) {
  730. createMessage.warning('未获取到工作表ID,暂时无法手动清洗');
  731. return;
  732. }
  733. allowedToLeave.value = true;
  734. setCleanFlowSource(resolveCleanFlowSource());
  735. go({ path: PageEnum.BASE_CLEANING, query: withRedirect({ focusKey }) });
  736. };
  737. /** 本地移除整个文件节点并同步 sessionStorage */
  738. const removeFileNodeSync = (rootId: string) => {
  739. resultTrees.value = resultTrees.value.filter((item) => String(item.id ?? '') !== rootId);
  740. allFileInfos.value = allFileInfos.value.filter((item) => String(item.id ?? '') !== rootId);
  741. // 同步移除已删除文件根的展开状态
  742. expandedRowKeys.value = expandedRowKeys.value.filter((key) => String(key) !== rootId);
  743. persistFileInfos(allFileInfos.value);
  744. };
  745. /** 本地移除单个工作表节点并同步 sessionStorage */
  746. const removeSheetNodeSync = (rootId: string, sheetId: string) => {
  747. const stripSheet = (item: Recordable) =>
  748. String(item.id ?? '') === rootId
  749. ? {
  750. ...item,
  751. children: (item.children || []).filter((child) => String(child.id ?? '') !== sheetId),
  752. }
  753. : item;
  754. resultTrees.value = resultTrees.value.map(stripSheet);
  755. allFileInfos.value = allFileInfos.value.map(stripSheet);
  756. persistFileInfos(allFileInfos.value);
  757. };
  758. /** 行级删除:文件根行删除整个文件(级联全部工作表),工作表行删除单个工作表(仅剩此表时连同文件根) */
  759. const handleRowDelete = (record: Recordable) => {
  760. // 文件根行:整文件删除
  761. if (isRootNode(record)) {
  762. const fileRootId = String(record.id ?? '').trim();
  763. const fileName = record.fileName || '未命名文件';
  764. if (!fileRootId) {
  765. createMessage.warning('未获取到文件ID,暂时无法删除');
  766. return;
  767. }
  768. Modal.confirm({
  769. title: '删除确认',
  770. content: `确定删除文件「${fileName}」及其全部工作表吗?`,
  771. okText: '删除',
  772. okType: 'primary',
  773. okButtonProps: { danger: true },
  774. cancelText: '取消',
  775. onOk: async () => {
  776. try {
  777. // 后端按 id 级联删除(id 匹配 + pid 匹配),传根 id 即删整文件
  778. await deletedFileList({ ids: [fileRootId] });
  779. removeFileNodeSync(fileRootId);
  780. createMessage.success('删除成功');
  781. } catch (error: any) {
  782. createMessage.error(error?.message || '删除失败');
  783. }
  784. },
  785. });
  786. return;
  787. }
  788. // 工作表行:单表删除(该文件仅剩此表时连同文件根一起删除)
  789. const rootId = String(record.pid ?? '').trim();
  790. const root = resultTrees.value.find((item) => String(item.id ?? '') === rootId);
  791. const siblings = root?.children || [];
  792. const isLastSheet = siblings.length <= 1;
  793. const sheetId = String(record.id ?? '').trim();
  794. const sheetName = record.sheetName || '未命名工作表';
  795. const fileName = root?.fileName || '未命名文件';
  796. Modal.confirm({
  797. title: '删除确认',
  798. content: isLastSheet
  799. ? `「${fileName}」仅剩此工作表,删除后将移除整个文件,是否继续?`
  800. : `确定删除工作表「${sheetName}」吗?`,
  801. okText: '删除',
  802. okType: 'primary',
  803. okButtonProps: { danger: true },
  804. cancelText: '取消',
  805. onOk: async () => {
  806. try {
  807. // 后端按 id 级联删除(id 匹配 + pid 匹配):仅剩此表时传根 id 删整文件,否则传 sheet id 删单表
  808. const deleteIds = isLastSheet && root ? [String(root.id ?? '')] : [sheetId];
  809. const validIds = deleteIds.filter(Boolean);
  810. if (!validIds.length) {
  811. createMessage.warning('未获取到ID,暂时无法删除');
  812. return;
  813. }
  814. await deletedFileList({ ids: validIds });
  815. if (isLastSheet) {
  816. removeFileNodeSync(rootId);
  817. } else {
  818. removeSheetNodeSync(rootId, sheetId);
  819. }
  820. createMessage.success('删除成功');
  821. } catch (error: any) {
  822. createMessage.error(error?.message || '删除失败');
  823. }
  824. },
  825. });
  826. };
  827. // 兜底浏览器返回 / 标签页关闭等非按钮离开
  828. onBeforeRouteLeave((to, _from, next) => {
  829. if (allowedToLeave.value || !hasActivity.value) {
  830. // 进入清洗流程(清洗页 / 清洗进度页)时保留缓存,供下游页面读取;
  831. // 其余离开上传页的场景(返回案件数据、切菜单等)一律清空已上传文件与清洗流程缓存
  832. const toPath = String(to?.path ?? '');
  833. if (toPath !== PageEnum.BASE_CLEANING && toPath !== PageEnum.BASE_CLEAN_PROGRESS) {
  834. clearCleanFlowSessionCache();
  835. }
  836. next();
  837. return;
  838. }
  839. Modal.confirm({
  840. title: isUploading.value ? '上传解析进行中' : '存在未处理文件',
  841. content: '离开将清理本批次已上传文件与解析结果(未进入清洗流程),是否继续?',
  842. okText: '离开并清理',
  843. cancelText: '留在本页',
  844. onOk: cleanupAndLeave,
  845. });
  846. next(false);
  847. });
  848. </script>
  849. <style lang="less">
  850. @prefix-cls: ~'jeesite-case-upload';
  851. .@{prefix-cls}.case-upload-page {
  852. width: 100%;
  853. height: 100vh;
  854. background: #f0f2f5;
  855. display: flex;
  856. flex-direction: column;
  857. box-sizing: border-box;
  858. overflow: hidden;
  859. // 公共顶栏由 AppTopBar 自带高度,内容区在剩余高度里滚动
  860. .page-body {
  861. flex: 1 1 auto;
  862. min-height: 0;
  863. padding: 8px 12px;
  864. display: flex;
  865. flex-direction: column;
  866. gap: 8px;
  867. box-sizing: border-box;
  868. overflow: hidden;
  869. }
  870. .page-header {
  871. flex-shrink: 0;
  872. display: flex;
  873. align-items: center;
  874. justify-content: space-between;
  875. gap: 16px;
  876. padding: 10px 14px;
  877. border: 1px solid #e2e8f0;
  878. border-radius: 10px;
  879. background: #fff;
  880. box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
  881. }
  882. .page-header__left {
  883. display: flex;
  884. align-items: center;
  885. gap: 12px;
  886. min-width: 0;
  887. }
  888. .page-header__divider {
  889. flex: 0 0 auto;
  890. width: 1px;
  891. height: 22px;
  892. background: #e2e8f0;
  893. }
  894. .back-btn {
  895. display: inline-flex;
  896. align-items: center;
  897. justify-content: center;
  898. flex: 0 0 auto;
  899. width: 32px;
  900. height: 32px;
  901. padding: 0;
  902. border: 1px solid #e2e8f0;
  903. border-radius: 8px;
  904. background: #f8fafc;
  905. color: #475569;
  906. font-size: 14px;
  907. line-height: 1;
  908. cursor: pointer;
  909. transition:
  910. color 0.2s ease,
  911. border-color 0.2s ease,
  912. background-color 0.2s ease;
  913. &:hover,
  914. &:focus,
  915. &:focus-visible {
  916. color: #2563eb;
  917. border-color: #bfdbfe;
  918. background: #eff6ff;
  919. outline: none;
  920. }
  921. }
  922. .page-title-block {
  923. min-width: 0;
  924. overflow: hidden;
  925. }
  926. .page-title {
  927. font-size: 16px;
  928. line-height: 1.4;
  929. font-weight: 600;
  930. color: #111827;
  931. }
  932. .page-subtitle {
  933. margin-top: 1px;
  934. overflow: hidden;
  935. font-size: 12px;
  936. line-height: 1.6;
  937. color: #64748b;
  938. white-space: nowrap;
  939. text-overflow: ellipsis;
  940. }
  941. .page-header__actions {
  942. display: flex;
  943. align-items: center;
  944. gap: 8px;
  945. flex-shrink: 0;
  946. }
  947. .next-btn {
  948. height: 32px;
  949. padding: 0 16px;
  950. border-radius: 8px;
  951. font-weight: 500;
  952. }
  953. .upload-layout {
  954. flex: 1 1 auto;
  955. min-height: 0;
  956. display: flex;
  957. gap: 8px;
  958. align-items: stretch;
  959. }
  960. .left-panel,
  961. .right-panel {
  962. border: 1px solid #e2e8f0;
  963. border-radius: 10px;
  964. background: #fff;
  965. overflow: hidden;
  966. }
  967. .left-panel {
  968. flex: 0 0 460px;
  969. display: flex;
  970. flex-direction: column;
  971. gap: 10px;
  972. padding: 12px;
  973. box-sizing: border-box;
  974. overflow-y: auto;
  975. }
  976. .right-panel {
  977. flex: 1 1 auto;
  978. min-width: 0;
  979. display: flex;
  980. flex-direction: column;
  981. }
  982. .panel-header {
  983. flex-shrink: 0;
  984. display: flex;
  985. align-items: center;
  986. justify-content: space-between;
  987. gap: 12px;
  988. padding: 10px 14px 8px;
  989. border-bottom: 1px solid #eef2f7;
  990. }
  991. .panel-title {
  992. font-size: 14px;
  993. font-weight: 600;
  994. color: #111827;
  995. }
  996. .panel-desc {
  997. margin-top: 2px;
  998. font-size: 12px;
  999. color: #6b7280;
  1000. line-height: 1.5;
  1001. }
  1002. .panel-header__actions {
  1003. display: flex;
  1004. align-items: center;
  1005. gap: 8px;
  1006. margin-left: auto;
  1007. }
  1008. .panel-stats {
  1009. font-size: 12px;
  1010. color: #64748b;
  1011. white-space: nowrap;
  1012. }
  1013. .table-wrapper {
  1014. flex: 1 1 auto;
  1015. min-height: 0;
  1016. padding: 2px 10px 10px;
  1017. overflow: auto;
  1018. }
  1019. .result-tree-table {
  1020. .ant-table-thead > tr > th {
  1021. padding: 6px 8px;
  1022. font-size: 12px;
  1023. font-weight: 600;
  1024. color: #6b7280;
  1025. background: #fafbfc;
  1026. border-bottom: 1px solid #eef2f7;
  1027. }
  1028. .ant-table-tbody > tr > td {
  1029. padding: 6px 8px;
  1030. font-size: 13px;
  1031. border-bottom: 1px solid #f6f8fa;
  1032. }
  1033. .ant-table-tbody > tr:hover > td {
  1034. background: #f8fafc;
  1035. }
  1036. .ant-table-row-expand-icon {
  1037. margin-right: 6px;
  1038. border-radius: 3px;
  1039. }
  1040. /* 无子集的行:隐藏展开占位图标(样式块非 scoped,直接用普通选择器) */
  1041. .ant-table-row-expand-icon-spaced {
  1042. display: none;
  1043. }
  1044. }
  1045. .cell-name {
  1046. color: #1e293b;
  1047. font-size: 13px;
  1048. &.is-child {
  1049. color: #475569;
  1050. }
  1051. }
  1052. .cell-actions {
  1053. display: inline-flex;
  1054. align-items: center;
  1055. gap: 2px;
  1056. .action-link {
  1057. padding: 0 4px;
  1058. height: 24px;
  1059. font-size: 12px;
  1060. color: #2563eb;
  1061. &:hover {
  1062. color: #1d4ed8;
  1063. }
  1064. &.is-danger {
  1065. color: #dc2626;
  1066. &:hover {
  1067. color: #b91c1c;
  1068. }
  1069. }
  1070. }
  1071. }
  1072. .status-tag {
  1073. display: inline-flex;
  1074. align-items: center;
  1075. padding: 2px 10px;
  1076. border-radius: 999px;
  1077. font-size: 12px;
  1078. font-weight: 600;
  1079. white-space: nowrap;
  1080. &.is-success {
  1081. background: #ecfdf3;
  1082. color: #15803d;
  1083. }
  1084. &.is-fail {
  1085. background: #fef2f2;
  1086. color: #dc2626;
  1087. }
  1088. &.is-other {
  1089. background: #f1f5f9;
  1090. color: #64748b;
  1091. }
  1092. }
  1093. .upload-picker {
  1094. flex: 0 0 auto;
  1095. display: flex;
  1096. justify-content: center;
  1097. }
  1098. .upload-mode-switch {
  1099. display: inline-flex;
  1100. align-items: center;
  1101. gap: 4px;
  1102. width: fit-content;
  1103. padding: 3px;
  1104. border-radius: 8px;
  1105. background: #f1f5f9;
  1106. border: 1px solid #e2e8f0;
  1107. }
  1108. .upload-mode-switch__item {
  1109. min-width: 92px;
  1110. height: 28px;
  1111. padding: 0 12px;
  1112. border: none;
  1113. border-radius: 6px;
  1114. background: transparent;
  1115. color: #64748b;
  1116. font-size: 12px;
  1117. font-weight: 600;
  1118. cursor: pointer;
  1119. transition:
  1120. background-color 0.2s ease,
  1121. color 0.2s ease;
  1122. &.is-active {
  1123. background: #2563eb;
  1124. color: #fff;
  1125. }
  1126. &:disabled {
  1127. cursor: not-allowed;
  1128. opacity: 0.6;
  1129. }
  1130. }
  1131. .upload-dropzone {
  1132. flex: 0 0 auto;
  1133. min-height: 96px;
  1134. border-radius: 8px;
  1135. border: 1px dashed #cbd5e1;
  1136. background: #fafbfc;
  1137. transition: all 0.2s ease;
  1138. cursor: pointer;
  1139. &:hover,
  1140. &.is-dragover {
  1141. border-color: #3b82f6;
  1142. }
  1143. }
  1144. .upload-dragger-content {
  1145. display: flex;
  1146. flex-direction: column;
  1147. align-items: center;
  1148. justify-content: center;
  1149. gap: 4px;
  1150. padding: 12px 0;
  1151. color: #475569;
  1152. }
  1153. .upload-dragger-icon {
  1154. width: 28px;
  1155. height: 28px;
  1156. border-radius: 8px;
  1157. background: rgba(59, 130, 246, 0.1);
  1158. color: #2563eb;
  1159. display: flex;
  1160. align-items: center;
  1161. justify-content: center;
  1162. font-size: 14px;
  1163. }
  1164. .upload-dragger-title {
  1165. font-size: 13px;
  1166. font-weight: 600;
  1167. color: #334155;
  1168. }
  1169. .upload-dragger-desc {
  1170. font-size: 12px;
  1171. color: #64748b;
  1172. max-width: 420px;
  1173. text-align: center;
  1174. }
  1175. .upload-action-btn {
  1176. margin-top: 2px;
  1177. height: 26px;
  1178. padding: 0 14px;
  1179. border-radius: 6px;
  1180. }
  1181. .upload-file-list {
  1182. flex: 1 1 auto;
  1183. min-height: 0;
  1184. display: flex;
  1185. flex-direction: column;
  1186. border: 1px solid #e2e8f0;
  1187. border-radius: 12px;
  1188. background: #ffffff;
  1189. overflow: hidden;
  1190. &__header {
  1191. flex-shrink: 0;
  1192. display: flex;
  1193. align-items: center;
  1194. justify-content: space-between;
  1195. padding: 8px 12px;
  1196. background: #f8fafc;
  1197. border-bottom: 1px solid #e2e8f0;
  1198. font-size: 13px;
  1199. color: #475569;
  1200. font-weight: 500;
  1201. }
  1202. &__body {
  1203. flex: 1 1 auto;
  1204. min-height: 0;
  1205. overflow-y: auto;
  1206. padding: 4px 0;
  1207. }
  1208. &--empty {
  1209. flex: 1 1 auto;
  1210. border-style: dashed;
  1211. background: #fafafa;
  1212. }
  1213. }
  1214. .upload-empty {
  1215. margin: auto;
  1216. padding: 24px 12px;
  1217. color: #9ca3af;
  1218. font-size: 13px;
  1219. text-align: center;
  1220. }
  1221. .upload-file-item {
  1222. display: flex;
  1223. align-items: center;
  1224. justify-content: space-between;
  1225. padding: 6px 12px;
  1226. gap: 12px;
  1227. transition: background 0.15s ease;
  1228. &:hover {
  1229. background: #f8fafc;
  1230. }
  1231. &__info {
  1232. display: flex;
  1233. align-items: center;
  1234. gap: 10px;
  1235. min-width: 0;
  1236. flex: 1;
  1237. }
  1238. &__name {
  1239. font-size: 13px;
  1240. color: #1e293b;
  1241. white-space: nowrap;
  1242. overflow: hidden;
  1243. text-overflow: ellipsis;
  1244. max-width: 300px;
  1245. }
  1246. &__size {
  1247. font-size: 12px;
  1248. color: #94a3b8;
  1249. flex-shrink: 0;
  1250. }
  1251. &__status {
  1252. flex-shrink: 0;
  1253. font-size: 12px;
  1254. min-width: 48px;
  1255. text-align: right;
  1256. }
  1257. &__remove {
  1258. flex-shrink: 0;
  1259. display: inline-flex;
  1260. align-items: center;
  1261. justify-content: center;
  1262. width: 22px;
  1263. height: 22px;
  1264. padding: 0;
  1265. border: none;
  1266. border-radius: 6px;
  1267. background: transparent;
  1268. color: #9ca3af;
  1269. font-size: 11px;
  1270. cursor: pointer;
  1271. transition:
  1272. background-color 0.15s ease,
  1273. color 0.15s ease;
  1274. &:hover {
  1275. background: #fef2f2;
  1276. color: #dc2626;
  1277. }
  1278. }
  1279. &__error {
  1280. color: #dc2626;
  1281. font-weight: 600;
  1282. }
  1283. &__progress-text {
  1284. color: #2563eb;
  1285. font-weight: 500;
  1286. }
  1287. &__pending {
  1288. color: #94a3b8;
  1289. }
  1290. }
  1291. .upload-overall-progress {
  1292. flex-shrink: 0;
  1293. display: flex;
  1294. align-items: center;
  1295. gap: 10px;
  1296. padding: 8px 12px;
  1297. border-top: 1px solid #e2e8f0;
  1298. &__bar {
  1299. flex: 1;
  1300. height: 5px;
  1301. border-radius: 3px;
  1302. background: #e2e8f0;
  1303. overflow: hidden;
  1304. }
  1305. &__fill {
  1306. height: 100%;
  1307. border-radius: 3px;
  1308. background: #2563eb;
  1309. transition: width 0.3s ease;
  1310. }
  1311. &__text {
  1312. font-size: 12px;
  1313. color: #475569;
  1314. font-weight: 500;
  1315. white-space: nowrap;
  1316. }
  1317. }
  1318. @media (max-width: 1080px) {
  1319. .upload-layout {
  1320. flex-direction: column;
  1321. }
  1322. .left-panel {
  1323. flex: 0 0 auto;
  1324. width: auto;
  1325. }
  1326. }
  1327. }
  1328. html[data-theme='dark'] {
  1329. .@{prefix-cls}.case-upload-page {
  1330. background: #0f172a;
  1331. .page-header {
  1332. border-color: #1f2937;
  1333. background: #111827;
  1334. box-shadow: none;
  1335. }
  1336. .page-header__divider {
  1337. background: #1f2937;
  1338. }
  1339. .back-btn {
  1340. border-color: #334155;
  1341. background: #0f172a;
  1342. color: #cbd5e1;
  1343. &:hover,
  1344. &:focus,
  1345. &:focus-visible {
  1346. border-color: #3b82f6;
  1347. background: #1e293b;
  1348. color: #93c5fd;
  1349. }
  1350. }
  1351. .page-title,
  1352. .panel-title,
  1353. .upload-dragger-title,
  1354. .upload-file-item__name,
  1355. .cell-name {
  1356. color: #e5e7eb;
  1357. }
  1358. .page-subtitle,
  1359. .panel-desc,
  1360. .panel-stats,
  1361. .upload-dragger-desc,
  1362. .upload-file-item__size,
  1363. .upload-file-item__pending {
  1364. color: #9ca3af;
  1365. }
  1366. .left-panel,
  1367. .right-panel,
  1368. .upload-file-list {
  1369. background: #111827;
  1370. border-color: #1f2937;
  1371. }
  1372. .panel-header,
  1373. .upload-file-list__header,
  1374. .upload-overall-progress {
  1375. border-color: #1f2937;
  1376. }
  1377. .upload-file-list__header {
  1378. background: #0f172a;
  1379. color: #cbd5e1;
  1380. }
  1381. .upload-file-item:hover {
  1382. background: #1f2937;
  1383. }
  1384. .upload-dropzone {
  1385. border-color: #334155;
  1386. background: #0f172a;
  1387. }
  1388. .upload-mode-switch {
  1389. background: #1e293b;
  1390. border-color: #334155;
  1391. }
  1392. .upload-mode-switch__item {
  1393. color: #94a3b8;
  1394. }
  1395. .upload-overall-progress__bar {
  1396. background: #1f2937;
  1397. }
  1398. .status-tag.is-other {
  1399. background: #1e293b;
  1400. color: #94a3b8;
  1401. }
  1402. .cell-name.is-child {
  1403. color: #cbd5e1;
  1404. }
  1405. .result-tree-table {
  1406. :deep(.ant-table-thead > tr > th) {
  1407. background: #0f172a;
  1408. color: #94a3b8;
  1409. border-bottom-color: #1f2937;
  1410. }
  1411. :deep(.ant-table-tbody > tr > td) {
  1412. border-bottom-color: #111827;
  1413. }
  1414. :deep(.ant-table-tbody > tr:hover > td) {
  1415. background: #1f2937;
  1416. }
  1417. }
  1418. }
  1419. }
  1420. </style>