From 12b7e3178324ebfddbea50f41807ad9fe322dd4b Mon Sep 17 00:00:00 2001 From: mangmang <362165265@qq.com> Date: Sun, 19 Jul 2026 19:49:05 +0800 Subject: [PATCH] Complete frontend console redesign --- frontend/src/App.vue | 15 +- frontend/src/components/IngestTab.vue | 72 +- frontend/src/components/SettingsTab.vue | 1020 ++++++++++++++++---- frontend/src/components/common/AppIcon.vue | 19 +- frontend/src/composables/useAppState.ts | 127 ++- frontend/src/composables/usePaths.ts | 44 +- frontend/src/styles/theme.css | 14 + 7 files changed, 1097 insertions(+), 214 deletions(-) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index fc15845..8e9555f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -13,7 +13,7 @@ aria-label="切换导航菜单" @click="drawerOpen = !drawerOpen" > - + @@ -159,7 +159,14 @@ const drawerOpen = ref(false); const activeMeta = computed(() => navMap[activeKey.value] ?? navItems[0]); const currentComponent = computed(() => activeMeta.value.component); -const { state: appState, refreshConfig, startHealthPolling, stopHealthPolling } = useAppState(); +const { + state: appState, + refreshConfig, + startHealthPolling, + stopHealthPolling, + startIngestStatusSync, + stopIngestStatusSync +} = useAppState(); const rootPathDisplay = computed(() => appState.config.basePath || '未配置工作根目录'); @@ -182,6 +189,8 @@ function selectNav(key: NavKey) { onMounted(() => { startHealthPolling(); + // 外壳持有导入任务状态同步:离开导入页后仍能如实更新导航指示灯。 + startIngestStatusSync(); // 顶部条与侧边栏需要真实 basePath,主动拉取一次(各面板也会各自刷新)。 void refreshConfig().catch(() => { /* 配置加载失败不阻塞外壳渲染,各面板会显示未配置状态 */ @@ -190,6 +199,7 @@ onMounted(() => { onUnmounted(() => { stopHealthPolling(); + stopIngestStatusSync(); }); @@ -197,6 +207,7 @@ onUnmounted(() => { .console-shell { display: flex; width: 100%; + max-width: 100vw; height: 100vh; height: 100dvh; overflow: hidden; diff --git a/frontend/src/components/IngestTab.vue b/frontend/src/components/IngestTab.vue index 4003180..426e9cc 100644 --- a/frontend/src/components/IngestTab.vue +++ b/frontend/src/components/IngestTab.vue @@ -115,7 +115,7 @@ :disabled="!canStart" @click="startIngest" > - + 执行一键入库 { return Math.min(100, Math.round((progress.processed / progress.total) * 100)); }); -/** 被拒绝类别之和(不含已入库)。 */ +/** + * 被拒绝类别之和(不含已入库、不含重复跳过)。 + * 重复文件是设计上的跳过,不计入拒绝/部分失败。 + */ const totalRejected = computed(() => - (progress.duplicateFiles ?? 0) - + (progress.missingMetadataFiles ?? 0) + (progress.missingMetadataFiles ?? 0) + (progress.unreadableFiles ?? 0) + (progress.conversionFailedFiles ?? 0) + (progress.otherRejectedFiles ?? 0) @@ -393,7 +402,11 @@ const resultText = computed(() => { if (totalRejected.value > 0) { return `导入完成:成功入库 ${progress.ingestedFiles ?? 0} 首,${totalRejected.value} 首被拒绝`; } - return `导入完成:全部 ${progress.processed ?? 0} 首成功入库`; + const dups = progress.duplicateFiles ?? 0; + if (dups > 0) { + return `导入完成:成功入库 ${progress.ingestedFiles ?? 0} 首,${dups} 首重复跳过`; + } + return `导入完成:全部 ${progress.ingestedFiles ?? progress.processed ?? 0} 首成功入库`; }); /* ---------------- 活动记录(仅由真实状态转移构建) ---------------- */ @@ -439,9 +452,20 @@ function classifyLevel(msg: ProgressMessage): { level: ActivityLevel; badge: str if (text.includes('损坏') || text.includes('不可读') || text.includes('转码失败')) { return { level: 'error', badge: '[错误]' }; } + // 重复/跳过按设计是警告级事件记录,但不计入完成态「拒绝」统计。 if (text.includes('重复') || text.includes('跳过') || text.includes('缺') || text.includes('拒绝')) { return { level: 'warning', badge: '[警告]' }; } + if (msg.completed) { + // 完成分类仅看真实拒绝类别,不含 duplicateFiles。 + const rejected = + (msg.missingMetadataFiles ?? 0) + + (msg.unreadableFiles ?? 0) + + (msg.conversionFailedFiles ?? 0) + + (msg.otherRejectedFiles ?? 0); + if (rejected > 0) return { level: 'warning', badge: '[部分]' }; + return { level: 'normal', badge: '[完成]' }; + } return { level: 'normal', badge: '[进行]' }; } @@ -678,6 +702,9 @@ async function copyPath(text: string) { /* ---------------- 生命周期 ---------------- */ onMounted(async () => { + // 接管细粒度进度源,暂停外壳后台监视(避免双重 HTTP 轮询)。 + claimIngestViewOwnership(); + // 若外壳尚未加载配置,则此处兜底加载真值。 if (!appState.config.attempted) { await reloadConfig(); @@ -688,8 +715,8 @@ onMounted(async () => { onUnmounted(() => { wsDisconnect(); stopPolling(); - // 组件卸载不代表任务停止,但本视图不再持有运行指示。 - setIngestRunning(false); + // 交还所有权:若任务仍在跑,由外壳后台监视继续同步真实完成态。 + releaseIngestViewOwnership(submitting.value); }); @@ -1476,5 +1503,32 @@ onUnmounted(() => { .stats-secondary { grid-template-columns: 1fr; } + + .console-head { + flex-direction: column; + align-items: flex-start; + } + + .filter-group { + flex-wrap: wrap; + } +} + +@media (max-width: 390px) { + .ingest-view { + gap: 14px; + } + + .panel { + padding: 14px; + } + + .stat-num { + font-size: 16px; + } + + .log-line { + flex-wrap: wrap; + } } diff --git a/frontend/src/components/SettingsTab.vue b/frontend/src/components/SettingsTab.vue index 761ee52..c9046a8 100644 --- a/frontend/src/components/SettingsTab.vue +++ b/frontend/src/components/SettingsTab.vue @@ -1,250 +1,880 @@ - - - - - - - + + 全局配置与路径设置 + + 规划底层工作区的挂载主根目录,并对各子通道所派生的标准管道路径进行集中管控。 + + + + + + + + 基础挂载中心 + 未保存 + + + + + + 工作根目录 (BASEPATH) * + + 双向实时更新 + + + + + + + + + 恢复已加载 + + + + {{ validationError }} + + + + + + + + 数据管道数据流向模型 + Active Pipe + + + + + 1. 待整入 Input + 外部待理音频 + + → + + 2. 校验 / 转码 / 去重 + 完整性与指纹 + + → + + 3. 归档 Library + 高保真规整库 + + / + + 4. 废品 Rejected + 破损 / 缺元数据 + + + + + + 衍生工作子存储节点(简化模型) + + + Input + + {{ preview.input || '未配置' }} + + + + Library + + {{ preview.library || '未配置' }} + + + + Rejected + + {{ preview.rejected || '未配置' }} + + + + + + + + + 派生子存储路径(旧版向后兼容) + COMPAT + + + + + {{ item.name }} + {{ item.tag }} + + + {{ item.path || '未配置' }} + + + + + + + + diff --git a/frontend/src/components/common/AppIcon.vue b/frontend/src/components/common/AppIcon.vue index a21d000..52e4ec7 100644 --- a/frontend/src/components/common/AppIcon.vue +++ b/frontend/src/components/common/AppIcon.vue @@ -114,6 +114,12 @@ const ICONS: Record = { paths: [ 'M13 10V3L4 14h7v7l9-11h-7z' ] + }, + menu: { + paths: ['M4 6h16M4 12h16M4 18h16'] + }, + close: { + paths: ['M6 18L18 6M6 6l12 12'] } }; @@ -121,6 +127,8 @@ interface Props { name: keyof typeof ICONS | string; size?: number | string; strokeWidth?: number | string; + /** 覆盖图标定义:true 实心填充,false 描边。未传则使用图标内置定义。 */ + filled?: boolean; } const props = withDefaults(defineProps(), { @@ -131,13 +139,22 @@ const props = withDefaults(defineProps(), { const def = computed(() => ICONS[props.name] ?? ICONS.info); const paths = computed(() => def.value.paths); const circles = computed(() => def.value.circles ?? []); -const filled = computed(() => def.value.filled ?? false); +const filled = computed(() => + typeof props.filled === 'boolean' ? props.filled : (def.value.filled ?? false) +); const viewBox = computed(() => def.value.viewBox ?? '0 0 24 24'); +const sizePx = computed(() => + typeof props.size === 'number' ? `${props.size}px` : String(props.size) +); diff --git a/frontend/src/composables/useAppState.ts b/frontend/src/composables/useAppState.ts index 7a0f5e2..0128b3f 100644 --- a/frontend/src/composables/useAppState.ts +++ b/frontend/src/composables/useAppState.ts @@ -2,6 +2,7 @@ import { computed, reactive } from 'vue'; import { getHealth } from '../api/health'; import { getConfig } from '../api/config'; import type { ConfigResponse } from '../api/config'; +import { getIngestCurrentStatus } from '../api/ingest'; /** * 轻量级全局状态:在应用外壳、一键导入、全局配置之间共享后端真实数据。 @@ -27,7 +28,7 @@ interface AppConfigState { interface AppState { health: HealthStatus; config: AppConfigState; - /** 导入任务是否正在运行(由一键导入面板依据真实任务状态写入)。 */ + /** 导入任务是否正在运行(真实任务状态,外壳与导入页共享)。 */ ingestRunning: boolean; /** 导入任务真实百分比(0-100)。 */ ingestPercentage: number; @@ -82,12 +83,18 @@ async function refreshConfig(): Promise { function setIngestRunning(running: boolean): void { state.ingestRunning = running; + if (running) { + // 导入页或其它入口声明任务在跑时,确保外壳后台监视已启动。 + ensureIngestBackgroundWatch(); + } } function setIngestPercentage(percentage: number): void { state.ingestPercentage = Math.max(0, Math.min(100, Math.round(percentage))); } +/* ---------------- 健康轮询 ---------------- */ + let healthTimer: ReturnType | null = null; let healthConsumers = 0; @@ -114,15 +121,131 @@ function stopHealthPolling(): void { } } +/* ---------------- 导入任务后台状态同步(外壳持有,避免离开导入页后指示灯过期) ---------------- */ + +/** + * 后台 HTTP 监视器:仅在导入页卸载且任务仍在跑时由外壳接管, + * 或应用启动时发现有进行中的任务。导入页挂载期间会 pause, + * 由导入页自己的 WS/轮询负责细粒度进度,避免双重轮询。 + */ +let ingestBgTimer: ReturnType | null = null; +/** 导入页是否正持有细粒度进度源(挂载中)。为 true 时后台监视暂停。 */ +let ingestViewOwned = false; +let ingestBgConsumers = 0; + +function applyIngestStatus(resp: { + running: boolean; + completed: boolean; + total: number; + processed: number; +}): void { + state.ingestRunning = !!resp.running; + if (resp.completed) { + state.ingestPercentage = 100; + } else if (resp.total > 0) { + state.ingestPercentage = Math.min( + 100, + Math.round((resp.processed / resp.total) * 100) + ); + } +} + +/** + * 拉取一次当前导入任务真值并更新外壳指示灯。 + * 网络错误时不改写既有状态,避免误闪。 + */ +async function refreshIngestStatus(): Promise { + try { + const resp = await getIngestCurrentStatus(); + if (!resp) { + state.ingestRunning = false; + stopIngestBackgroundWatch(); + return; + } + applyIngestStatus(resp); + if (resp.running) { + ensureIngestBackgroundWatch(); + } else { + stopIngestBackgroundWatch(); + } + } catch { + // 保留上次已知状态 + } +} + +function ensureIngestBackgroundWatch(): void { + // 导入页持有时不启动后台轮询,避免与页内轮询重复。 + if (ingestViewOwned) return; + if (ingestBgConsumers <= 0) return; + if (ingestBgTimer !== null) return; + ingestBgTimer = setInterval(() => { + void refreshIngestStatus(); + }, 2000); +} + +function stopIngestBackgroundWatch(): void { + if (ingestBgTimer !== null) { + clearInterval(ingestBgTimer); + ingestBgTimer = null; + } +} + +/** + * 外壳挂载时调用:引用计数 + 立即查一次当前任务。 + * 若发现 running,则启动后台轮询直到完成。 + */ +function startIngestStatusSync(): void { + ingestBgConsumers += 1; + void refreshIngestStatus(); +} + +/** 外壳卸载时调用。 */ +function stopIngestStatusSync(): void { + ingestBgConsumers = Math.max(0, ingestBgConsumers - 1); + if (ingestBgConsumers === 0) { + stopIngestBackgroundWatch(); + } +} + +/** + * 导入页挂载:接管进度源,暂停外壳后台轮询(避免双重 HTTP 轮询)。 + * 导入页自己的 WS/轮询负责细粒度更新,并通过 setIngestRunning 回写外壳。 + */ +function claimIngestViewOwnership(): void { + ingestViewOwned = true; + stopIngestBackgroundWatch(); +} + +/** + * 导入页卸载:交还所有权。若任务仍在跑且外壳仍存活,恢复后台监视。 + */ +function releaseIngestViewOwnership(stillRunning: boolean): void { + ingestViewOwned = false; + if (stillRunning) { + state.ingestRunning = true; + ensureIngestBackgroundWatch(); + // 立即校准一次,避免等待下一个 interval。 + void refreshIngestStatus(); + } else { + state.ingestRunning = false; + stopIngestBackgroundWatch(); + } +} + export function useAppState() { return { state, isConfigured, refreshHealth, refreshConfig, + refreshIngestStatus, setIngestRunning, setIngestPercentage, startHealthPolling, - stopHealthPolling + stopHealthPolling, + startIngestStatusSync, + stopIngestStatusSync, + claimIngestViewOwnership, + releaseIngestViewOwnership }; } diff --git a/frontend/src/composables/usePaths.ts b/frontend/src/composables/usePaths.ts index ceb3588..cd9130a 100644 --- a/frontend/src/composables/usePaths.ts +++ b/frontend/src/composables/usePaths.ts @@ -4,19 +4,35 @@ * 前端仅用于「预览」派生路径,实际创建目录由后端完成。为了与后端 * PathUtils.joinPath 的行为保持一致,这里统一: * - 将反斜杠 \ 归一化为正斜杠 / - * - 去除父路径末尾多余的分隔符 + * - 去除父路径末尾多余的分隔符(但保留文件系统根 `/` 与 Windows 盘符根) * - 去除子路径开头多余的分隔符 * - 以单个 / 连接 * * 注意:这只是显示层的规范化,不做真实文件系统解析。 */ -/** 归一化根路径:转 POSIX 分隔符并去掉结尾斜杠。 */ +/** 归一化根路径:转 POSIX 分隔符;去掉结尾斜杠,但保留 `/` 与 `C:` 形式的根。 */ export function normalizeRoot(raw: string): string { if (!raw) { return ''; } - return raw.replace(/\\/g, '/').replace(/\/+$/, ''); + const posix = raw.replace(/\\/g, '/'); + + // 纯根斜杠:'/'、'//' → '/' + if (/^\/+$/.test(posix)) { + return '/'; + } + + // Windows 盘符根:'C:'、'C:/'、'C://' → 'C:' + const driveRoot = posix.match(/^([A-Za-z]:)\/+$/); + if (driveRoot) { + return driveRoot[1]; + } + if (/^[A-Za-z]:$/.test(posix)) { + return posix; + } + + return posix.replace(/\/+$/, ''); } /** 以规范化方式拼接父/子路径,返回 POSIX 形式字符串。 */ @@ -29,20 +45,38 @@ export function joinPath(parent: string, child: string): string { return parentPosix; } const childPosix = child.replace(/\\/g, '/').replace(/^\/+/, ''); + + // 文件系统根:'/' + 'Input' → '/Input' + if (parentPosix === '/') { + return `/${childPosix}`; + } + return `${parentPosix}/${childPosix}`; } /** * 校验路径是否合法(与后端 PathUtils.isValidPath 对齐)。 * Windows 盘符后的冒号是合法的,其余情况下不允许出现 < > " | ? *。 + * 文件系统根 `/` 与 Windows 盘符根(`C:` / `C:/`)视为合法。 */ export function isValidPath(path: string): boolean { if (!path || !path.trim()) { return false; } - let pathToValidate = path.trim(); - if (/^[A-Za-z]:[/\\]/.test(pathToValidate)) { + let pathToValidate = path.trim().replace(/\\/g, '/'); + + // POSIX 根 + if (pathToValidate === '/' || /^\/+$/.test(pathToValidate)) { + return true; + } + // Windows 盘符根 + if (/^[A-Za-z]:\/?$/.test(pathToValidate)) { + return true; + } + // 去掉 Windows 盘符再检查非法字符 + if (/^[A-Za-z]:\//.test(pathToValidate)) { pathToValidate = pathToValidate.substring(2); } + return !/[<>"|?*]/.test(pathToValidate); } diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index dc87128..955d1d5 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -91,6 +91,7 @@ html, body { background: var(--c-bg-root); + overflow-x: hidden; } body { @@ -103,6 +104,8 @@ body { #app { isolation: isolate; + min-height: 100%; + overflow-x: hidden; } /* Element Plus 暗色控件适配(局部、克制的覆盖) */ @@ -169,6 +172,17 @@ body { border-radius: 4px; } +/* 通用旋转指示(refresh 图标等) */ +.spin { + animation: c-spin 0.85s linear infinite; +} + +@keyframes c-spin { + to { + transform: rotate(360deg); + } +} + @media (prefers-reduced-motion: reduce) { *, *::before,
+ 规划底层工作区的挂载主根目录,并对各子通道所派生的标准管道路径进行集中管控。 +
{{ validationError }}