upload.vue 47 KB

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