| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510 |
- <template>
- <div :class="prefixCls" class="case-upload-page">
- <div class="page-header">
- <div class="page-header__left">
- <Button type="link" class="back-btn" @click="handleBack">
- <template #icon><LeftOutlined /></template>
- 返回
- </Button>
- <div>
- <div class="page-title">文件上传</div>
- <div class="page-subtitle">
- 上传文件;xls / xlsx / csv 会自动解析,其余格式仅登记为「不支持解析」。 「智能」会同时跑既有模板清洗与 AI
- 识别:未匹配到模板的文件交给 AI 判定,判不出来的标为待人工确认。
- </div>
- </div>
- </div>
- <div class="page-header__actions">
- <Button
- type="primary"
- class="next-btn"
- :loading="smartCleaning"
- :disabled="!hasCleanableSheet"
- @click="handleSmartClean"
- >
- 智能
- </Button>
- <Button class="next-btn" :disabled="!hasCleanableSheet" @click="handleManualClean">手动清洗</Button>
- </div>
- </div>
- <div class="upload-layout">
- <aside class="left-panel">
- <div class="upload-picker">
- <div class="upload-mode-switch" role="tablist" aria-label="上传类型切换">
- <button
- type="button"
- :class="['upload-mode-switch__item', { 'is-active': uploadMode === 'single' }]"
- :disabled="isUploading"
- @click="handleUploadModeChange('single')"
- >
- 选择文件
- </button>
- <button
- type="button"
- :class="['upload-mode-switch__item', { 'is-active': uploadMode === 'folder' }]"
- :disabled="isUploading"
- @click="handleUploadModeChange('folder')"
- >
- 选择文件夹
- </button>
- </div>
- </div>
- <!-- 隐藏的文件输入 -->
- <input ref="fileInputRef" type="file" multiple style="display: none" @change="handleFileInputChange" />
- <input
- ref="folderInputRef"
- type="file"
- webkitdirectory
- directory
- style="display: none"
- @change="handleFolderInputChange"
- />
- <div
- class="upload-dropzone"
- :class="{ 'is-dragover': dragOver }"
- @dragover.prevent="handleDragOver"
- @dragleave="handleDragLeave"
- @drop.prevent="handleDrop"
- >
- <div class="upload-dragger-content">
- <div class="upload-dragger-icon">⬆</div>
- <div class="upload-dragger-title">
- {{ uploadMode === 'single' ? '拖拽文件到这里,或点击选择文件' : '拖拽文件夹到这里,或点击选择文件夹' }}
- </div>
- <div class="upload-dragger-desc">
- {{
- uploadMode === 'single'
- ? '支持批量选择任意文件;xls / xlsx / csv 会自动解析,其余格式仅登记为「不支持解析」'
- : '支持选择整个文件夹,系统会自动读取符合条件的文件'
- }}
- </div>
- <Button type="primary" class="upload-action-btn" :disabled="isUploading" @click="handleTriggerFileSelect">
- {{ uploadMode === 'single' ? '选择文件' : '选择文件夹' }}
- </Button>
- </div>
- </div>
- <!-- 已选文件列表(加入后自动上传,成功即移除) -->
- <div v-if="selectedFiles.length" class="upload-file-list">
- <div class="upload-file-list__header">
- <span>待上传 {{ selectedFiles.length }} 个文件</span>
- <Button type="link" size="small" :disabled="isUploading" @click="clearSelectedFiles"> 清空 </Button>
- </div>
- <div class="upload-file-list__body">
- <div
- v-for="file in selectedFiles"
- :key="file.name + file.size + file.lastModified"
- class="upload-file-item"
- >
- <div class="upload-file-item__info">
- <span class="upload-file-item__name" :title="file.name">{{ file.name }}</span>
- <span class="upload-file-item__size">{{ formatFileSize(file.size) }}</span>
- </div>
- <div class="upload-file-item__status">
- <template v-if="uploadStatuses.get(file) === 'error'">
- <span class="upload-file-item__error">✗</span>
- </template>
- <template v-else-if="uploadStatuses.get(file) === 'uploading'">
- <span class="upload-file-item__progress-text">{{ uploadProgressMap.get(file) || 0 }}%</span>
- </template>
- <template v-else>
- <span class="upload-file-item__pending">待上传</span>
- </template>
- </div>
- <button
- v-if="!isUploading && (uploadStatuses.get(file) || 'pending') === 'pending'"
- type="button"
- class="upload-file-item__remove"
- :title="`移除 ${file.name}`"
- aria-label="移除文件"
- @click="removeSelectedFile(file)"
- >
- <CloseOutlined />
- </button>
- </div>
- </div>
- <!-- 整体进度 -->
- <div v-if="isUploading" class="upload-overall-progress">
- <div class="upload-overall-progress__bar">
- <div class="upload-overall-progress__fill" :style="{ width: overallProgress + '%' }"></div>
- </div>
- <span class="upload-overall-progress__text">总进度 {{ overallProgress }}%</span>
- </div>
- </div>
- <div v-else class="upload-file-list upload-file-list--empty">
- <div class="upload-empty">暂无待上传文件,选择文件后将自动上传解析</div>
- </div>
- </aside>
- <section class="right-panel">
- <div class="panel-header">
- <div>
- <div class="panel-title">解析结果(实时)</div>
- <div class="panel-desc">每完成一份文件解析,结果树会实时追加到下方</div>
- </div>
- <div class="panel-header__actions">
- <Button size="small" :disabled="!expandableCount" @click="toggleExpandAllRows">
- {{ allExpanded ? '全部收起' : '全部展开' }}
- </Button>
- </div>
- <div class="panel-stats">已解析 {{ resultTrees.length }} 个文件</div>
- </div>
- <div class="table-wrapper">
- <Table
- class="result-tree-table"
- :columns="resultTableColumns"
- :data-source="tableDataSource"
- :loading="tableLoading"
- :pagination="false"
- row-key="id"
- :children-column-name="'children'"
- :expanded-row-keys="expandedRowKeys"
- size="small"
- :scroll="{ x: 840 }"
- @expandedRowsChange="onExpandedRowsChange"
- />
- </div>
- </section>
- </div>
- </div>
- </template>
- <script lang="ts" setup name="ViewsDataUpload">
- import { computed, h, onMounted, ref } from 'vue';
- import { onBeforeRouteLeave } from 'vue-router';
- import { CloseOutlined, LeftOutlined } from '@ant-design/icons-vue';
- import { Button, Modal, Table, type TableColumnsType } from 'ant-design-vue';
- import { useDesign } from '@/core/hooks/web/useDesign';
- import { useGo, useQuery } from '@/core/hooks/web/usePage';
- import { PageEnum } from '@/core/enums/pageEnum';
- import { useMessage } from '@/core/hooks/web/useMessage';
- import { useEmitter } from '@/core/store/modules/case';
- import { clearUploadBatch, deletedFileList, preFileUpload, smartClean } from '@/case/api/govern/governApi';
- import { FileStatusEnum } from '@/case/types/enum';
- import { resetImportProgressSession, setCleanFlowSource } from './importProgressSession';
- import type { CleanFlowSource } from './importProgressSession';
- import type { FileInfo } from '@/types/api';
- const { prefixCls } = useDesign('case-upload');
- const go = useGo();
- const pageQuery = useQuery();
- const emitter = useEmitter();
- const { createMessage } = useMessage();
- const FILE_INFO_STORAGE_KEY = 'case_clean_file_infos';
- const IMPORT_FILE_INFO_STORAGE_KEY = 'case_clean_import_file_infos';
- const RECLEAN_CONTEXT_STORAGE_KEY = 'case_clean_reclean_context';
- const CASE_DATA_REFRESH_EVENT = 'case-data:refresh';
- type UploadMode = 'single' | 'folder';
- /** 进入上传页即生成批次 id,本页所有文件统一使用该批次(Long 型,上限约 1.7e18,安全) */
- const createUploadBatchId = () => Date.now() * 1000000 + Math.floor(Math.random() * 1000000);
- const uploadBatchId = ref(createUploadBatchId());
- const modeFromQuery = computed<UploadMode>(() => (pageQuery.value?.mode === 'folder' ? 'folder' : 'single'));
- const uploadMode = ref<UploadMode>(modeFromQuery.value);
- // 文件选择与上传状态(以 File 对象为键,上传成功移除文件时状态映射不会错乱)
- const fileInputRef = ref<HTMLInputElement | null>(null);
- const folderInputRef = ref<HTMLInputElement | null>(null);
- const selectedFiles = ref<File[]>([]);
- const uploadProgressMap = ref<Map<File, number>>(new Map());
- const uploadStatuses = ref<Map<File, 'pending' | 'uploading' | 'done' | 'error'>>(new Map());
- const isUploading = ref(false);
- const overallProgress = ref(0);
- const dragOver = ref(false);
- // 解析结果(右侧 treetable 数据源,实时追加)
- const resultTrees = ref<FileInfo[]>([]);
- const allFileInfos = ref<Recordable[]>([]);
- const tableLoading = ref(false);
- // 受控展开:默认全部展开,新解析的文件根节点自动展开
- // (键类型与 ant-design-vue Table 的 expandedRowKeys 保持一致:string | number)
- const expandedRowKeys = ref<(string | number)[]>([]);
- const expandableCount = computed(() => resultTrees.value.filter((root) => (root.children || []).length > 0).length);
- const allExpanded = computed(
- () => expandableCount.value > 0 && expandedRowKeys.value.length >= expandableCount.value,
- );
- const collectExpandableKeys = () =>
- resultTrees.value.filter((root) => (root.children || []).length > 0).map((root) => String(root.id ?? ''));
- const expandAllRows = () => {
- expandedRowKeys.value = collectExpandableKeys();
- };
- const toggleExpandAllRows = () => {
- expandedRowKeys.value = allExpanded.value ? [] : collectExpandableKeys();
- };
- // 用户手动展开/收起时同步受控 keys
- const onExpandedRowsChange = (keys: (string | number)[]) => {
- expandedRowKeys.value = keys;
- };
- // 表格数据源:递归剥离空 children。
- // 后端 FileInfo.children 恒为空数组(非 undefined),ant-design-vue 树形模式下
- // 只要 record.children 为真值就渲染展开图标,空数组也算,必须逐层剥掉
- const stripEmptyChildren = (node: Recordable): Recordable => {
- const children = node.children || [];
- if (!children.length) {
- return { ...node, children: undefined };
- }
- return { ...node, children: children.map(stripEmptyChildren) };
- };
- const tableDataSource = computed(() => resultTrees.value.map(stripEmptyChildren));
- // 离开放行标记:点「下一步」或已确认清理后的程序化跳转置为 true
- const allowedToLeave = ref(false);
- // 放弃标记:确认离开后中断上传循环,避免组件卸载后继续写结果
- const isAbandoned = ref(false);
- const batchIdReady = computed(() => !!uploadBatchId.value);
- const hasActivity = computed(
- () => isUploading.value || selectedFiles.value.length > 0 || resultTrees.value.length > 0,
- );
- /**
- * 是否存在可清洗的工作表(「智能」与「手动清洗」的可用条件)。
- *
- * 不能沿用 `resultTrees.length` —— 「无法读取」的失败节点(txt / md / doc / docx / pdf)
- * 也会进 resultTrees,但它没有 children(没有解析出任何 sheet)。
- * 若只按长度判断,用户传一个 docx 后「手动清洗」按钮会亮起,点进去却是一个空的手动清洗页。
- */
- const hasCleanableSheet = computed(() => resultTrees.value.some((root) => (root.children || []).length > 0));
- /** 「智能」是异步发起:请求本身要等后端建批次,按钮要给出进行中反馈 */
- const smartCleaning = ref(false);
- const formatFileSize = (bytes?: number | null) => {
- if (bytes == null || bytes === 0) return '0 B';
- const k = 1024;
- const sizes = ['B', 'KB', 'MB', 'GB'];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
- return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
- };
- const handleDragOver = () => {
- dragOver.value = true;
- };
- const handleDragLeave = () => {
- dragOver.value = false;
- };
- const traverseDirectory = async (entry: FileSystemDirectoryEntry): Promise<File[]> => {
- const files: File[] = [];
- const reader = entry.createReader();
- const getEntries = (): Promise<FileSystemEntry[]> => new Promise((resolve) => reader.readEntries(resolve));
- let entries = await getEntries();
- while (entries.length) {
- for (const e of entries) {
- if (e.isFile) {
- const file = await new Promise<File>((resolve) => (e as FileSystemFileEntry).file(resolve));
- files.push(file);
- } else if (e.isDirectory) {
- files.push(...(await traverseDirectory(e as FileSystemDirectoryEntry)));
- }
- }
- entries = await getEntries();
- }
- return files;
- };
- const collectFilesFromDataTransfer = async (items: DataTransferItemList): Promise<File[]> => {
- const files: File[] = [];
- for (let i = 0; i < items.length; i++) {
- const item = items[i];
- const entry = item.webkitGetAsEntry?.();
- if (!entry) {
- const file = item.getAsFile();
- if (file) files.push(file);
- continue;
- }
- if (entry.isFile) {
- const file = await new Promise<File>((resolve) => (entry as FileSystemFileEntry).file(resolve));
- files.push(file);
- } else if (entry.isDirectory) {
- files.push(...(await traverseDirectory(entry as FileSystemDirectoryEntry)));
- }
- }
- return files;
- };
- /** 文件加入上传列表并自动开始上传(不按后缀初筛:能不能解析由后端判定并回写状态) */
- const addFiles = (files: File[]) => {
- if (!files.length || isAbandoned.value) return;
- selectedFiles.value = [...selectedFiles.value, ...files];
- void startUpload();
- };
- const handleDrop = async (event: DragEvent) => {
- dragOver.value = false;
- if (!event.dataTransfer) return;
- const files = await collectFilesFromDataTransfer(event.dataTransfer.items);
- addFiles(files);
- };
- const handleFileInputChange = (event: Event) => {
- const target = event.target as HTMLInputElement;
- if (!target.files?.length) return;
- addFiles(Array.from(target.files));
- target.value = '';
- };
- const handleFolderInputChange = (event: Event) => {
- const target = event.target as HTMLInputElement;
- if (!target.files?.length) return;
- const files = Array.from(target.files).filter((f) => f.webkitRelativePath);
- addFiles(files);
- target.value = '';
- };
- const handleTriggerFileSelect = () => {
- if (uploadMode.value === 'folder') {
- folderInputRef.value?.click();
- } else {
- fileInputRef.value?.click();
- }
- };
- const handleUploadModeChange = (mode: UploadMode) => {
- if (uploadMode.value === mode || isUploading.value) return;
- uploadMode.value = mode;
- clearSelectedFiles();
- };
- const clearSelectedFiles = () => {
- selectedFiles.value = [];
- uploadProgressMap.value = new Map();
- uploadStatuses.value = new Map();
- overallProgress.value = 0;
- };
- /** 移除单个待上传文件(上传解析中不允许移除) */
- const removeSelectedFile = (target: File) => {
- if (isUploading.value) return;
- const index = selectedFiles.value.indexOf(target);
- if (index === -1) return;
- // 仅允许移除待上传文件
- if ((uploadStatuses.value.get(target) || 'pending') !== 'pending') {
- return;
- }
- selectedFiles.value.splice(index, 1);
- uploadProgressMap.value.delete(target);
- uploadStatuses.value.delete(target);
- if (!selectedFiles.value.length) {
- overallProgress.value = 0;
- }
- };
- const persistFileInfos = (infos: Recordable[]) => {
- sessionStorage.setItem(FILE_INFO_STORAGE_KEY, JSON.stringify(infos));
- };
- /**
- * 从 storage 恢复已解析结果(从清洗页返回时右侧表格还原)
- * 同时把批次号恢复为原批次,保证后续上传/删除/清理与原批次数据一致
- */
- const restoreParsedResults = () => {
- try {
- const raw = sessionStorage.getItem(FILE_INFO_STORAGE_KEY);
- if (!raw) return;
- const parsed = JSON.parse(raw);
- if (!Array.isArray(parsed) || !parsed.length) return;
- allFileInfos.value = parsed;
- resultTrees.value = parsed;
- // 恢复后默认全部展开
- expandAllRows();
- const storedBatchId = parsed[0]?.batchId;
- if (storedBatchId !== null && storedBatchId !== undefined && String(storedBatchId).trim() !== '') {
- uploadBatchId.value = storedBatchId as number;
- }
- } catch {
- // 无效缓存忽略,保持新批次
- }
- };
- onMounted(() => {
- restoreParsedResults();
- });
- const isOwnBatchStorage = () => {
- const raw = sessionStorage.getItem(FILE_INFO_STORAGE_KEY);
- if (!raw) return true;
- try {
- const parsed = JSON.parse(raw);
- if (!Array.isArray(parsed) || !parsed.length) return true;
- return parsed.every((item) => String(item?.batchId ?? '') === String(uploadBatchId.value));
- } catch {
- return true;
- }
- };
- const clearStoredFileInfos = () => {
- if (isOwnBatchStorage()) {
- sessionStorage.removeItem(FILE_INFO_STORAGE_KEY);
- }
- };
- /**
- * 离开上传页时清空本流程的 sessionStorage 缓存,避免上一批已上传文件被后续流程复用:
- * - case_clean_file_infos 已上传文件的解析结果(右侧表格数据源)
- * - case_clean_import_file_infos 手动清洗页提交给清洗进度页的导入文件列表
- * - case_clean_reclean_context 重新清洗上下文(reClean=1 + fileId)
- * - case_clean_import_progress 清洗进度快照,连同运行时状态与 SSE 连接一起重置
- */
- const clearCleanFlowSessionCache = () => {
- clearStoredFileInfos();
- sessionStorage.removeItem(IMPORT_FILE_INFO_STORAGE_KEY);
- sessionStorage.removeItem(RECLEAN_CONTEXT_STORAGE_KEY);
- resetImportProgressSession();
- };
- /**
- * 自动上传队列:选择文件后自动触发,按序消费列表中的待上传文件;
- * 上传解析成功即从列表移除,失败保留(标记错误,可手动移除)。
- */
- const startUpload = async () => {
- if (isUploading.value || !batchIdReady.value) return;
- const currentBatchId = uploadBatchId.value;
- isUploading.value = true;
- isAbandoned.value = false;
- tableLoading.value = true;
- try {
- while (true) {
- if (isAbandoned.value) break;
- // 取当前第一个待上传文件
- const index = selectedFiles.value.findIndex((f) => (uploadStatuses.value.get(f) || 'pending') === 'pending');
- if (index === -1) break;
- const file = selectedFiles.value[index];
- uploadStatuses.value.set(file, 'uploading');
- const onProgress = (percent: number) => {
- uploadProgressMap.value.set(file, percent);
- // 总进度:全量待处理文件按(pending=0 / uploading=进度 / error=100)取平均
- const list = selectedFiles.value;
- const total = list.length || 1;
- const sum = list.reduce((acc, f) => {
- const st = uploadStatuses.value.get(f);
- if (st === 'uploading') return acc + (uploadProgressMap.value.get(f) || 0);
- if (st === 'error') return acc + 100;
- return acc;
- }, 0);
- overallProgress.value = Math.round(sum / total);
- };
- try {
- // defHttp.uploadFile 返回原始 AxiosResponse,业务数据在 res.data.data
- // 后端返回单个文件节点:文件根(含 children sheet 列表)
- const res = await preFileUpload(file, onProgress, { batchId: currentBatchId });
- if (isAbandoned.value) break;
- const fileNode: FileInfo | null = res?.data?.data ?? null;
- if (fileNode) {
- // 右侧 treetable 直接渲染树(文件根 → sheet 子节点)
- resultTrees.value = [...resultTrees.value, fileNode];
- // 新文件根自动展开(受控展开,默认全部展开)
- if ((fileNode.children || []).length > 0) {
- expandedRowKeys.value = [...expandedRowKeys.value, String(fileNode.id ?? '')];
- }
- // 持久化给 match/清洗流程的数据按后端返回的树结构存储(文件根 + children sheet)
- allFileInfos.value = [...allFileInfos.value, fileNode];
- persistFileInfos(allFileInfos.value);
- }
- // 上传成功:从列表移除该文件
- uploadProgressMap.value.delete(file);
- uploadStatuses.value.delete(file);
- const idx = selectedFiles.value.indexOf(file);
- if (idx !== -1) {
- selectedFiles.value.splice(idx, 1);
- }
- onProgress(100);
- } catch (error: any) {
- // 失败保留在列表,标记错误供用户查看/移除
- uploadStatuses.value.set(file, 'error');
- uploadProgressMap.value.set(file, 100);
- createMessage.error(`「${file.name}」上传解析失败:${error?.message || '未知错误'}`);
- }
- }
- overallProgress.value = 100;
- // 只有解析出工作表(children)的才算真正解析成功;「无法读取」的失败节点也占一个
- // resultTrees 名额,若直接按长度提示「成功解析 N 个文件」,对用户就是假消息。
- const parsedCount = resultTrees.value.filter((root) => (root.children || []).length > 0).length;
- const unreadableCount = resultTrees.value.length - parsedCount;
- if (!isAbandoned.value && parsedCount) {
- createMessage.success(
- unreadableCount
- ? `成功解析 ${parsedCount} 个文件;另有 ${unreadableCount} 个文件后缀不支持解析,已在右侧列表标出`
- : `成功解析 ${parsedCount} 个文件,可在右侧查看结果`,
- );
- } else if (!isAbandoned.value && unreadableCount) {
- createMessage.warning(`${unreadableCount} 个文件后缀不支持解析,未上传(见右侧列表)`);
- } else if (!isAbandoned.value && !selectedFiles.value.length) {
- createMessage.warning('未识别到可处理文件,请检查文件格式是否正确');
- }
- } catch (error) {
- createMessage.error('上传解析过程中发生错误');
- } finally {
- isUploading.value = false;
- tableLoading.value = false;
- }
- };
- /** 文件/工作表状态 → 中文标签(与 importList、cleaning 页保持一致) */
- const fileStatusLabelMap = Object.entries(FileStatusEnum).reduce<Record<string, string>>((map, [key, label]) => {
- map[key] = label;
- return map;
- }, {});
- /** 读取/处理成功,可进入匹配、清洗流程的状态 */
- const SUCCESS_FILE_STATUSES = new Set<string>([
- 'FILE_READ_COMPLETE',
- 'SHEET_TEMPLATE_MATCHED_SUCCESS',
- 'FILE_CLEAN_COMPLETE',
- 'FILE_GOVERN_COMPLETE',
- ]);
- /** 明确失败的状态(加密文件等中性状态不算失败) */
- const FAIL_FILE_STATUSES = new Set<string>([
- 'FILE_READ_FAIL',
- 'FILE_UNSUPPORTED_FAIL',
- 'SHEET_READ_FAIL',
- 'SHEET_TEMPLATE_MATCHED_FAIL',
- ]);
- const isSuccessStatus = (status?: string | null) => {
- const normalized = String(status || '').trim();
- return !!normalized && SUCCESS_FILE_STATUSES.has(normalized);
- };
- const mapStatus = (status?: string) => {
- const normalized = String(status || '').trim();
- if (!normalized) return { success: false, fail: false, label: '待处理' };
- return {
- success: SUCCESS_FILE_STATUSES.has(normalized),
- fail: FAIL_FILE_STATUSES.has(normalized),
- label: fileStatusLabelMap[normalized] || normalized,
- };
- };
- const isRootNode = (record: Recordable) => !record.pid || Number(record.pid) === 0;
- const resultTableColumns: TableColumnsType<Recordable> = [
- {
- title: '名称',
- key: 'name',
- width: 360,
- ellipsis: true,
- customRender: ({ record }: any) => {
- const root = isRootNode(record);
- const name = root ? record.fileName || '未命名文件' : record.sheetName || record.tableName || '未命名工作表';
- const extra = root && record.fileType ? ` · ${record.fileType}` : '';
- return h('span', { class: ['cell-name', { 'is-child': !root }], title: String(name) + extra }, [name + extra]);
- },
- },
- {
- title: '大小',
- key: 'fileSize',
- width: 110,
- customRender: ({ record }: any) => (isRootNode(record) ? formatFileSize(record.fileSize) : '—'),
- },
- {
- title: '数据量',
- key: 'dataNum',
- width: 90,
- customRender: ({ record }: any) => record.dataNum ?? '—',
- },
- {
- title: '状态',
- key: 'status',
- width: 130,
- customRender: ({ record }: any) => {
- const mapped = mapStatus(record.fileStatus);
- const cls = mapped.success ? 'is-success' : mapped.fail ? 'is-fail' : 'is-other';
- return h('span', { class: ['status-tag', cls] }, [mapped.label]);
- },
- },
- {
- title: '操作',
- key: 'action',
- width: 150,
- customRender: ({ record }: any) => {
- // 文件根行:删除整个文件(级联全部工作表)
- if (isRootNode(record)) {
- return h('div', { class: 'cell-actions' }, [
- h(
- Button,
- {
- type: 'link',
- size: 'small',
- class: 'action-link is-danger',
- disabled: isUploading.value,
- onClick: () => handleRowDelete(record),
- },
- () => '删除',
- ),
- ]);
- }
- // 工作表行:手动清洗 + 删除
- return h('div', { class: 'cell-actions' }, [
- h(
- Button,
- {
- type: 'link',
- size: 'small',
- class: 'action-link',
- disabled: isUploading.value,
- onClick: () => handleRowManualClean(record),
- },
- () => '手动清洗',
- ),
- h(
- Button,
- {
- type: 'link',
- size: 'small',
- class: 'action-link is-danger',
- disabled: isUploading.value,
- onClick: () => handleRowDelete(record),
- },
- () => '删除',
- ),
- ]);
- },
- },
- ];
- /**
- * 返回目标:优先用跳入时带来的来源地址(redirect,如从工作台研判方案页进来的 /workbench?view=agent),
- * 回到出发界面;没有来源时保持历史逻辑回案件数据。仅接受站内绝对路径,防开放重定向。
- */
- const backTarget = () => {
- const redirect = String(pageQuery.value.redirect || '');
- return redirect.startsWith('/') && !redirect.startsWith('//') ? redirect : PageEnum.BASE_HOME;
- };
- /** 进入流程下一跳时把来源地址透传下去,保证清洗页返回上传页后仍能回到原界面 */
- const withRedirect = (query: Record<string, string> = {}) => {
- const redirect = String(pageQuery.value.redirect || '');
- return redirect ? { ...query, redirect } : query;
- };
- /**
- * 本次上传/清洗流程从哪个页面进入:
- * 智能工作台的「上传文件」入口固定带 redirect=/workbench?...,
- * 其余入口(案件数据页「上传数据」)都算自主页面。
- * 完成面板据此决定第二个按钮是「清洗结果」还是「开始分析」。
- */
- const resolveCleanFlowSource = (): CleanFlowSource =>
- /^\/workbench(\/|\?|$)/.test(String(pageQuery.value.redirect || '')) ? 'smart' : 'manual';
- const cleanupAndLeave = async () => {
- isAbandoned.value = true;
- allowedToLeave.value = true;
- try {
- await clearUploadBatch({ batchId: uploadBatchId.value });
- } catch (error: any) {
- createMessage.warning(error?.message || '清理本批次数据失败,可稍后在导入文件管理中删除');
- } finally {
- clearCleanFlowSessionCache();
- await go(backTarget(), true);
- emitter.emit(CASE_DATA_REFRESH_EVENT);
- }
- };
- const handleBack = () => {
- if (!hasActivity.value) {
- allowedToLeave.value = true;
- // 返回案件数据:清空已上传文件与清洗流程缓存,避免下次进入上传页还原上一批文件
- clearCleanFlowSessionCache();
- go(backTarget(), true);
- emitter.emit(CASE_DATA_REFRESH_EVENT);
- return;
- }
- Modal.confirm({
- title: isUploading.value ? '上传解析进行中' : '存在未处理文件',
- content: isUploading.value
- ? '当前仍有文件在上传解析,离开将中断本次上传,并清理本批次已上传文件与解析结果,是否继续?'
- : '离开将清理本批次已上传文件与解析结果(未进入清洗流程),是否继续?',
- okText: '离开并清理',
- cancelText: '留在本页',
- onOk: cleanupAndLeave,
- });
- };
- /**
- * 智能清洗:一次请求跑完「既有模板清洗 + 未匹配文件的 AI 清洗」,跳到进度页看两路结果。
- *
- * 可用条件是 hasCleanableSheet 而不是 hasSuccessResult:模板匹配失败的那批文件正是 AI 要接的活,
- * 按「有成功文件」判断等于在最需要这个按钮的场景里把它锁死。
- *
- * 接口立即返回 batchId(后台异步跑),所以要把 fileInfos 先写进 sessionStorage:
- * 进度页读它来展示文件与批次。发起失败时留在本页、不清缓存,用户可重试或转手动清洗。
- */
- const handleSmartClean = async () => {
- if (!hasCleanableSheet.value || smartCleaning.value) return;
- smartCleaning.value = true;
- try {
- const fileInfos = allFileInfos.value as unknown as FileInfo[];
- persistFileInfos(allFileInfos.value);
- const res = await smartClean({ fileInfos, reClean: 0 });
- if (!res?.batchId) {
- throw new Error('后端未返回批次号');
- }
- allowedToLeave.value = true;
- resetImportProgressSession();
- setCleanFlowSource(resolveCleanFlowSource());
- go({
- path: PageEnum.BASE_CLEAN_PROGRESS,
- query: { fused: '1', batchId: String(res.batchId) },
- });
- } catch (error: any) {
- createMessage.error(error?.message || '智能清洗发起失败,可改用手动清洗');
- } finally {
- smartCleaning.value = false;
- }
- };
- /** 手动清洗:进入手动清洗页逐个处理(含模板匹配失败的表) */
- const handleManualClean = () => {
- if (!hasCleanableSheet.value) return;
- allowedToLeave.value = true;
- setCleanFlowSource(resolveCleanFlowSource());
- go({ path: PageEnum.BASE_CLEANING, query: withRedirect() });
- };
- /** 行级手动清洗:定位到指定工作表进入手动清洗页 */
- const handleRowManualClean = (record: Recordable) => {
- const focusKey = String(record.id ?? '').trim();
- if (!focusKey) {
- createMessage.warning('未获取到工作表ID,暂时无法手动清洗');
- return;
- }
- allowedToLeave.value = true;
- setCleanFlowSource(resolveCleanFlowSource());
- go({ path: PageEnum.BASE_CLEANING, query: withRedirect({ focusKey }) });
- };
- /** 本地移除整个文件节点并同步 sessionStorage */
- const removeFileNodeSync = (rootId: string) => {
- resultTrees.value = resultTrees.value.filter((item) => String(item.id ?? '') !== rootId);
- allFileInfos.value = allFileInfos.value.filter((item) => String(item.id ?? '') !== rootId);
- // 同步移除已删除文件根的展开状态
- expandedRowKeys.value = expandedRowKeys.value.filter((key) => String(key) !== rootId);
- persistFileInfos(allFileInfos.value);
- };
- /** 本地移除单个工作表节点并同步 sessionStorage */
- const removeSheetNodeSync = (rootId: string, sheetId: string) => {
- const stripSheet = (item: Recordable) =>
- String(item.id ?? '') === rootId
- ? {
- ...item,
- children: (item.children || []).filter((child) => String(child.id ?? '') !== sheetId),
- }
- : item;
- resultTrees.value = resultTrees.value.map(stripSheet);
- allFileInfos.value = allFileInfos.value.map(stripSheet);
- persistFileInfos(allFileInfos.value);
- };
- /** 行级删除:文件根行删除整个文件(级联全部工作表),工作表行删除单个工作表(仅剩此表时连同文件根) */
- const handleRowDelete = (record: Recordable) => {
- // 文件根行:整文件删除
- if (isRootNode(record)) {
- const fileRootId = String(record.id ?? '').trim();
- const fileName = record.fileName || '未命名文件';
- if (!fileRootId) {
- createMessage.warning('未获取到文件ID,暂时无法删除');
- return;
- }
- Modal.confirm({
- title: '删除确认',
- content: `确定删除文件「${fileName}」及其全部工作表吗?`,
- okText: '删除',
- okType: 'primary',
- okButtonProps: { danger: true },
- cancelText: '取消',
- onOk: async () => {
- try {
- // 后端按 id 级联删除(id 匹配 + pid 匹配),传根 id 即删整文件
- await deletedFileList({ ids: [fileRootId] });
- removeFileNodeSync(fileRootId);
- createMessage.success('删除成功');
- } catch (error: any) {
- createMessage.error(error?.message || '删除失败');
- }
- },
- });
- return;
- }
- // 工作表行:单表删除(该文件仅剩此表时连同文件根一起删除)
- const rootId = String(record.pid ?? '').trim();
- const root = resultTrees.value.find((item) => String(item.id ?? '') === rootId);
- const siblings = root?.children || [];
- const isLastSheet = siblings.length <= 1;
- const sheetId = String(record.id ?? '').trim();
- const sheetName = record.sheetName || '未命名工作表';
- const fileName = root?.fileName || '未命名文件';
- Modal.confirm({
- title: '删除确认',
- content: isLastSheet
- ? `「${fileName}」仅剩此工作表,删除后将移除整个文件,是否继续?`
- : `确定删除工作表「${sheetName}」吗?`,
- okText: '删除',
- okType: 'primary',
- okButtonProps: { danger: true },
- cancelText: '取消',
- onOk: async () => {
- try {
- // 后端按 id 级联删除(id 匹配 + pid 匹配):仅剩此表时传根 id 删整文件,否则传 sheet id 删单表
- const deleteIds = isLastSheet && root ? [String(root.id ?? '')] : [sheetId];
- const validIds = deleteIds.filter(Boolean);
- if (!validIds.length) {
- createMessage.warning('未获取到ID,暂时无法删除');
- return;
- }
- await deletedFileList({ ids: validIds });
- if (isLastSheet) {
- removeFileNodeSync(rootId);
- } else {
- removeSheetNodeSync(rootId, sheetId);
- }
- createMessage.success('删除成功');
- } catch (error: any) {
- createMessage.error(error?.message || '删除失败');
- }
- },
- });
- };
- // 兜底浏览器返回 / 标签页关闭等非按钮离开
- onBeforeRouteLeave((to, _from, next) => {
- if (allowedToLeave.value || !hasActivity.value) {
- // 进入清洗流程(清洗页 / 清洗进度页)时保留缓存,供下游页面读取;
- // 其余离开上传页的场景(返回案件数据、切菜单等)一律清空已上传文件与清洗流程缓存
- const toPath = String(to?.path ?? '');
- if (toPath !== PageEnum.BASE_CLEANING && toPath !== PageEnum.BASE_CLEAN_PROGRESS) {
- clearCleanFlowSessionCache();
- }
- next();
- return;
- }
- Modal.confirm({
- title: isUploading.value ? '上传解析进行中' : '存在未处理文件',
- content: '离开将清理本批次已上传文件与解析结果(未进入清洗流程),是否继续?',
- okText: '离开并清理',
- cancelText: '留在本页',
- onOk: cleanupAndLeave,
- });
- next(false);
- });
- </script>
- <style lang="less">
- @prefix-cls: ~'jeesite-case-upload';
- .@{prefix-cls}.case-upload-page {
- width: 100%;
- height: 100vh;
- padding: 8px 12px;
- background: #f0f2f5;
- display: flex;
- flex-direction: column;
- gap: 8px;
- box-sizing: border-box;
- overflow: hidden;
- .page-header {
- flex-shrink: 0;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- padding: 2px 2px 0;
- }
- .page-header__left {
- display: flex;
- align-items: center;
- gap: 10px;
- min-width: 0;
- }
- .back-btn {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- padding: 0 4px 0 0;
- height: 30px;
- border-radius: 6px;
- color: #2563eb;
- font-size: 14px;
- font-weight: 600;
- line-height: 1;
- flex-shrink: 0;
- :deep(.ant-btn-icon) {
- font-size: 15px;
- }
- &:hover {
- color: #1d4ed8;
- }
- }
- .page-title {
- font-size: 15px;
- line-height: 1.3;
- font-weight: 600;
- color: #111827;
- }
- .page-subtitle {
- margin-top: 2px;
- font-size: 12px;
- line-height: 1.5;
- color: #6b7280;
- }
- .page-header__actions {
- display: flex;
- align-items: center;
- gap: 8px;
- flex-shrink: 0;
- }
- .next-btn {
- height: 32px;
- padding: 0 14px;
- border-radius: 8px;
- }
- .upload-layout {
- flex: 1 1 auto;
- min-height: 0;
- display: flex;
- gap: 8px;
- align-items: stretch;
- }
- .left-panel,
- .right-panel {
- border: 1px solid #e2e8f0;
- border-radius: 10px;
- background: #fff;
- overflow: hidden;
- }
- .left-panel {
- flex: 0 0 460px;
- display: flex;
- flex-direction: column;
- gap: 10px;
- padding: 12px;
- box-sizing: border-box;
- overflow-y: auto;
- }
- .right-panel {
- flex: 1 1 auto;
- min-width: 0;
- display: flex;
- flex-direction: column;
- }
- .panel-header {
- flex-shrink: 0;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- padding: 10px 14px 8px;
- border-bottom: 1px solid #eef2f7;
- }
- .panel-title {
- font-size: 14px;
- font-weight: 600;
- color: #111827;
- }
- .panel-desc {
- margin-top: 2px;
- font-size: 12px;
- color: #6b7280;
- line-height: 1.5;
- }
- .panel-header__actions {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-left: auto;
- }
- .panel-stats {
- font-size: 12px;
- color: #64748b;
- white-space: nowrap;
- }
- .table-wrapper {
- flex: 1 1 auto;
- min-height: 0;
- padding: 2px 10px 10px;
- overflow: auto;
- }
- .result-tree-table {
- .ant-table-thead > tr > th {
- padding: 6px 8px;
- font-size: 12px;
- font-weight: 600;
- color: #6b7280;
- background: #fafbfc;
- border-bottom: 1px solid #eef2f7;
- }
- .ant-table-tbody > tr > td {
- padding: 6px 8px;
- font-size: 13px;
- border-bottom: 1px solid #f6f8fa;
- }
- .ant-table-tbody > tr:hover > td {
- background: #f8fafc;
- }
- .ant-table-row-expand-icon {
- margin-right: 6px;
- border-radius: 3px;
- }
- /* 无子集的行:隐藏展开占位图标(样式块非 scoped,直接用普通选择器) */
- .ant-table-row-expand-icon-spaced {
- display: none;
- }
- }
- .cell-name {
- color: #1e293b;
- font-size: 13px;
- &.is-child {
- color: #475569;
- }
- }
- .cell-actions {
- display: inline-flex;
- align-items: center;
- gap: 2px;
- .action-link {
- padding: 0 4px;
- height: 24px;
- font-size: 12px;
- color: #2563eb;
- &:hover {
- color: #1d4ed8;
- }
- &.is-danger {
- color: #dc2626;
- &:hover {
- color: #b91c1c;
- }
- }
- }
- }
- .status-tag {
- display: inline-flex;
- align-items: center;
- padding: 2px 10px;
- border-radius: 999px;
- font-size: 12px;
- font-weight: 600;
- white-space: nowrap;
- &.is-success {
- background: #ecfdf3;
- color: #15803d;
- }
- &.is-fail {
- background: #fef2f2;
- color: #dc2626;
- }
- &.is-other {
- background: #f1f5f9;
- color: #64748b;
- }
- }
- .upload-picker {
- flex: 0 0 auto;
- display: flex;
- justify-content: center;
- }
- .upload-mode-switch {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- width: fit-content;
- padding: 3px;
- border-radius: 8px;
- background: #f1f5f9;
- border: 1px solid #e2e8f0;
- }
- .upload-mode-switch__item {
- min-width: 92px;
- height: 28px;
- padding: 0 12px;
- border: none;
- border-radius: 6px;
- background: transparent;
- color: #64748b;
- font-size: 12px;
- font-weight: 600;
- cursor: pointer;
- transition:
- background-color 0.2s ease,
- color 0.2s ease;
- &.is-active {
- background: #2563eb;
- color: #fff;
- }
- &:disabled {
- cursor: not-allowed;
- opacity: 0.6;
- }
- }
- .upload-dropzone {
- flex: 0 0 auto;
- min-height: 96px;
- border-radius: 8px;
- border: 1px dashed #cbd5e1;
- background: #fafbfc;
- transition: all 0.2s ease;
- cursor: pointer;
- &:hover,
- &.is-dragover {
- border-color: #3b82f6;
- }
- }
- .upload-dragger-content {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 4px;
- padding: 12px 0;
- color: #475569;
- }
- .upload-dragger-icon {
- width: 28px;
- height: 28px;
- border-radius: 8px;
- background: rgba(59, 130, 246, 0.1);
- color: #2563eb;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 14px;
- }
- .upload-dragger-title {
- font-size: 13px;
- font-weight: 600;
- color: #334155;
- }
- .upload-dragger-desc {
- font-size: 12px;
- color: #64748b;
- max-width: 420px;
- text-align: center;
- }
- .upload-action-btn {
- margin-top: 2px;
- height: 26px;
- padding: 0 14px;
- border-radius: 6px;
- }
- .upload-file-list {
- flex: 1 1 auto;
- min-height: 0;
- display: flex;
- flex-direction: column;
- border: 1px solid #e2e8f0;
- border-radius: 12px;
- background: #ffffff;
- overflow: hidden;
- &__header {
- flex-shrink: 0;
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 8px 12px;
- background: #f8fafc;
- border-bottom: 1px solid #e2e8f0;
- font-size: 13px;
- color: #475569;
- font-weight: 500;
- }
- &__body {
- flex: 1 1 auto;
- min-height: 0;
- overflow-y: auto;
- padding: 4px 0;
- }
- &--empty {
- flex: 1 1 auto;
- border-style: dashed;
- background: #fafafa;
- }
- }
- .upload-empty {
- margin: auto;
- padding: 24px 12px;
- color: #9ca3af;
- font-size: 13px;
- text-align: center;
- }
- .upload-file-item {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 6px 12px;
- gap: 12px;
- transition: background 0.15s ease;
- &:hover {
- background: #f8fafc;
- }
- &__info {
- display: flex;
- align-items: center;
- gap: 10px;
- min-width: 0;
- flex: 1;
- }
- &__name {
- font-size: 13px;
- color: #1e293b;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- max-width: 300px;
- }
- &__size {
- font-size: 12px;
- color: #94a3b8;
- flex-shrink: 0;
- }
- &__status {
- flex-shrink: 0;
- font-size: 12px;
- min-width: 48px;
- text-align: right;
- }
- &__remove {
- flex-shrink: 0;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 22px;
- height: 22px;
- padding: 0;
- border: none;
- border-radius: 6px;
- background: transparent;
- color: #9ca3af;
- font-size: 11px;
- cursor: pointer;
- transition:
- background-color 0.15s ease,
- color 0.15s ease;
- &:hover {
- background: #fef2f2;
- color: #dc2626;
- }
- }
- &__error {
- color: #dc2626;
- font-weight: 600;
- }
- &__progress-text {
- color: #2563eb;
- font-weight: 500;
- }
- &__pending {
- color: #94a3b8;
- }
- }
- .upload-overall-progress {
- flex-shrink: 0;
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 8px 12px;
- border-top: 1px solid #e2e8f0;
- &__bar {
- flex: 1;
- height: 5px;
- border-radius: 3px;
- background: #e2e8f0;
- overflow: hidden;
- }
- &__fill {
- height: 100%;
- border-radius: 3px;
- background: #2563eb;
- transition: width 0.3s ease;
- }
- &__text {
- font-size: 12px;
- color: #475569;
- font-weight: 500;
- white-space: nowrap;
- }
- }
- @media (max-width: 1080px) {
- .upload-layout {
- flex-direction: column;
- }
- .left-panel {
- flex: 0 0 auto;
- width: auto;
- }
- }
- }
- html[data-theme='dark'] {
- .@{prefix-cls}.case-upload-page {
- background: #0f172a;
- .page-title,
- .panel-title,
- .upload-dragger-title,
- .upload-file-item__name,
- .cell-name {
- color: #e5e7eb;
- }
- .page-subtitle,
- .panel-desc,
- .panel-stats,
- .upload-dragger-desc,
- .upload-file-item__size,
- .upload-file-item__pending {
- color: #9ca3af;
- }
- .left-panel,
- .right-panel,
- .upload-file-list {
- background: #111827;
- border-color: #1f2937;
- }
- .panel-header,
- .upload-file-list__header,
- .upload-overall-progress {
- border-color: #1f2937;
- }
- .upload-file-list__header {
- background: #0f172a;
- color: #cbd5e1;
- }
- .upload-file-item:hover {
- background: #1f2937;
- }
- .upload-dropzone {
- border-color: #334155;
- background: #0f172a;
- }
- .upload-mode-switch {
- background: #1e293b;
- border-color: #334155;
- }
- .upload-mode-switch__item {
- color: #94a3b8;
- }
- .upload-overall-progress__bar {
- background: #1f2937;
- }
- .status-tag.is-other {
- background: #1e293b;
- color: #94a3b8;
- }
- .cell-name.is-child {
- color: #cbd5e1;
- }
- .result-tree-table {
- :deep(.ant-table-thead > tr > th) {
- background: #0f172a;
- color: #94a3b8;
- border-bottom-color: #1f2937;
- }
- :deep(.ant-table-tbody > tr > td) {
- border-bottom-color: #111827;
- }
- :deep(.ant-table-tbody > tr:hover > td) {
- background: #1f2937;
- }
- }
- }
- }
- </style>
|