Template
Complete frontend console redesign
This commit is contained in:
+13
-2
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user