Complete frontend console redesign

This commit is contained in:
2026-07-19 19:49:05 +08:00
parent 1b1d983060
commit 12b7e31783
7 changed files with 1097 additions and 214 deletions
+13 -2
View File
@@ -13,7 +13,7 @@
aria-label="切换导航菜单" aria-label="切换导航菜单"
@click="drawerOpen = !drawerOpen" @click="drawerOpen = !drawerOpen"
> >
<app-icon :name="drawerOpen ? 'reject' : 'chart'" :size="18" /> <app-icon :name="drawerOpen ? 'close' : 'menu'" :size="18" />
</button> </button>
</div> </div>
@@ -159,7 +159,14 @@ const drawerOpen = ref(false);
const activeMeta = computed(() => navMap[activeKey.value] ?? navItems[0]); const activeMeta = computed(() => navMap[activeKey.value] ?? navItems[0]);
const currentComponent = computed(() => activeMeta.value.component); 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 || '未配置工作根目录'); const rootPathDisplay = computed(() => appState.config.basePath || '未配置工作根目录');
@@ -182,6 +189,8 @@ function selectNav(key: NavKey) {
onMounted(() => { onMounted(() => {
startHealthPolling(); startHealthPolling();
// 外壳持有导入任务状态同步:离开导入页后仍能如实更新导航指示灯。
startIngestStatusSync();
// 顶部条与侧边栏需要真实 basePath,主动拉取一次(各面板也会各自刷新)。 // 顶部条与侧边栏需要真实 basePath,主动拉取一次(各面板也会各自刷新)。
void refreshConfig().catch(() => { void refreshConfig().catch(() => {
/* 配置加载失败不阻塞外壳渲染,各面板会显示未配置状态 */ /* 配置加载失败不阻塞外壳渲染,各面板会显示未配置状态 */
@@ -190,6 +199,7 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
stopHealthPolling(); stopHealthPolling();
stopIngestStatusSync();
}); });
</script> </script>
@@ -197,6 +207,7 @@ onUnmounted(() => {
.console-shell { .console-shell {
display: flex; display: flex;
width: 100%; width: 100%;
max-width: 100vw;
height: 100vh; height: 100vh;
height: 100dvh; height: 100dvh;
overflow: hidden; overflow: hidden;
+63 -9
View File
@@ -115,7 +115,7 @@
:disabled="!canStart" :disabled="!canStart"
@click="startIngest" @click="startIngest"
> >
<app-icon name="play" :size="16" filled /> <app-icon name="play" :size="16" :filled="true" />
<span>执行一键入库</span> <span>执行一键入库</span>
</button> </button>
<button <button
@@ -288,14 +288,21 @@
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'; import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import AppIcon from './common/AppIcon.vue'; import AppIcon from './common/AppIcon.vue';
import { getConfig } from '../api/config';
import { startIngest as apiStartIngest, getIngestStatus, getIngestCurrentStatus } from '../api/ingest'; import { startIngest as apiStartIngest, getIngestStatus, getIngestCurrentStatus } from '../api/ingest';
import type { IngestStatusResponse } from '../api/ingest'; import type { IngestStatusResponse } from '../api/ingest';
import { useWebSocket } from '../composables/useWebSocket'; import { useWebSocket } from '../composables/useWebSocket';
import type { ProgressMessage } from '../composables/useWebSocket'; import type { ProgressMessage } from '../composables/useWebSocket';
import { useAppState } from '../composables/useAppState'; import { useAppState } from '../composables/useAppState';
const { state: appState, isConfigured, refreshConfig, setIngestRunning, setIngestPercentage } = useAppState(); const {
state: appState,
isConfigured,
refreshConfig,
setIngestRunning,
setIngestPercentage,
claimIngestViewOwnership,
releaseIngestViewOwnership
} = useAppState();
const submitting = ref(false); const submitting = ref(false);
const configLoading = ref(false); const configLoading = ref(false);
@@ -330,10 +337,12 @@ const percentage = computed(() => {
return Math.min(100, Math.round((progress.processed / progress.total) * 100)); return Math.min(100, Math.round((progress.processed / progress.total) * 100));
}); });
/** 被拒绝类别之和(不含已入库)。 */ /**
* 被拒绝类别之和(不含已入库、不含重复跳过)。
* 重复文件是设计上的跳过,不计入拒绝/部分失败。
*/
const totalRejected = computed(() => const totalRejected = computed(() =>
(progress.duplicateFiles ?? 0) (progress.missingMetadataFiles ?? 0)
+ (progress.missingMetadataFiles ?? 0)
+ (progress.unreadableFiles ?? 0) + (progress.unreadableFiles ?? 0)
+ (progress.conversionFailedFiles ?? 0) + (progress.conversionFailedFiles ?? 0)
+ (progress.otherRejectedFiles ?? 0) + (progress.otherRejectedFiles ?? 0)
@@ -393,7 +402,11 @@ const resultText = computed(() => {
if (totalRejected.value > 0) { if (totalRejected.value > 0) {
return `导入完成:成功入库 ${progress.ingestedFiles ?? 0} 首,${totalRejected.value} 首被拒绝`; 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('转码失败')) { if (text.includes('损坏') || text.includes('不可读') || text.includes('转码失败')) {
return { level: 'error', badge: '[错误]' }; return { level: 'error', badge: '[错误]' };
} }
// 重复/跳过按设计是警告级事件记录,但不计入完成态「拒绝」统计。
if (text.includes('重复') || text.includes('跳过') || text.includes('缺') || text.includes('拒绝')) { if (text.includes('重复') || text.includes('跳过') || text.includes('缺') || text.includes('拒绝')) {
return { level: 'warning', badge: '[警告]' }; 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: '[进行]' }; return { level: 'normal', badge: '[进行]' };
} }
@@ -678,6 +702,9 @@ async function copyPath(text: string) {
/* ---------------- 生命周期 ---------------- */ /* ---------------- 生命周期 ---------------- */
onMounted(async () => { onMounted(async () => {
// 接管细粒度进度源,暂停外壳后台监视(避免双重 HTTP 轮询)。
claimIngestViewOwnership();
// 若外壳尚未加载配置,则此处兜底加载真值。 // 若外壳尚未加载配置,则此处兜底加载真值。
if (!appState.config.attempted) { if (!appState.config.attempted) {
await reloadConfig(); await reloadConfig();
@@ -688,8 +715,8 @@ onMounted(async () => {
onUnmounted(() => { onUnmounted(() => {
wsDisconnect(); wsDisconnect();
stopPolling(); stopPolling();
// 组件卸载不代表任务停止,但本视图不再持有运行指示 // 交还所有权:若任务仍在跑,由外壳后台监视继续同步真实完成态
setIngestRunning(false); releaseIngestViewOwnership(submitting.value);
}); });
</script> </script>
@@ -1476,5 +1503,32 @@ onUnmounted(() => {
.stats-secondary { .stats-secondary {
grid-template-columns: 1fr; 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;
}
} }
</style> </style>
File diff suppressed because it is too large Load Diff
+18 -1
View File
@@ -114,6 +114,12 @@ const ICONS: Record<string, IconDef> = {
paths: [ paths: [
'M13 10V3L4 14h7v7l9-11h-7z' '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; name: keyof typeof ICONS | string;
size?: number | string; size?: number | string;
strokeWidth?: number | string; strokeWidth?: number | string;
/** 覆盖图标定义:true 实心填充,false 描边。未传则使用图标内置定义。 */
filled?: boolean;
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -131,13 +139,22 @@ const props = withDefaults(defineProps<Props>(), {
const def = computed<IconDef>(() => ICONS[props.name] ?? ICONS.info); const def = computed<IconDef>(() => ICONS[props.name] ?? ICONS.info);
const paths = computed(() => def.value.paths); const paths = computed(() => def.value.paths);
const circles = computed(() => def.value.circles ?? []); 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 viewBox = computed(() => def.value.viewBox ?? '0 0 24 24');
const sizePx = computed(() =>
typeof props.size === 'number' ? `${props.size}px` : String(props.size)
);
</script> </script>
<style scoped> <style scoped>
.app-icon { .app-icon {
display: block; display: block;
flex-shrink: 0; flex-shrink: 0;
width: v-bind(sizePx);
height: v-bind(sizePx);
max-width: 48px;
max-height: 48px;
} }
</style> </style>
+125 -2
View File
@@ -2,6 +2,7 @@ import { computed, reactive } from 'vue';
import { getHealth } from '../api/health'; import { getHealth } from '../api/health';
import { getConfig } from '../api/config'; import { getConfig } from '../api/config';
import type { ConfigResponse } from '../api/config'; import type { ConfigResponse } from '../api/config';
import { getIngestCurrentStatus } from '../api/ingest';
/** /**
* 轻量级全局状态:在应用外壳、一键导入、全局配置之间共享后端真实数据。 * 轻量级全局状态:在应用外壳、一键导入、全局配置之间共享后端真实数据。
@@ -27,7 +28,7 @@ interface AppConfigState {
interface AppState { interface AppState {
health: HealthStatus; health: HealthStatus;
config: AppConfigState; config: AppConfigState;
/** 导入任务是否正在运行(由一键导入面板依据真实任务状态写入)。 */ /** 导入任务是否正在运行(真实任务状态,外壳与导入页共享)。 */
ingestRunning: boolean; ingestRunning: boolean;
/** 导入任务真实百分比(0-100)。 */ /** 导入任务真实百分比(0-100)。 */
ingestPercentage: number; ingestPercentage: number;
@@ -82,12 +83,18 @@ async function refreshConfig(): Promise<ConfigResponse | null> {
function setIngestRunning(running: boolean): void { function setIngestRunning(running: boolean): void {
state.ingestRunning = running; state.ingestRunning = running;
if (running) {
// 导入页或其它入口声明任务在跑时,确保外壳后台监视已启动。
ensureIngestBackgroundWatch();
}
} }
function setIngestPercentage(percentage: number): void { function setIngestPercentage(percentage: number): void {
state.ingestPercentage = Math.max(0, Math.min(100, Math.round(percentage))); state.ingestPercentage = Math.max(0, Math.min(100, Math.round(percentage)));
} }
/* ---------------- 健康轮询 ---------------- */
let healthTimer: ReturnType<typeof setInterval> | null = null; let healthTimer: ReturnType<typeof setInterval> | null = null;
let healthConsumers = 0; let healthConsumers = 0;
@@ -114,15 +121,131 @@ function stopHealthPolling(): void {
} }
} }
/* ---------------- 导入任务后台状态同步(外壳持有,避免离开导入页后指示灯过期) ---------------- */
/**
* 后台 HTTP 监视器:仅在导入页卸载且任务仍在跑时由外壳接管,
* 或应用启动时发现有进行中的任务。导入页挂载期间会 pause,
* 由导入页自己的 WS/轮询负责细粒度进度,避免双重轮询。
*/
let ingestBgTimer: ReturnType<typeof setInterval> | 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<void> {
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() { export function useAppState() {
return { return {
state, state,
isConfigured, isConfigured,
refreshHealth, refreshHealth,
refreshConfig, refreshConfig,
refreshIngestStatus,
setIngestRunning, setIngestRunning,
setIngestPercentage, setIngestPercentage,
startHealthPolling, startHealthPolling,
stopHealthPolling stopHealthPolling,
startIngestStatusSync,
stopIngestStatusSync,
claimIngestViewOwnership,
releaseIngestViewOwnership
}; };
} }
+39 -5
View File
@@ -4,19 +4,35 @@
* 前端仅用于「预览」派生路径,实际创建目录由后端完成。为了与后端 * 前端仅用于「预览」派生路径,实际创建目录由后端完成。为了与后端
* PathUtils.joinPath 的行为保持一致,这里统一: * PathUtils.joinPath 的行为保持一致,这里统一:
* - 将反斜杠 \ 归一化为正斜杠 / * - 将反斜杠 \ 归一化为正斜杠 /
* - 去除父路径末尾多余的分隔符 * - 去除父路径末尾多余的分隔符(但保留文件系统根 `/` 与 Windows 盘符根)
* - 去除子路径开头多余的分隔符 * - 去除子路径开头多余的分隔符
* - 以单个 / 连接 * - 以单个 / 连接
* *
* 注意:这只是显示层的规范化,不做真实文件系统解析。 * 注意:这只是显示层的规范化,不做真实文件系统解析。
*/ */
/** 归一化根路径:转 POSIX 分隔符去掉结尾斜杠。 */ /** 归一化根路径:转 POSIX 分隔符去掉结尾斜杠,但保留 `/` 与 `C:` 形式的根。 */
export function normalizeRoot(raw: string): string { export function normalizeRoot(raw: string): string {
if (!raw) { if (!raw) {
return ''; 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 形式字符串。 */ /** 以规范化方式拼接父/子路径,返回 POSIX 形式字符串。 */
@@ -29,20 +45,38 @@ export function joinPath(parent: string, child: string): string {
return parentPosix; return parentPosix;
} }
const childPosix = child.replace(/\\/g, '/').replace(/^\/+/, ''); const childPosix = child.replace(/\\/g, '/').replace(/^\/+/, '');
// 文件系统根:'/' + 'Input' → '/Input'
if (parentPosix === '/') {
return `/${childPosix}`;
}
return `${parentPosix}/${childPosix}`; return `${parentPosix}/${childPosix}`;
} }
/** /**
* 校验路径是否合法(与后端 PathUtils.isValidPath 对齐)。 * 校验路径是否合法(与后端 PathUtils.isValidPath 对齐)。
* Windows 盘符后的冒号是合法的,其余情况下不允许出现 < > " | ? *。 * Windows 盘符后的冒号是合法的,其余情况下不允许出现 < > " | ? *。
* 文件系统根 `/` 与 Windows 盘符根(`C:` / `C:/`)视为合法。
*/ */
export function isValidPath(path: string): boolean { export function isValidPath(path: string): boolean {
if (!path || !path.trim()) { if (!path || !path.trim()) {
return false; return false;
} }
let pathToValidate = path.trim(); let pathToValidate = path.trim().replace(/\\/g, '/');
if (/^[A-Za-z]:[/\\]/.test(pathToValidate)) {
// 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); pathToValidate = pathToValidate.substring(2);
} }
return !/[<>"|?*]/.test(pathToValidate); return !/[<>"|?*]/.test(pathToValidate);
} }
+14
View File
@@ -91,6 +91,7 @@
html, html,
body { body {
background: var(--c-bg-root); background: var(--c-bg-root);
overflow-x: hidden;
} }
body { body {
@@ -103,6 +104,8 @@ body {
#app { #app {
isolation: isolate; isolation: isolate;
min-height: 100%;
overflow-x: hidden;
} }
/* Element Plus 暗色控件适配(局部、克制的覆盖) */ /* Element Plus 暗色控件适配(局部、克制的覆盖) */
@@ -169,6 +172,17 @@ body {
border-radius: 4px; border-radius: 4px;
} }
/* 通用旋转指示(refresh 图标等) */
.spin {
animation: c-spin 0.85s linear infinite;
}
@keyframes c-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *,
*::before, *::before,