Template
Complete frontend console redesign
This commit is contained in:
+13
-2
@@ -13,7 +13,7 @@
|
||||
aria-label="切换导航菜单"
|
||||
@click="drawerOpen = !drawerOpen"
|
||||
>
|
||||
<app-icon :name="drawerOpen ? 'reject' : 'chart'" :size="18" />
|
||||
<app-icon :name="drawerOpen ? 'close' : 'menu'" :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -197,6 +207,7 @@ onUnmounted(() => {
|
||||
.console-shell {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
:disabled="!canStart"
|
||||
@click="startIngest"
|
||||
>
|
||||
<app-icon name="play" :size="16" filled />
|
||||
<app-icon name="play" :size="16" :filled="true" />
|
||||
<span>执行一键入库</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -288,14 +288,21 @@
|
||||
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import AppIcon from './common/AppIcon.vue';
|
||||
import { getConfig } from '../api/config';
|
||||
import { startIngest as apiStartIngest, getIngestStatus, getIngestCurrentStatus } from '../api/ingest';
|
||||
import type { IngestStatusResponse } from '../api/ingest';
|
||||
import { useWebSocket } from '../composables/useWebSocket';
|
||||
import type { ProgressMessage } from '../composables/useWebSocket';
|
||||
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 configLoading = ref(false);
|
||||
@@ -330,10 +337,12 @@ const percentage = computed(() => {
|
||||
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);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -114,6 +114,12 @@ const ICONS: Record<string, IconDef> = {
|
||||
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<Props>(), {
|
||||
@@ -131,13 +139,22 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
const def = computed<IconDef>(() => 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)
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-icon {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
width: v-bind(sizePx);
|
||||
height: v-bind(sizePx);
|
||||
max-width: 48px;
|
||||
max-height: 48px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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<ConfigResponse | null> {
|
||||
|
||||
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<typeof setInterval> | null = null;
|
||||
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() {
|
||||
return {
|
||||
state,
|
||||
isConfigured,
|
||||
refreshHealth,
|
||||
refreshConfig,
|
||||
refreshIngestStatus,
|
||||
setIngestRunning,
|
||||
setIngestPercentage,
|
||||
startHealthPolling,
|
||||
stopHealthPolling
|
||||
stopHealthPolling,
|
||||
startIngestStatusSync,
|
||||
stopIngestStatusSync,
|
||||
claimIngestViewOwnership,
|
||||
releaseIngestViewOwnership
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user