WIP: preserve frontend redesign

This commit is contained in:
2026-07-19 19:31:00 +08:00
parent 7c19234523
commit 1b1d983060
8 changed files with 2345 additions and 528 deletions
+513 -222
View File
@@ -1,315 +1,606 @@
<template>
<el-container class="app-root">
<el-aside width="220px" class="app-aside">
<div class="app-logo">
<span class="app-logo-title">MangTool</span>
<span class="app-logo-sub">音乐工具箱</span>
<div class="console-shell">
<!-- 移动端顶部条品牌 + 抽屉开关 -->
<div class="mobile-topbar">
<div class="brand-mini">
<span class="brand-mark"><app-icon name="logo" :size="18" :stroke-width="2.5" /></span>
<span class="brand-mini-name">MangTool</span>
</div>
<el-menu
:default-active="activeKey"
class="app-menu"
@select="handleSelect"
<button
class="drawer-toggle"
type="button"
:aria-expanded="drawerOpen"
aria-label="切换导航菜单"
@click="drawerOpen = !drawerOpen"
>
<el-menu-item
v-for="tab in visibleTabs"
:key="tab.key"
:index="tab.key"
>
{{ tab.menuLabel }}
</el-menu-item>
</el-menu>
</el-aside>
<app-icon :name="drawerOpen ? 'reject' : 'chart'" :size="18" />
</button>
</div>
<el-container>
<el-header class="app-header">
<div class="app-header-title">
<h1>{{ currentTitle }}</h1>
<p>{{ currentSubtitle }}</p>
<!-- 侧边栏遮罩仅移动端抽屉展开时 -->
<div
v-if="drawerOpen"
class="drawer-scrim"
@click="drawerOpen = false"
></div>
<!-- 左侧固定侧边栏 -->
<aside class="sidebar" :class="{ 'is-open': drawerOpen }">
<div class="sidebar-brand">
<span class="brand-mark"><app-icon name="logo" :size="20" :stroke-width="2.5" /></span>
<div class="brand-text">
<span class="brand-name">MangTool</span>
<span class="brand-sub">音乐工作台</span>
</div>
<div class="app-header-meta">
<span class="app-header-index">{{ currentTab.badge }}</span>
<span class="app-header-helper">执行面板</span>
</div>
</el-header>
<el-main class="app-main">
<nav class="sidebar-nav c-scroll" aria-label="主导航">
<p class="nav-group-label">主控工作区</p>
<button
v-for="item in navItems"
:key="item.key"
type="button"
class="nav-item"
:class="{ 'is-active': activeKey === item.key }"
:aria-current="activeKey === item.key ? 'page' : undefined"
@click="selectNav(item.key)"
>
<span class="nav-item-main">
<app-icon :name="item.icon" :size="16" />
<span>{{ item.label }}</span>
</span>
<span
v-if="item.key === 'ingest' && appState.ingestRunning"
class="nav-dot pulse"
title="导入任务运行中"
></span>
</button>
</nav>
<div class="sidebar-status">
<div class="status-row">
<span class="status-label">引擎健康状态</span>
<span class="status-value" :class="healthClass">
<span class="status-dot" :class="{ pulse: appState.health === 'ok' }"></span>
{{ healthText }}
</span>
</div>
<div class="status-path" :title="rootPathDisplay">
<span class="status-path-key">Root: </span>
<span class="status-path-val">{{ rootPathDisplay }}</span>
</div>
</div>
</aside>
<!-- 右侧主区域 -->
<div class="main-area">
<header class="topbar">
<div class="topbar-left">
<h2 class="topbar-title">{{ activeMeta.title }}</h2>
<span class="topbar-badge">{{ activeMeta.badge }}</span>
</div>
<div class="topbar-right">
<span class="topbar-health" :class="healthClass">
<span class="status-dot" :class="{ pulse: appState.health === 'ok' }"></span>
<span class="topbar-health-text">{{ healthText }}</span>
</span>
<span class="topbar-sep" aria-hidden="true">|</span>
<span class="topbar-basepath" :title="rootPathDisplay">{{ rootPathDisplay }}</span>
</div>
</header>
<main class="content c-scroll">
<component :is="currentComponent" />
</el-main>
</el-container>
</el-container>
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, ref } from 'vue';
import { computed, defineAsyncComponent, onMounted, onUnmounted, ref } from 'vue';
import type { Component } from 'vue';
import AppIcon from './components/common/AppIcon.vue';
import { useAppState } from './composables/useAppState';
const IngestTab = defineAsyncComponent(() => import('./components/IngestTab.vue'));
const AggregateTab = defineAsyncComponent(() => import('./components/AggregateTab.vue'));
const ConvertTab = defineAsyncComponent(() => import('./components/ConvertTab.vue'));
const DedupTab = defineAsyncComponent(() => import('./components/DedupTab.vue'));
const TraditionalFilterTab = defineAsyncComponent(() => import('./components/TraditionalFilterTab.vue'));
const RenameTab = defineAsyncComponent(() => import('./components/RenameTab.vue'));
const MergeTab = defineAsyncComponent(() => import('./components/MergeTab.vue'));
const SettingsTab = defineAsyncComponent(() => import('./components/SettingsTab.vue'));
type TabKey =
| 'ingest'
| 'aggregate'
| 'convert'
| 'dedup'
| 'zhconvert'
| 'organize'
| 'merge'
| 'settings';
// 旧版六步工具组件保留在源码中(隐藏、不在导航中暴露)。
// 静态引用确保它们随构建保留,且未来可按需恢复到导航。
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const LEGACY_COMPONENTS = {
aggregate: () => import('./components/AggregateTab.vue'),
convert: () => import('./components/ConvertTab.vue'),
dedup: () => import('./components/DedupTab.vue'),
zhconvert: () => import('./components/TraditionalFilterTab.vue'),
organize: () => import('./components/RenameTab.vue'),
merge: () => import('./components/MergeTab.vue')
};
void LEGACY_COMPONENTS;
interface TabDefinition {
key: TabKey;
menuLabel: string;
type NavKey = 'ingest' | 'settings';
interface NavItem {
key: NavKey;
label: string;
icon: string;
badge: string;
title: string;
subtitle: string;
component: Component;
}
const tabs: TabDefinition[] = [
const navItems: NavItem[] = [
{
key: 'ingest',
menuLabel: '一键导入',
badge: 'INGEST',
label: '一键智能导入',
icon: 'bolt',
badge: 'AUTOMATED',
title: '一键导入',
subtitle: '自动扫描、校验、转码、去重、整理入库 —— 一步到位。',
component: IngestTab
},
{
key: 'aggregate',
menuLabel: '01 音频文件汇聚',
badge: 'STEP 01',
title: '01 · 音频文件汇聚',
subtitle: '将分散音频扁平化汇聚,为后续处理统一入口。',
component: AggregateTab
},
{
key: 'convert',
menuLabel: '02 音频格式智能处理',
badge: 'STEP 02',
title: '02 · 音频格式智能处理',
subtitle: '智能识别无损/有损格式并统一转码为 FLAC。',
component: ConvertTab
},
{
key: 'dedup',
menuLabel: '03 音乐去重',
badge: 'STEP 03',
title: '03 · 音乐去重',
subtitle: '基于 MD5 与元数据的双重策略进行音乐去重。',
component: DedupTab
},
{
key: 'zhconvert',
menuLabel: '04 元数据繁简转换',
badge: 'STEP 04',
title: '04 · 音乐元数据繁体转简体',
subtitle: '批量检测并转换标签中的繁体中文。',
component: TraditionalFilterTab
},
{
key: 'organize',
menuLabel: '05 音乐整理',
badge: 'STEP 05',
title: '05 · 音乐整理',
subtitle: '按 Navidrome 规范重命名与整理目录结构。',
component: RenameTab
},
{
key: 'merge',
menuLabel: '06 整理入库',
badge: 'STEP 06',
title: '06 · 整理入库',
subtitle: '将整理好的 staging 目录智能合并入主库。',
component: MergeTab
},
{
key: 'settings',
menuLabel: '全局置',
label: '全局存储配置',
icon: 'gear',
badge: 'CONFIG',
title: '全局配置与路径设置',
subtitle: '配置全局工作目录与各阶段标准子目录。',
component: SettingsTab
}
];
const tabMap = tabs.reduce((acc, tab) => {
acc[tab.key] = tab;
const navMap = navItems.reduce((acc, item) => {
acc[item.key] = item;
return acc;
}, {} as Record<TabKey, TabDefinition>);
}, {} as Record<NavKey, NavItem>);
const activeKey = ref<TabKey>('ingest');
const activeKey = ref<NavKey>('ingest');
const drawerOpen = ref(false);
// 用户可见的导航菜单:仅保留一键导入 + 配置(旧版六步标签隐藏但保留功能)
const visibleTabs = computed(() => tabs.filter(t => t.key === 'ingest' || t.key === 'settings'));
const activeMeta = computed(() => navMap[activeKey.value] ?? navItems[0]);
const currentComponent = computed(() => activeMeta.value.component);
const currentTab = computed(() => tabMap[activeKey.value] || tabs[0]);
const currentComponent = computed(() => currentTab.value.component);
const currentTitle = computed(() => currentTab.value.title);
const currentSubtitle = computed(() => currentTab.value.subtitle);
const { state: appState, refreshConfig, startHealthPolling, stopHealthPolling } = useAppState();
function handleSelect(key: string) {
activeKey.value = key as TabKey;
const rootPathDisplay = computed(() => appState.config.basePath || '未配置工作根目录');
const healthText = computed(() => {
if (appState.health === 'ok') return '运行中';
if (appState.health === 'error') return '连接失败';
return '检测中';
});
const healthClass = computed(() => ({
'is-ok': appState.health === 'ok',
'is-error': appState.health === 'error',
'is-checking': appState.health === 'checking'
}));
function selectNav(key: NavKey) {
activeKey.value = key;
drawerOpen.value = false;
}
onMounted(() => {
startHealthPolling();
// 顶部条与侧边栏需要真实 basePath,主动拉取一次(各面板也会各自刷新)。
void refreshConfig().catch(() => {
/* 配置加载失败不阻塞外壳渲染,各面板会显示未配置状态 */
});
});
onUnmounted(() => {
stopHealthPolling();
});
</script>
<style scoped>
.app-root {
min-height: 100vh;
padding: 18px;
background:
radial-gradient(circle at 12% 12%, rgba(15, 118, 110, 0.13), transparent 45%),
radial-gradient(circle at 78% 4%, rgba(14, 116, 144, 0.16), transparent 40%),
linear-gradient(145deg, #f8fbff 0%, #f3f7fd 42%, #eef5fa 100%);
.console-shell {
display: flex;
width: 100%;
height: 100vh;
height: 100dvh;
overflow: hidden;
background: var(--c-bg-root);
color: var(--c-text-primary);
}
.app-aside {
margin-right: 14px;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 20px;
padding: 18px 12px;
background: rgba(255, 255, 255, 0.78);
backdrop-filter: blur(12px);
box-shadow:
0 20px 45px -30px rgba(15, 23, 42, 0.45),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
/* ---------- 侧边栏 ---------- */
.sidebar {
display: flex;
flex-direction: column;
flex-shrink: 0;
width: 256px;
background: var(--c-bg-panel);
border-right: 1px solid var(--c-border);
}
.app-logo {
padding: 2px 14px 16px;
border-bottom: 1px solid rgba(148, 163, 184, 0.26);
margin-bottom: 14px;
.sidebar-brand {
display: flex;
align-items: center;
gap: 12px;
padding: 18px 20px;
border-bottom: 1px solid var(--c-border);
}
.app-logo-title {
display: block;
font-size: 24px;
.brand-mark {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
flex-shrink: 0;
border-radius: 12px;
color: #fff;
background: linear-gradient(135deg, var(--c-accent-strong), var(--c-teal));
box-shadow: 0 8px 20px -8px rgba(5, 150, 105, 0.6);
}
.brand-text {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.brand-name {
font-size: 15px;
font-weight: 700;
letter-spacing: 0.4px;
color: #0f172a;
color: #fff;
}
.app-logo-sub {
display: block;
margin-top: 4px;
font-size: 13px;
letter-spacing: 1.3px;
text-transform: uppercase;
color: #475569;
.brand-sub {
font-size: 11px;
color: var(--c-text-secondary);
}
.app-menu {
.sidebar-nav {
flex: 1;
border-right: none;
min-height: 0;
padding: 16px 12px;
overflow-y: auto;
}
.app-menu :deep(.el-menu-item) {
height: 44px;
line-height: 44px;
border-radius: 11px;
margin: 2px 6px;
color: #334155;
transition: all 0.2s ease;
.nav-group-label {
padding: 0 12px;
margin: 0 0 8px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--c-text-muted);
}
.app-menu :deep(.el-menu-item:hover) {
color: #0f172a;
background: rgba(15, 118, 110, 0.08);
}
.app-menu :deep(.el-menu-item.is-active) {
color: #0f766e;
font-weight: 600;
background: linear-gradient(95deg, rgba(15, 118, 110, 0.14), rgba(14, 116, 144, 0.07));
}
.app-header {
.nav-item {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 88px;
padding: 0 26px;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 18px;
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(10px);
box-shadow: 0 16px 35px -30px rgba(15, 23, 42, 0.45);
width: 100%;
padding: 10px 14px;
margin-bottom: 6px;
border: none;
border-left: 3px solid transparent;
border-radius: 10px;
background: transparent;
color: var(--c-text-secondary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
.app-header-title h1 {
.nav-item:hover {
background: rgba(30, 41, 59, 0.6);
color: var(--c-text-regular);
}
.nav-item.is-active {
border-left-color: var(--c-accent);
background: rgba(16, 185, 129, 0.08);
color: var(--c-accent-text);
}
.nav-item-main {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.nav-item-main span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-dot {
width: 7px;
height: 7px;
flex-shrink: 0;
border-radius: 999px;
background: var(--c-accent);
}
.sidebar-status {
padding: 16px;
border-top: 1px solid var(--c-border);
background: rgba(2, 6, 23, 0.4);
}
.status-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
font-size: 12px;
}
.status-label {
color: var(--c-text-muted);
font-weight: 500;
}
.status-value {
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 600;
}
.status-value.is-ok {
color: var(--c-accent-text);
}
.status-value.is-error {
color: var(--c-error-text);
}
.status-value.is-checking {
color: var(--c-text-secondary);
}
.status-dot {
width: 7px;
height: 7px;
flex-shrink: 0;
border-radius: 999px;
background: currentColor;
}
.status-path {
padding: 8px 10px;
border: 1px solid var(--c-border);
border-radius: 8px;
background: var(--c-bg-inset);
font-family: var(--c-mono);
font-size: 10px;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-path-key {
color: var(--c-text-muted);
}
.status-path-val {
color: var(--c-text-regular);
}
/* ---------- 主区域 ---------- */
.main-area {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
overflow: hidden;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
height: 56px;
flex-shrink: 0;
padding: 0 24px;
border-bottom: 1px solid var(--c-border);
background: rgba(15, 23, 42, 0.5);
}
.topbar-left {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.topbar-title {
margin: 0;
font-size: 22px;
font-size: 15px;
font-weight: 700;
color: #0f172a;
color: #fff;
white-space: nowrap;
}
.app-header-title p {
margin: 6px 0 0;
font-size: 14px;
color: #475569;
.topbar-badge {
flex-shrink: 0;
padding: 2px 8px;
border: 1px solid rgba(16, 185, 129, 0.2);
border-radius: 6px;
background: rgba(16, 185, 129, 0.1);
color: var(--c-accent-text);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
}
.app-header-meta {
.topbar-right {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.topbar-health {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
}
.topbar-health.is-ok {
color: var(--c-accent-text);
}
.topbar-health.is-error {
color: var(--c-error-text);
}
.topbar-health.is-checking {
color: var(--c-text-secondary);
}
.topbar-sep {
color: var(--c-text-faint);
}
.topbar-basepath {
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--c-mono);
font-size: 11px;
color: var(--c-text-secondary);
}
.content {
flex: 1;
min-height: 0;
padding: 24px;
overflow-y: auto;
background: rgba(2, 6, 23, 0.3);
}
/* ---------- 移动端顶部条 / 抽屉 ---------- */
.mobile-topbar {
display: none;
}
.drawer-scrim {
display: none;
}
.brand-mini {
display: flex;
align-items: center;
gap: 10px;
}
.app-header-index {
.brand-mini-name {
font-size: 15px;
font-weight: 700;
color: #fff;
}
.drawer-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 30px;
padding: 0 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.8px;
color: #0b5f59;
background: rgba(15, 118, 110, 0.12);
width: 38px;
height: 38px;
border: 1px solid var(--c-border);
border-radius: 10px;
background: var(--c-bg-elevated);
color: var(--c-text-regular);
cursor: pointer;
}
.app-header-helper {
font-size: 13px;
color: #64748b;
/* 脉冲动画(reduced-motion 下由 theme.css 关闭) */
.pulse {
animation: consolePulse 2s ease-in-out infinite;
}
.app-main {
margin-top: 14px;
border-radius: 18px;
padding: 20px;
background: rgba(255, 255, 255, 0.66);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
@media (max-width: 992px) {
.app-root {
padding: 12px;
@keyframes consolePulse {
0%,
100% {
opacity: 0.55;
transform: scale(0.92);
}
.app-aside {
width: 100% !important;
margin-right: 0;
margin-bottom: 12px;
50% {
opacity: 1;
transform: scale(1.08);
}
}
.app-header {
padding: 16px;
min-height: auto;
/* ---------- 响应式:平板 / 移动端 ---------- */
@media (max-width: 900px) {
.console-shell {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.app-main {
margin-top: 10px;
padding: 14px;
.mobile-topbar {
display: flex;
align-items: center;
justify-content: space-between;
height: 54px;
flex-shrink: 0;
padding: 0 16px;
border-bottom: 1px solid var(--c-border);
background: var(--c-bg-panel);
z-index: 40;
}
.sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 260px;
max-width: 82vw;
transform: translateX(-100%);
transition: transform 0.22s ease;
z-index: 60;
}
.sidebar.is-open {
transform: translateX(0);
}
.drawer-scrim {
display: block;
position: fixed;
inset: 0;
background: rgba(2, 6, 23, 0.6);
z-index: 50;
}
.topbar {
padding: 0 16px;
}
.topbar-basepath {
max-width: 180px;
}
.content {
padding: 16px;
}
}
@media (max-width: 560px) {
.topbar-title {
font-size: 14px;
}
.topbar-right {
gap: 8px;
}
.topbar-sep,
.topbar-basepath {
display: none;
}
}
</style>
+3
View File
@@ -3,6 +3,9 @@ import request from './request';
export interface ConfigResponse {
basePath: string;
inputDir: string;
libraryDir: string;
rejectedDir: string;
// 保留原有字段以保证向后兼容
aggregatedDir: string;
formatIssuesDir: string;
duplicatesDir: string;
+10
View File
@@ -0,0 +1,10 @@
import request from './request';
/**
* 后端健康检查。
* 对应 GET /api/health,成功时后端返回 Result<String>data 为 "OK")。
* request 拦截器会自动解包 Result,成功返回 data 字符串;失败会 reject。
*/
export function getHealth(): Promise<string> {
return request.get('/api/health');
}
File diff suppressed because it is too large Load Diff
+143
View File
@@ -0,0 +1,143 @@
<template>
<svg
class="app-icon"
:width="size"
:height="size"
:viewBox="viewBox"
:fill="filled ? 'currentColor' : 'none'"
:stroke="filled ? 'none' : 'currentColor'"
:stroke-width="filled ? undefined : strokeWidth"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
focusable="false"
>
<path v-for="(d, i) in paths" :key="i" :d="d" />
<template v-if="circles.length">
<circle
v-for="(c, i) in circles"
:key="`c-${i}`"
:cx="c.cx"
:cy="c.cy"
:r="c.r"
/>
</template>
</svg>
</template>
<script setup lang="ts">
import { computed } from 'vue';
/**
* 本地内联 SVG 图标组件。所有图标显式指定 width/height,避免在 flex 布局
* 下被拉伸放大(对应旧原型 image_881d9c.png 巨型齿轮问题)。
* 全部为本地矢量路径,不引用任何远程字体 / 图标库。
*/
interface IconDef {
paths: string[];
circles?: Array<{ cx: number; cy: number; r: number }>;
/** true 表示实心填充图标(如播放三角),默认描边图标。 */
filled?: boolean;
viewBox?: string;
}
const ICONS: Record<string, IconDef> = {
logo: {
paths: ['M9 18V5l12-2v13M9 10l12-2'],
circles: [
{ cx: 6, cy: 18, r: 3 },
{ cx: 18, cy: 16, r: 3 }
]
},
bolt: {
paths: ['M13 10V3L4 14h7v7l9-11h-7z']
},
gear: {
paths: [
'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z',
'M15 12a3 3 0 11-6 0 3 3 0 016 0z'
]
},
folder: {
paths: ['M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z']
},
login: {
paths: [
'M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1'
]
},
shield: {
paths: [
'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z'
]
},
reject: {
paths: [
'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z'
]
},
play: {
paths: [
'M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z',
'M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
]
},
refresh: {
paths: ['M4 4v5h.582m15.356 2A8.001 8.001 0 1121.21 15H19M9 5a7 7 0 0112.56 3.44']
},
info: {
paths: ['M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z']
},
chart: {
paths: [
'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14m-6 0a2 2 0 002 2h2a2 2 0 002-2'
]
},
check: {
paths: ['M5 13l4 4L19 7']
},
pipeline: {
paths: [
'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10'
]
},
wave: {
paths: ['M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5']
},
warn: {
paths: [
'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z'
]
},
plug: {
paths: [
'M13 10V3L4 14h7v7l9-11h-7z'
]
}
};
interface Props {
name: keyof typeof ICONS | string;
size?: number | string;
strokeWidth?: number | string;
}
const props = withDefaults(defineProps<Props>(), {
size: 16,
strokeWidth: 2
});
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 viewBox = computed(() => def.value.viewBox ?? '0 0 24 24');
</script>
<style scoped>
.app-icon {
display: block;
flex-shrink: 0;
}
</style>
+128
View File
@@ -0,0 +1,128 @@
import { computed, reactive } from 'vue';
import { getHealth } from '../api/health';
import { getConfig } from '../api/config';
import type { ConfigResponse } from '../api/config';
/**
* 轻量级全局状态:在应用外壳、一键导入、全局配置之间共享后端真实数据。
*
* 这不是新框架,只是一个模块级 reactive 对象加若干动作函数。所有值都来自
* 真实接口 / 运行时(GET /api/health、GET /api/config/base-path、导入任务运行态),
* 不含任何模拟数据。
*/
export type HealthStatus = 'checking' | 'ok' | 'error';
interface AppConfigState {
/** 是否已经尝试过加载(用于区分「加载中」与「未配置」)。 */
attempted: boolean;
basePath: string;
inputDir: string;
libraryDir: string;
rejectedDir: string;
/** 后端返回的原始配置,供预览优先使用后端真值。 */
raw: ConfigResponse | null;
}
interface AppState {
health: HealthStatus;
config: AppConfigState;
/** 导入任务是否正在运行(由一键导入面板依据真实任务状态写入)。 */
ingestRunning: boolean;
/** 导入任务真实百分比(0-100)。 */
ingestPercentage: number;
}
const state = reactive<AppState>({
health: 'checking',
config: {
attempted: false,
basePath: '',
inputDir: '',
libraryDir: '',
rejectedDir: '',
raw: null
},
ingestRunning: false,
ingestPercentage: 0
});
/** 是否已配置工作根目录。 */
const isConfigured = computed(() => !!state.config.basePath);
/** 拉取真实健康状态;失败时如实标记为 error。 */
async function refreshHealth(): Promise<void> {
try {
await getHealth();
state.health = 'ok';
} catch {
state.health = 'error';
}
}
/** 拉取后端配置真值并写入共享状态。返回原始配置或 null。 */
async function refreshConfig(): Promise<ConfigResponse | null> {
const cfg = await getConfig();
state.config.attempted = true;
if (cfg && cfg.basePath) {
state.config.basePath = cfg.basePath;
state.config.inputDir = cfg.inputDir || '';
state.config.libraryDir = cfg.libraryDir || '';
state.config.rejectedDir = cfg.rejectedDir || '';
state.config.raw = cfg;
} else {
state.config.basePath = '';
state.config.inputDir = '';
state.config.libraryDir = '';
state.config.rejectedDir = '';
state.config.raw = null;
}
return cfg;
}
function setIngestRunning(running: boolean): void {
state.ingestRunning = running;
}
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;
/**
* 启动健康轮询(引用计数,避免多个消费者重复轮询)。
* 立即执行一次,随后每 15s 刷新。
*/
function startHealthPolling(intervalMs = 15000): void {
healthConsumers += 1;
if (healthTimer === null) {
void refreshHealth();
healthTimer = setInterval(() => {
void refreshHealth();
}, intervalMs);
}
}
/** 释放一个健康轮询消费者;无消费者时停止定时器。 */
function stopHealthPolling(): void {
healthConsumers = Math.max(0, healthConsumers - 1);
if (healthConsumers === 0 && healthTimer !== null) {
clearInterval(healthTimer);
healthTimer = null;
}
}
export function useAppState() {
return {
state,
isConfigured,
refreshHealth,
refreshConfig,
setIngestRunning,
setIngestPercentage,
startHealthPolling,
stopHealthPolling
};
}
+48
View File
@@ -0,0 +1,48 @@
/**
* 跨平台路径拼接助手。
*
* 前端仅用于「预览」派生路径,实际创建目录由后端完成。为了与后端
* PathUtils.joinPath 的行为保持一致,这里统一:
* - 将反斜杠 \ 归一化为正斜杠 /
* - 去除父路径末尾多余的分隔符
* - 去除子路径开头多余的分隔符
* - 以单个 / 连接
*
* 注意:这只是显示层的规范化,不做真实文件系统解析。
*/
/** 归一化根路径:转 POSIX 分隔符并去掉结尾斜杠。 */
export function normalizeRoot(raw: string): string {
if (!raw) {
return '';
}
return raw.replace(/\\/g, '/').replace(/\/+$/, '');
}
/** 以规范化方式拼接父/子路径,返回 POSIX 形式字符串。 */
export function joinPath(parent: string, child: string): string {
const parentPosix = normalizeRoot(parent);
if (!parentPosix) {
return child ? child.replace(/\\/g, '/') : '';
}
if (!child) {
return parentPosix;
}
const childPosix = child.replace(/\\/g, '/').replace(/^\/+/, '');
return `${parentPosix}/${childPosix}`;
}
/**
* 校验路径是否合法(与后端 PathUtils.isValidPath 对齐)。
* Windows 盘符后的冒号是合法的,其余情况下不允许出现 < > " | ? *。
*/
export function isValidPath(path: string): boolean {
if (!path || !path.trim()) {
return false;
}
let pathToValidate = path.trim();
if (/^[A-Za-z]:[/\\]/.test(pathToValidate)) {
pathToValidate = pathToValidate.substring(2);
}
return !/[<>"|?*]/.test(pathToValidate);
}
+155 -50
View File
@@ -1,67 +1,172 @@
/*
* MangTool 暗色控制台主题。
*
* 使用本地 CSS 变量构建视觉系统(slate/emerald),并对 Element Plus 的
* 控件 token 做暗色适配。尽量减少大范围 !important 覆盖:仅在 Element Plus
* 通过内联变量/较高权重选择器时才使用。
*/
:root {
--brand-50: #f0fdfa;
--brand-100: #ccfbf1;
--brand-200: #99f6e4;
--brand-500: #14b8a6;
--brand-600: #0d9488;
--brand-700: #0f766e;
--ink-900: #0f172a;
--ink-700: #334155;
--ink-500: #64748b;
/* 背景层级 */
--c-bg-root: #020617; /* slate-950 */
--c-bg-panel: #0f172a; /* slate-900 */
--c-bg-panel-soft: rgba(15, 23, 42, 0.8);
--c-bg-elevated: #1e293b; /* slate-800 */
--c-bg-inset: #020617;
--c-bg-hover: #334155;
--el-color-primary: var(--brand-600);
--el-color-primary-light-3: #2fb9ae;
--el-color-primary-light-5: #5ac7bf;
--el-color-primary-light-7: #8ad9d3;
--el-color-primary-light-8: #a9e5e1;
--el-color-primary-light-9: #daf4f2;
--el-color-primary-dark-2: #0c857a;
/* 边框 */
--c-border: #1e293b; /* slate-800 */
--c-border-soft: rgba(148, 163, 184, 0.14);
--c-border-strong: #334155;
--el-bg-color: rgba(255, 255, 255, 0.92);
--el-bg-color-page: #f3f7fb;
--el-text-color-primary: var(--ink-900);
--el-text-color-regular: var(--ink-700);
--el-text-color-secondary: var(--ink-500);
--el-border-color: #d6dee8;
--el-border-color-light: #e5ebf2;
--el-border-color-lighter: #edf2f7;
/* 文本 */
--c-text-primary: #f1f5f9; /* slate-100 */
--c-text-regular: #cbd5e1; /* slate-300 */
--c-text-secondary: #94a3b8; /* slate-400 */
--c-text-muted: #64748b; /* slate-500 */
--c-text-faint: #475569; /* slate-600 */
--el-border-radius-base: 12px;
--el-border-radius-small: 10px;
/* 主题色 */
--c-accent: #10b981; /* emerald-500 */
--c-accent-strong: #059669;/* emerald-600 */
--c-accent-text: #34d399; /* emerald-400 */
--c-teal: #14b8a6;
/* 语义色 */
--c-warning: #f59e0b; /* amber-500 */
--c-warning-text: #fbbf24;
--c-error: #f43f5e; /* rose-500 */
--c-error-text: #fb7185;
--c-purple: #a855f7;
--c-purple-text: #c084fc;
--c-info: #38bdf8;
--c-radius-lg: 14px;
--c-radius-md: 10px;
--c-radius-sm: 8px;
--c-mono: 'Fira Code', 'JetBrains Mono', 'SFMono-Regular', 'Menlo', 'Consolas', monospace;
/* Element Plus 暗色 token 适配 */
--el-color-primary: var(--c-accent-strong);
--el-color-primary-light-3: #34d399;
--el-color-primary-light-5: #6ee7b7;
--el-color-primary-light-7: #a7f3d0;
--el-color-primary-light-8: #bbf7d0;
--el-color-primary-light-9: #12321f;
--el-color-primary-dark-2: #047857;
--el-bg-color: var(--c-bg-panel);
--el-bg-color-overlay: var(--c-bg-panel);
--el-bg-color-page: var(--c-bg-root);
--el-fill-color-blank: var(--c-bg-inset);
--el-fill-color-light: rgba(30, 41, 59, 0.6);
--el-fill-color-lighter: rgba(30, 41, 59, 0.4);
--el-text-color-primary: var(--c-text-primary);
--el-text-color-regular: var(--c-text-regular);
--el-text-color-secondary: var(--c-text-secondary);
--el-text-color-placeholder: var(--c-text-muted);
--el-text-color-disabled: var(--c-text-faint);
--el-border-color: var(--c-border-strong);
--el-border-color-light: var(--c-border);
--el-border-color-lighter: var(--c-border);
--el-border-color-extra-light: var(--c-border);
--el-border-color-hover: var(--c-border-strong);
--el-border-radius-base: 10px;
--el-border-radius-small: 8px;
--el-color-success: var(--c-accent);
--el-color-warning: var(--c-warning);
--el-color-danger: var(--c-error);
--el-color-error: var(--c-error);
--el-color-info: var(--c-text-secondary);
color-scheme: dark;
}
html,
body {
background: var(--c-bg-root);
}
body {
color: var(--ink-900);
font-family: 'Avenir Next', 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', sans-serif;
color: var(--c-text-primary);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Noto Sans SC', 'Microsoft YaHei', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
isolation: isolate;
}
.el-card {
border: 1px solid rgba(148, 163, 184, 0.24) !important;
box-shadow: 0 20px 40px -36px rgba(15, 23, 42, 0.5) !important;
background: rgba(255, 255, 255, 0.88) !important;
}
.el-card:hover {
box-shadow: 0 26px 48px -36px rgba(15, 23, 42, 0.56) !important;
}
.el-button--primary {
background: linear-gradient(120deg, #0d9488, #0e7490) !important;
border-color: transparent !important;
}
.el-button--primary:hover {
opacity: 0.93;
}
/* Element Plus 暗色控件适配(局部、克制的覆盖) */
.el-input__wrapper,
.el-textarea__inner,
.el-select__wrapper {
background-color: rgba(255, 255, 255, 0.82) !important;
.el-textarea__inner {
background-color: var(--c-bg-inset) !important;
box-shadow: 0 0 0 1px var(--c-border) inset !important;
}
.el-input__wrapper:hover {
box-shadow: 0 0 0 1px var(--c-border-strong) inset !important;
}
.el-input__wrapper.is-focus {
box-shadow: 0 0 0 1px var(--c-accent) inset !important;
}
.el-input__inner {
color: var(--c-text-primary);
}
.el-input.is-disabled .el-input__wrapper {
background-color: rgba(2, 6, 23, 0.6) !important;
}
/* 消息 / 通知(渲染在 body 层,需全局暗色样式) */
.el-message {
background-color: var(--c-bg-panel) !important;
border-color: var(--c-border) !important;
}
.el-message .el-message__content {
color: var(--c-text-regular) !important;
}
/* 滚动条 */
.c-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.28) transparent;
}
.c-scroll::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.c-scroll::-webkit-scrollbar-track {
background: transparent;
}
.c-scroll::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.24);
border-radius: 6px;
}
.c-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(16, 185, 129, 0.42);
}
/* 键盘可达性:统一可见焦点环 */
:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 2px;
border-radius: 4px;
}
@media (prefers-reduced-motion: reduce) {