Template
WIP: preserve frontend redesign
This commit is contained in:
+513
-222
@@ -1,315 +1,606 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-container class="app-root">
|
<div class="console-shell">
|
||||||
<el-aside width="220px" class="app-aside">
|
<!-- 移动端顶部条:品牌 + 抽屉开关 -->
|
||||||
<div class="app-logo">
|
<div class="mobile-topbar">
|
||||||
<span class="app-logo-title">MangTool</span>
|
<div class="brand-mini">
|
||||||
<span class="app-logo-sub">音乐工具箱</span>
|
<span class="brand-mark"><app-icon name="logo" :size="18" :stroke-width="2.5" /></span>
|
||||||
|
<span class="brand-mini-name">MangTool</span>
|
||||||
</div>
|
</div>
|
||||||
<el-menu
|
<button
|
||||||
:default-active="activeKey"
|
class="drawer-toggle"
|
||||||
class="app-menu"
|
type="button"
|
||||||
@select="handleSelect"
|
:aria-expanded="drawerOpen"
|
||||||
|
aria-label="切换导航菜单"
|
||||||
|
@click="drawerOpen = !drawerOpen"
|
||||||
>
|
>
|
||||||
<el-menu-item
|
<app-icon :name="drawerOpen ? 'reject' : 'chart'" :size="18" />
|
||||||
v-for="tab in visibleTabs"
|
</button>
|
||||||
:key="tab.key"
|
</div>
|
||||||
:index="tab.key"
|
|
||||||
>
|
|
||||||
{{ tab.menuLabel }}
|
|
||||||
</el-menu-item>
|
|
||||||
</el-menu>
|
|
||||||
</el-aside>
|
|
||||||
|
|
||||||
<el-container>
|
<!-- 侧边栏遮罩(仅移动端抽屉展开时) -->
|
||||||
<el-header class="app-header">
|
<div
|
||||||
<div class="app-header-title">
|
v-if="drawerOpen"
|
||||||
<h1>{{ currentTitle }}</h1>
|
class="drawer-scrim"
|
||||||
<p>{{ currentSubtitle }}</p>
|
@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>
|
||||||
<div class="app-header-meta">
|
</div>
|
||||||
<span class="app-header-index">{{ currentTab.badge }}</span>
|
|
||||||
<span class="app-header-helper">执行面板</span>
|
<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>
|
||||||
</el-header>
|
<div class="status-path" :title="rootPathDisplay">
|
||||||
<el-main class="app-main">
|
<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" />
|
<component :is="currentComponent" />
|
||||||
</el-main>
|
</main>
|
||||||
</el-container>
|
</div>
|
||||||
</el-container>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, defineAsyncComponent, ref } from 'vue';
|
import { computed, defineAsyncComponent, onMounted, onUnmounted, ref } from 'vue';
|
||||||
import type { Component } 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 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'));
|
const SettingsTab = defineAsyncComponent(() => import('./components/SettingsTab.vue'));
|
||||||
|
|
||||||
type TabKey =
|
// 旧版六步工具组件保留在源码中(隐藏、不在导航中暴露)。
|
||||||
| 'ingest'
|
// 静态引用确保它们随构建保留,且未来可按需恢复到导航。
|
||||||
| 'aggregate'
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
| 'convert'
|
const LEGACY_COMPONENTS = {
|
||||||
| 'dedup'
|
aggregate: () => import('./components/AggregateTab.vue'),
|
||||||
| 'zhconvert'
|
convert: () => import('./components/ConvertTab.vue'),
|
||||||
| 'organize'
|
dedup: () => import('./components/DedupTab.vue'),
|
||||||
| 'merge'
|
zhconvert: () => import('./components/TraditionalFilterTab.vue'),
|
||||||
| 'settings';
|
organize: () => import('./components/RenameTab.vue'),
|
||||||
|
merge: () => import('./components/MergeTab.vue')
|
||||||
|
};
|
||||||
|
void LEGACY_COMPONENTS;
|
||||||
|
|
||||||
interface TabDefinition {
|
type NavKey = 'ingest' | 'settings';
|
||||||
key: TabKey;
|
|
||||||
menuLabel: string;
|
interface NavItem {
|
||||||
|
key: NavKey;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
badge: string;
|
badge: string;
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
|
||||||
component: Component;
|
component: Component;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabs: TabDefinition[] = [
|
const navItems: NavItem[] = [
|
||||||
{
|
{
|
||||||
key: 'ingest',
|
key: 'ingest',
|
||||||
menuLabel: '一键导入',
|
label: '一键智能导入',
|
||||||
badge: 'INGEST',
|
icon: 'bolt',
|
||||||
|
badge: 'AUTOMATED',
|
||||||
title: '一键导入',
|
title: '一键导入',
|
||||||
subtitle: '自动扫描、校验、转码、去重、整理入库 —— 一步到位。',
|
|
||||||
component: IngestTab
|
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',
|
key: 'settings',
|
||||||
menuLabel: '全局设置',
|
label: '全局存储配置',
|
||||||
|
icon: 'gear',
|
||||||
badge: 'CONFIG',
|
badge: 'CONFIG',
|
||||||
title: '全局配置与路径设置',
|
title: '全局配置与路径设置',
|
||||||
subtitle: '配置全局工作目录与各阶段标准子目录。',
|
|
||||||
component: SettingsTab
|
component: SettingsTab
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const tabMap = tabs.reduce((acc, tab) => {
|
const navMap = navItems.reduce((acc, item) => {
|
||||||
acc[tab.key] = tab;
|
acc[item.key] = item;
|
||||||
return acc;
|
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 activeMeta = computed(() => navMap[activeKey.value] ?? navItems[0]);
|
||||||
const visibleTabs = computed(() => tabs.filter(t => t.key === 'ingest' || t.key === 'settings'));
|
const currentComponent = computed(() => activeMeta.value.component);
|
||||||
|
|
||||||
const currentTab = computed(() => tabMap[activeKey.value] || tabs[0]);
|
const { state: appState, refreshConfig, startHealthPolling, stopHealthPolling } = useAppState();
|
||||||
const currentComponent = computed(() => currentTab.value.component);
|
|
||||||
const currentTitle = computed(() => currentTab.value.title);
|
|
||||||
const currentSubtitle = computed(() => currentTab.value.subtitle);
|
|
||||||
|
|
||||||
function handleSelect(key: string) {
|
const rootPathDisplay = computed(() => appState.config.basePath || '未配置工作根目录');
|
||||||
activeKey.value = key as TabKey;
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.app-root {
|
.console-shell {
|
||||||
min-height: 100vh;
|
display: flex;
|
||||||
padding: 18px;
|
width: 100%;
|
||||||
background:
|
height: 100vh;
|
||||||
radial-gradient(circle at 12% 12%, rgba(15, 118, 110, 0.13), transparent 45%),
|
height: 100dvh;
|
||||||
radial-gradient(circle at 78% 4%, rgba(14, 116, 144, 0.16), transparent 40%),
|
overflow: hidden;
|
||||||
linear-gradient(145deg, #f8fbff 0%, #f3f7fd 42%, #eef5fa 100%);
|
background: var(--c-bg-root);
|
||||||
|
color: var(--c-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-aside {
|
/* ---------- 侧边栏 ---------- */
|
||||||
margin-right: 14px;
|
.sidebar {
|
||||||
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);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 256px;
|
||||||
|
background: var(--c-bg-panel);
|
||||||
|
border-right: 1px solid var(--c-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-logo {
|
.sidebar-brand {
|
||||||
padding: 2px 14px 16px;
|
display: flex;
|
||||||
border-bottom: 1px solid rgba(148, 163, 184, 0.26);
|
align-items: center;
|
||||||
margin-bottom: 14px;
|
gap: 12px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
border-bottom: 1px solid var(--c-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-logo-title {
|
.brand-mark {
|
||||||
display: block;
|
display: flex;
|
||||||
font-size: 24px;
|
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;
|
font-weight: 700;
|
||||||
letter-spacing: 0.4px;
|
color: #fff;
|
||||||
color: #0f172a;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-logo-sub {
|
.brand-sub {
|
||||||
display: block;
|
font-size: 11px;
|
||||||
margin-top: 4px;
|
color: var(--c-text-secondary);
|
||||||
font-size: 13px;
|
|
||||||
letter-spacing: 1.3px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: #475569;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-menu {
|
.sidebar-nav {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
border-right: none;
|
min-height: 0;
|
||||||
|
padding: 16px 12px;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-menu :deep(.el-menu-item) {
|
.nav-group-label {
|
||||||
height: 44px;
|
padding: 0 12px;
|
||||||
line-height: 44px;
|
margin: 0 0 8px;
|
||||||
border-radius: 11px;
|
font-size: 10px;
|
||||||
margin: 2px 6px;
|
font-weight: 700;
|
||||||
color: #334155;
|
letter-spacing: 0.08em;
|
||||||
transition: all 0.2s ease;
|
text-transform: uppercase;
|
||||||
|
color: var(--c-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-menu :deep(.el-menu-item:hover) {
|
.nav-item {
|
||||||
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 {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
min-height: 88px;
|
width: 100%;
|
||||||
padding: 0 26px;
|
padding: 10px 14px;
|
||||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
margin-bottom: 6px;
|
||||||
border-radius: 18px;
|
border: none;
|
||||||
background: rgba(255, 255, 255, 0.88);
|
border-left: 3px solid transparent;
|
||||||
backdrop-filter: blur(10px);
|
border-radius: 10px;
|
||||||
box-shadow: 0 16px 35px -30px rgba(15, 23, 42, 0.45);
|
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;
|
margin: 0;
|
||||||
font-size: 22px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #0f172a;
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-header-title p {
|
.topbar-badge {
|
||||||
margin: 6px 0 0;
|
flex-shrink: 0;
|
||||||
font-size: 14px;
|
padding: 2px 8px;
|
||||||
color: #475569;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-header-index {
|
.brand-mini-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-toggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 30px;
|
width: 38px;
|
||||||
padding: 0 12px;
|
height: 38px;
|
||||||
border-radius: 999px;
|
border: 1px solid var(--c-border);
|
||||||
font-size: 12px;
|
border-radius: 10px;
|
||||||
font-weight: 700;
|
background: var(--c-bg-elevated);
|
||||||
letter-spacing: 0.8px;
|
color: var(--c-text-regular);
|
||||||
color: #0b5f59;
|
cursor: pointer;
|
||||||
background: rgba(15, 118, 110, 0.12);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-header-helper {
|
/* 脉冲动画(reduced-motion 下由 theme.css 关闭) */
|
||||||
font-size: 13px;
|
.pulse {
|
||||||
color: #64748b;
|
animation: consolePulse 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-main {
|
@keyframes consolePulse {
|
||||||
margin-top: 14px;
|
0%,
|
||||||
border-radius: 18px;
|
100% {
|
||||||
padding: 20px;
|
opacity: 0.55;
|
||||||
background: rgba(255, 255, 255, 0.66);
|
transform: scale(0.92);
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 992px) {
|
|
||||||
.app-root {
|
|
||||||
padding: 12px;
|
|
||||||
}
|
}
|
||||||
|
50% {
|
||||||
.app-aside {
|
opacity: 1;
|
||||||
width: 100% !important;
|
transform: scale(1.08);
|
||||||
margin-right: 0;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.app-header {
|
/* ---------- 响应式:平板 / 移动端 ---------- */
|
||||||
padding: 16px;
|
@media (max-width: 900px) {
|
||||||
min-height: auto;
|
.console-shell {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-main {
|
.mobile-topbar {
|
||||||
margin-top: 10px;
|
display: flex;
|
||||||
padding: 14px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import request from './request';
|
|||||||
export interface ConfigResponse {
|
export interface ConfigResponse {
|
||||||
basePath: string;
|
basePath: string;
|
||||||
inputDir: string;
|
inputDir: string;
|
||||||
|
libraryDir: string;
|
||||||
|
rejectedDir: string;
|
||||||
|
// 保留原有字段以保证向后兼容
|
||||||
aggregatedDir: string;
|
aggregatedDir: string;
|
||||||
formatIssuesDir: string;
|
formatIssuesDir: string;
|
||||||
duplicatesDir: string;
|
duplicatesDir: string;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
+1345
-256
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
@@ -1,67 +1,172 @@
|
|||||||
|
/*
|
||||||
|
* MangTool 暗色控制台主题。
|
||||||
|
*
|
||||||
|
* 使用本地 CSS 变量构建视觉系统(slate/emerald),并对 Element Plus 的
|
||||||
|
* 控件 token 做暗色适配。尽量减少大范围 !important 覆盖:仅在 Element Plus
|
||||||
|
* 通过内联变量/较高权重选择器时才使用。
|
||||||
|
*/
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--brand-50: #f0fdfa;
|
/* 背景层级 */
|
||||||
--brand-100: #ccfbf1;
|
--c-bg-root: #020617; /* slate-950 */
|
||||||
--brand-200: #99f6e4;
|
--c-bg-panel: #0f172a; /* slate-900 */
|
||||||
--brand-500: #14b8a6;
|
--c-bg-panel-soft: rgba(15, 23, 42, 0.8);
|
||||||
--brand-600: #0d9488;
|
--c-bg-elevated: #1e293b; /* slate-800 */
|
||||||
--brand-700: #0f766e;
|
--c-bg-inset: #020617;
|
||||||
--ink-900: #0f172a;
|
--c-bg-hover: #334155;
|
||||||
--ink-700: #334155;
|
|
||||||
--ink-500: #64748b;
|
|
||||||
|
|
||||||
--el-color-primary: var(--brand-600);
|
/* 边框 */
|
||||||
--el-color-primary-light-3: #2fb9ae;
|
--c-border: #1e293b; /* slate-800 */
|
||||||
--el-color-primary-light-5: #5ac7bf;
|
--c-border-soft: rgba(148, 163, 184, 0.14);
|
||||||
--el-color-primary-light-7: #8ad9d3;
|
--c-border-strong: #334155;
|
||||||
--el-color-primary-light-8: #a9e5e1;
|
|
||||||
--el-color-primary-light-9: #daf4f2;
|
|
||||||
--el-color-primary-dark-2: #0c857a;
|
|
||||||
|
|
||||||
--el-bg-color: rgba(255, 255, 255, 0.92);
|
/* 文本 */
|
||||||
--el-bg-color-page: #f3f7fb;
|
--c-text-primary: #f1f5f9; /* slate-100 */
|
||||||
--el-text-color-primary: var(--ink-900);
|
--c-text-regular: #cbd5e1; /* slate-300 */
|
||||||
--el-text-color-regular: var(--ink-700);
|
--c-text-secondary: #94a3b8; /* slate-400 */
|
||||||
--el-text-color-secondary: var(--ink-500);
|
--c-text-muted: #64748b; /* slate-500 */
|
||||||
--el-border-color: #d6dee8;
|
--c-text-faint: #475569; /* slate-600 */
|
||||||
--el-border-color-light: #e5ebf2;
|
|
||||||
--el-border-color-lighter: #edf2f7;
|
|
||||||
|
|
||||||
--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 {
|
body {
|
||||||
color: var(--ink-900);
|
color: var(--c-text-primary);
|
||||||
font-family: 'Avenir Next', 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', sans-serif;
|
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 {
|
#app {
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-card {
|
/* Element Plus 暗色控件适配(局部、克制的覆盖) */
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-input__wrapper,
|
.el-input__wrapper,
|
||||||
.el-textarea__inner,
|
.el-textarea__inner {
|
||||||
.el-select__wrapper {
|
background-color: var(--c-bg-inset) !important;
|
||||||
background-color: rgba(255, 255, 255, 0.82) !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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
Reference in New Issue
Block a user