feat: 导入 CapacityReport 初始源码
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CapacityReport</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+2393
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "capacity-report-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
},
|
||||
"scarfSettings": {
|
||||
"enabled": false
|
||||
},
|
||||
"overrides": {
|
||||
"dompurify": "3.4.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@vicons/ionicons5": "^0.13.0",
|
||||
"echarts": "^6.1.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"naive-ui": "^2.42.0",
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^5.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"typescript": "^5.9.0",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"unplugin-vue-components": "^32.1.0",
|
||||
"vite": "^7.0.0",
|
||||
"vue-tsc": "^3.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<n-config-provider :theme="naiveTheme">
|
||||
<n-message-provider>
|
||||
<n-dialog-provider>
|
||||
<AppShell />
|
||||
</n-dialog-provider>
|
||||
</n-message-provider>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { darkTheme } from 'naive-ui';
|
||||
|
||||
import AppShell from './AppShell.vue';
|
||||
import { themeName } from './composables/theme';
|
||||
|
||||
const naiveTheme = computed(() => (themeName.value === 'dark' ? darkTheme : undefined));
|
||||
</script>
|
||||
@@ -0,0 +1,318 @@
|
||||
<template>
|
||||
<LoginView v-if="!token" :loading="loginLoading" @login="handleLogin" />
|
||||
<n-layout v-else has-sider class="app-layout">
|
||||
<n-layout-sider
|
||||
class="app-sider"
|
||||
collapse-mode="width"
|
||||
:collapsed="sidebarCollapsed"
|
||||
:collapsed-width="64"
|
||||
:width="220"
|
||||
show-trigger="arrow-circle"
|
||||
@update:collapsed="handleSidebarCollapsed"
|
||||
>
|
||||
<div class="brand">
|
||||
<div
|
||||
class="brand-mark"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
title="CapacityReport"
|
||||
@click="handleBrandMarkClick"
|
||||
@keydown.enter.prevent="handleBrandMarkClick"
|
||||
@keydown.space.prevent="handleBrandMarkClick"
|
||||
>
|
||||
📊
|
||||
</div>
|
||||
<div class="brand-text">CapacityReport</div>
|
||||
</div>
|
||||
<n-menu
|
||||
:value="activeMenu"
|
||||
class="app-menu"
|
||||
:collapsed-width="64"
|
||||
:collapsed-icon-size="22"
|
||||
:options="menuOptions"
|
||||
@update:value="handleMenuChange"
|
||||
/>
|
||||
<div class="sider-footer">
|
||||
<span class="sider-version">
|
||||
<span class="sider-version-label">版本:</span>
|
||||
<span class="sider-version-number">v3.0.0</span>
|
||||
</span>
|
||||
<span class="sider-powered">Power by:NIXEVOL</span>
|
||||
</div>
|
||||
</n-layout-sider>
|
||||
|
||||
<n-layout>
|
||||
<n-layout-header bordered class="topbar page-header">
|
||||
<div class="page-header-content">
|
||||
<h1>{{ currentTitle }}</h1>
|
||||
<span v-if="pageSubtitle" class="page-subtitle" :title="pageSubtitle">{{ pageSubtitle }}</span>
|
||||
</div>
|
||||
<div class="page-header-actions">
|
||||
<template v-for="action in pageHeader.actions" :key="action.key">
|
||||
<span v-if="action.kind === 'text'" class="header-info-text">
|
||||
{{ actionLabel(action) }}
|
||||
</span>
|
||||
<n-dropdown
|
||||
v-else-if="actionDropdownOptions(action).length"
|
||||
trigger="click"
|
||||
:options="actionDropdownOptions(action)"
|
||||
:disabled="actionDisabled(action)"
|
||||
:on-select="runHeaderActionSelect(action)"
|
||||
>
|
||||
<n-button
|
||||
size="small"
|
||||
:type="action.type === 'default' ? undefined : action.type"
|
||||
:tertiary="action.variant !== 'solid'"
|
||||
:text="action.variant === 'text'"
|
||||
:loading="actionLoading(action)"
|
||||
:disabled="actionDisabled(action)"
|
||||
:title="actionTitle(action)"
|
||||
>
|
||||
<template v-if="actionIcon(action)" #icon>
|
||||
<n-icon><component :is="actionIcon(action)" /></n-icon>
|
||||
</template>
|
||||
<span class="header-dropdown-label">
|
||||
<span>{{ actionLabel(action) }}</span>
|
||||
<n-icon class="header-dropdown-caret"><ChevronDownOutline /></n-icon>
|
||||
</span>
|
||||
</n-button>
|
||||
</n-dropdown>
|
||||
<n-button
|
||||
v-else
|
||||
size="small"
|
||||
:type="action.type === 'default' ? undefined : action.type"
|
||||
:tertiary="action.variant !== 'solid'"
|
||||
:text="action.variant === 'text'"
|
||||
:loading="actionLoading(action)"
|
||||
:disabled="actionDisabled(action)"
|
||||
:title="actionTitle(action)"
|
||||
@click="runHeaderAction(action)"
|
||||
>
|
||||
<template v-if="actionIcon(action)" #icon>
|
||||
<n-icon><component :is="actionIcon(action)" /></n-icon>
|
||||
</template>
|
||||
{{ actionLabel(action) }}
|
||||
</n-button>
|
||||
</template>
|
||||
|
||||
<button class="theme-toggle" type="button" :title="themeToggleTitle" @click="toggleTheme">
|
||||
<n-icon><component :is="themeToggleIcon" /></n-icon>
|
||||
</button>
|
||||
<n-button tertiary circle title="退出登录" class="logout-button" @click="logout">
|
||||
<template #icon>
|
||||
<n-icon><LogOutOutline /></n-icon>
|
||||
</template>
|
||||
</n-button>
|
||||
</div>
|
||||
</n-layout-header>
|
||||
|
||||
<n-layout-content class="content">
|
||||
<RouterView />
|
||||
</n-layout-content>
|
||||
</n-layout>
|
||||
</n-layout>
|
||||
|
||||
<LicenseActivationModal v-if="licenseModalVisible" v-model:show="licenseModalVisible" @metrix-changed="onMetrixChanged" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, h, onBeforeUnmount, onMounted, ref, watch, type Component } from 'vue';
|
||||
import { RouterView, useRoute, useRouter } from 'vue-router';
|
||||
import { useMessage, type DropdownOption, type MenuOption, NIcon } from 'naive-ui';
|
||||
import {
|
||||
ChevronDownOutline,
|
||||
CloudUploadOutline,
|
||||
ConstructOutline,
|
||||
FileTrayFullOutline,
|
||||
LogOutOutline,
|
||||
MoonOutline,
|
||||
ServerOutline,
|
||||
SettingsOutline,
|
||||
StatsChartOutline,
|
||||
SunnyOutline
|
||||
} from '@vicons/ionicons5';
|
||||
|
||||
import { apiGet, apiPost, clearToken, getToken, setToken, setUnauthorizedHandler } from './api/client';
|
||||
import type { AppConfig, LoginResponse } from './types';
|
||||
import { pageHeader, resetPageHeader, resolveHeaderValue, type PageHeaderAction } from './composables/pageHeader';
|
||||
import { metrixEnabled } from './composables/metrixEnabled';
|
||||
import { themeName, toggleAppTheme } from './composables/theme';
|
||||
|
||||
const LoginView = defineAsyncComponent(() => import('./components/LoginView.vue'));
|
||||
const LicenseActivationModal = defineAsyncComponent(() => import('./components/LicenseActivationModal.vue'));
|
||||
const message = useMessage();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const token = ref(getToken());
|
||||
const loginLoading = ref(false);
|
||||
const sidebarCollapsed = ref(localStorage.getItem('sidebarCollapsed') === 'true');
|
||||
const licenseModalVisible = ref(false);
|
||||
const brandClickCount = ref(0);
|
||||
const menuKeys = ['workflow', 'dashboard', 'history', 'database', 'script', 'settings'] as const;
|
||||
type MenuKey = (typeof menuKeys)[number];
|
||||
let brandClickResetTimer: number | undefined;
|
||||
|
||||
const menuOptions: MenuOption[] = [
|
||||
{ label: '数据处理', key: 'workflow', icon: renderIcon(CloudUploadOutline) },
|
||||
{ label: '容量看板', key: 'dashboard', icon: renderIcon(StatsChartOutline) },
|
||||
{ label: '处理历史', key: 'history', icon: renderIcon(FileTrayFullOutline) },
|
||||
{ label: '数据管理', key: 'database', icon: renderIcon(ServerOutline) },
|
||||
{ label: '脚本编辑', key: 'script', icon: renderIcon(ConstructOutline) },
|
||||
{ label: '系统设置', key: 'settings', icon: renderIcon(SettingsOutline) }
|
||||
];
|
||||
|
||||
const activeMenu = computed<MenuKey>(() => {
|
||||
return isMenuKey(route.name) ? route.name : 'workflow';
|
||||
});
|
||||
|
||||
const currentTitle = computed(() => {
|
||||
return String(route.meta.title || menuOptions.find(item => item.key === activeMenu.value)?.label || '');
|
||||
});
|
||||
const pageSubtitle = computed(() => resolveHeaderValue(pageHeader.subtitle || '', ''));
|
||||
const themeToggleIcon = computed<Component>(() => (themeName.value === 'dark' ? SunnyOutline : MoonOutline));
|
||||
const themeToggleTitle = computed(() => (themeName.value === 'dark' ? '切换到浅色主题' : '切换到深色主题'));
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('dragover', preventWindowFileDrop, { capture: true });
|
||||
window.addEventListener('drop', preventWindowFileDrop, { capture: true });
|
||||
if (token.value) {
|
||||
void loadMetrixEnabled();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('dragover', preventWindowFileDrop, { capture: true });
|
||||
window.removeEventListener('drop', preventWindowFileDrop, { capture: true });
|
||||
if (brandClickResetTimer !== undefined) {
|
||||
window.clearTimeout(brandClickResetTimer);
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.name,
|
||||
() => resetPageHeader()
|
||||
);
|
||||
|
||||
setUnauthorizedHandler(() => {
|
||||
token.value = '';
|
||||
});
|
||||
|
||||
async function handleLogin(payload: { username: string; password: string }) {
|
||||
loginLoading.value = true;
|
||||
try {
|
||||
const result = await apiPost<LoginResponse>('/api/login', payload);
|
||||
setToken(result.token);
|
||||
token.value = result.token;
|
||||
message.success('登录成功');
|
||||
void loadMetrixEnabled();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '登录失败');
|
||||
} finally {
|
||||
loginLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
clearToken();
|
||||
token.value = '';
|
||||
}
|
||||
|
||||
function handleSidebarCollapsed(value: boolean) {
|
||||
sidebarCollapsed.value = value;
|
||||
localStorage.setItem('sidebarCollapsed', sidebarCollapsed.value ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
toggleAppTheme();
|
||||
}
|
||||
|
||||
function handleMenuChange(key: string | number) {
|
||||
if (!isMenuKey(key) || key === activeMenu.value) {
|
||||
return;
|
||||
}
|
||||
void router.push({ name: key });
|
||||
}
|
||||
|
||||
function onMetrixChanged(enabled: boolean) {
|
||||
metrixEnabled.value = enabled;
|
||||
}
|
||||
|
||||
async function loadMetrixEnabled() {
|
||||
try {
|
||||
const config = await apiGet<AppConfig>('/api/config/full');
|
||||
metrixEnabled.value = config.metrix_enabled ?? false;
|
||||
} catch {
|
||||
metrixEnabled.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleBrandMarkClick() {
|
||||
brandClickCount.value += 1;
|
||||
if (brandClickResetTimer !== undefined) {
|
||||
window.clearTimeout(brandClickResetTimer);
|
||||
}
|
||||
|
||||
if (brandClickCount.value >= 8) {
|
||||
brandClickCount.value = 0;
|
||||
licenseModalVisible.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
brandClickResetTimer = window.setTimeout(() => {
|
||||
brandClickCount.value = 0;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function isMenuKey(value: unknown): value is MenuKey {
|
||||
return typeof value === 'string' && menuKeys.includes(value as MenuKey);
|
||||
}
|
||||
|
||||
function renderIcon(icon: Component) {
|
||||
return () => h(NIcon, null, { default: () => h(icon) });
|
||||
}
|
||||
|
||||
function actionLabel(action: PageHeaderAction) {
|
||||
return resolveHeaderValue(action.label, '');
|
||||
}
|
||||
|
||||
function actionIcon(action: PageHeaderAction) {
|
||||
return resolveHeaderValue(action.icon) ?? null;
|
||||
}
|
||||
|
||||
function actionTitle(action: PageHeaderAction) {
|
||||
return resolveHeaderValue(action.title, actionLabel(action));
|
||||
}
|
||||
|
||||
function actionLoading(action: PageHeaderAction) {
|
||||
return resolveHeaderValue(action.loading, false);
|
||||
}
|
||||
|
||||
function actionDisabled(action: PageHeaderAction) {
|
||||
return resolveHeaderValue(action.disabled, false);
|
||||
}
|
||||
|
||||
function actionDropdownOptions(action: PageHeaderAction) {
|
||||
return resolveHeaderValue(action.dropdownOptions, []);
|
||||
}
|
||||
|
||||
function runHeaderAction(action: PageHeaderAction) {
|
||||
void action.onClick?.();
|
||||
}
|
||||
|
||||
function runHeaderActionSelect(action: PageHeaderAction) {
|
||||
return (key: string | number, option: DropdownOption) => {
|
||||
void action.onSelect?.(key, option);
|
||||
};
|
||||
}
|
||||
|
||||
function preventWindowFileDrop(event: DragEvent) {
|
||||
const types = Array.from(event.dataTransfer?.types || []);
|
||||
if (!types.includes('Files')) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,394 @@
|
||||
import type { ApiError, ApiErrorDetail } from '../types';
|
||||
|
||||
const TOKEN_KEY = 'capacity_report_token';
|
||||
const DESKTOP_API_BASE = 'http://127.0.0.1:9081';
|
||||
const API_BASE = resolveApiBase();
|
||||
const API_FETCH_RETRIES = 8;
|
||||
const API_FETCH_RETRY_DELAY_MS = 500;
|
||||
const TAURI_HTTP_ERROR_PREFIX = 'CAPAREPORT_HTTP_ERROR:';
|
||||
let onUnauthorized: (() => void) | null = null;
|
||||
let cachedTauriInvoke: TauriInvoke | null = null;
|
||||
|
||||
type TauriInvoke = <T>(command: string, args?: Record<string, unknown>) => Promise<T>;
|
||||
|
||||
export interface DownloadResult {
|
||||
saved: boolean;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
interface TauriDownloadHeader {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface TauriDownloadRequest {
|
||||
url: string;
|
||||
method: 'GET' | 'POST';
|
||||
filename: string;
|
||||
headers: TauriDownloadHeader[];
|
||||
body?: string;
|
||||
}
|
||||
|
||||
interface TauriDownloadResult {
|
||||
saved: boolean;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export class ApiRequestError extends Error {
|
||||
status: number;
|
||||
code?: string;
|
||||
detail?: ApiErrorDetail;
|
||||
|
||||
constructor(message: string, status: number, detail?: ApiErrorDetail) {
|
||||
super(message);
|
||||
this.name = 'ApiRequestError';
|
||||
this.status = status;
|
||||
this.code = detail?.code;
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
|
||||
export function getToken(): string {
|
||||
return localStorage.getItem(TOKEN_KEY) || '';
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
document.cookie = `token=${token}; path=/; max-age=${60 * 60 * 24 * 30}`;
|
||||
}
|
||||
|
||||
export function clearToken(): void {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
document.cookie = 'token=; path=/; max-age=0';
|
||||
}
|
||||
|
||||
export function setUnauthorizedHandler(handler: () => void): void {
|
||||
onUnauthorized = handler;
|
||||
}
|
||||
|
||||
export async function apiGet<T>(url: string): Promise<T> {
|
||||
return request<T>(url, { method: 'GET' });
|
||||
}
|
||||
|
||||
export async function apiPost<T>(url: string, body?: unknown): Promise<T> {
|
||||
return request<T>(url, {
|
||||
method: 'POST',
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
export async function request<T>(url: string, init: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
const token = isLoginRequest(url) ? '' : getToken();
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
const response = await fetchWithRetry(apiUrl(url), { ...init, headers });
|
||||
if (response.status === 401) {
|
||||
if (isLoginRequest(url)) {
|
||||
const error = await readError(response);
|
||||
throw new ApiRequestError(error.message, response.status, error.detail);
|
||||
}
|
||||
|
||||
clearToken();
|
||||
onUnauthorized?.();
|
||||
throw new ApiRequestError('登录已过期,请重新登录', response.status, {
|
||||
code: 'UNAUTHORIZED',
|
||||
message: '登录已过期,请重新登录'
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await readError(response);
|
||||
throw new ApiRequestError(error.message, response.status, error.detail);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function upload<T>(
|
||||
url: string,
|
||||
formData: FormData,
|
||||
onProgress?: (percent: number) => void
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', apiUrl(url));
|
||||
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
xhr.upload.addEventListener('progress', event => {
|
||||
if (event.lengthComputable) {
|
||||
onProgress?.(Math.round((event.loaded / event.total) * 100));
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status === 401) {
|
||||
clearToken();
|
||||
onUnauthorized?.();
|
||||
reject(
|
||||
new ApiRequestError('登录已过期,请重新登录', xhr.status, {
|
||||
code: 'UNAUTHORIZED',
|
||||
message: '登录已过期,请重新登录'
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (xhr.status < 200 || xhr.status >= 300) {
|
||||
reject(new Error(parseXhrError(xhr.responseText)));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(JSON.parse(xhr.responseText) as T);
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => reject(new Error('网络错误')));
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
export async function download(url: string, body: unknown, filename: string): Promise<DownloadResult> {
|
||||
const headers = new Headers({ 'Content-Type': 'application/json' });
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
if (isTauriRuntime()) {
|
||||
return await downloadWithTauri({
|
||||
url: apiUrl(url),
|
||||
method: 'POST',
|
||||
filename,
|
||||
headers: headersToPairs(headers),
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetchWithRetry(apiUrl(url), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await readError(response);
|
||||
throw new ApiRequestError(error.message, response.status, error.detail);
|
||||
}
|
||||
|
||||
return await saveBlobResponse(response, parseFilename(response.headers.get('content-disposition')) || filename);
|
||||
}
|
||||
|
||||
export async function downloadGet(url: string, fallbackFilename: string): Promise<DownloadResult> {
|
||||
const headers = new Headers();
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
if (isTauriRuntime()) {
|
||||
return await downloadWithTauri({
|
||||
url: apiUrl(url),
|
||||
method: 'GET',
|
||||
filename: fallbackFilename,
|
||||
headers: headersToPairs(headers)
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetchWithRetry(apiUrl(url), { headers });
|
||||
if (response.status === 401) {
|
||||
clearToken();
|
||||
onUnauthorized?.();
|
||||
throw new ApiRequestError('登录已过期,请重新登录', response.status, {
|
||||
code: 'UNAUTHORIZED',
|
||||
message: '登录已过期,请重新登录'
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await readError(response);
|
||||
throw new ApiRequestError(error.message, response.status, error.detail);
|
||||
}
|
||||
|
||||
const filename = parseFilename(response.headers.get('content-disposition')) || fallbackFilename;
|
||||
return await saveBlobResponse(response, filename);
|
||||
}
|
||||
|
||||
async function readError(response: Response): Promise<{ message: string; detail?: ApiErrorDetail }> {
|
||||
try {
|
||||
const data = (await response.json()) as ApiError;
|
||||
return parseApiError(data, response.statusText);
|
||||
} catch {
|
||||
return { message: response.statusText || '请求失败' };
|
||||
}
|
||||
}
|
||||
|
||||
function parseXhrError(text: string): string {
|
||||
try {
|
||||
const data = JSON.parse(text) as ApiError;
|
||||
return parseApiError(data, '请求失败').message;
|
||||
} catch {
|
||||
return text || '请求失败';
|
||||
}
|
||||
}
|
||||
|
||||
function parseApiError(data: ApiError, fallback: string): { message: string; detail?: ApiErrorDetail } {
|
||||
if (typeof data.detail === 'object' && data.detail !== null) {
|
||||
return {
|
||||
message: data.detail.message || data.message || data.error || fallback,
|
||||
detail: data.detail
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: data.detail || data.error || data.message || fallback,
|
||||
detail: data.code ? { code: data.code, message: data.message || data.error } : undefined
|
||||
};
|
||||
}
|
||||
|
||||
async function saveBlobResponse(response: Response, filename: string): Promise<DownloadResult> {
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = objectUrl;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
return { saved: true };
|
||||
}
|
||||
|
||||
async function downloadWithTauri(request: TauriDownloadRequest): Promise<DownloadResult> {
|
||||
try {
|
||||
const invoke = await getTauriInvoke();
|
||||
const result = await invoke<TauriDownloadResult>('download_to_file', { request });
|
||||
return { saved: result.saved, path: result.path };
|
||||
} catch (error) {
|
||||
throw parseTauriDownloadError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function openPathInFileManager(path: string): Promise<void> {
|
||||
if (!isTauriRuntime()) return;
|
||||
const invoke = await getTauriInvoke();
|
||||
await invoke('open_path_in_file_manager', { path });
|
||||
}
|
||||
|
||||
async function getTauriInvoke(): Promise<TauriInvoke> {
|
||||
if (!cachedTauriInvoke) {
|
||||
const api = await import('@tauri-apps/api/core');
|
||||
cachedTauriInvoke = api.invoke;
|
||||
}
|
||||
return cachedTauriInvoke;
|
||||
}
|
||||
|
||||
function headersToPairs(headers: Headers): TauriDownloadHeader[] {
|
||||
const pairs: TauriDownloadHeader[] = [];
|
||||
headers.forEach((value, name) => {
|
||||
pairs.push({ name, value });
|
||||
});
|
||||
return pairs;
|
||||
}
|
||||
|
||||
function parseTauriDownloadError(error: unknown): Error {
|
||||
const message = error instanceof Error ? error.message : String(error || '下载失败');
|
||||
if (!message.startsWith(TAURI_HTTP_ERROR_PREFIX)) {
|
||||
return error instanceof Error ? error : new Error(message);
|
||||
}
|
||||
|
||||
const payload = message.slice(TAURI_HTTP_ERROR_PREFIX.length);
|
||||
const separator = payload.indexOf(':');
|
||||
const status = Number(payload.slice(0, separator));
|
||||
const body = separator >= 0 ? payload.slice(separator + 1) : '';
|
||||
const parsed = parseApiErrorText(body, `下载失败 (${status || 'HTTP'})`);
|
||||
|
||||
if (status === 401) {
|
||||
clearToken();
|
||||
onUnauthorized?.();
|
||||
}
|
||||
|
||||
return new ApiRequestError(parsed.message, status || 500, parsed.detail);
|
||||
}
|
||||
|
||||
function parseApiErrorText(text: string, fallback: string): { message: string; detail?: ApiErrorDetail } {
|
||||
try {
|
||||
const data = JSON.parse(text) as ApiError;
|
||||
return parseApiError(data, fallback);
|
||||
} catch {
|
||||
return { message: text || fallback };
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWithRetry(url: string, init: RequestInit): Promise<Response> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt <= API_FETCH_RETRIES; attempt += 1) {
|
||||
try {
|
||||
return await fetch(url, init);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt === API_FETCH_RETRIES) {
|
||||
break;
|
||||
}
|
||||
await sleep(API_FETCH_RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error ? lastError : new Error('网络请求失败');
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function parseFilename(disposition: string | null): string {
|
||||
if (!disposition) return '';
|
||||
|
||||
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
|
||||
if (utf8Match?.[1]) {
|
||||
return decodeURIComponent(utf8Match[1]);
|
||||
}
|
||||
|
||||
const plainMatch = disposition.match(/filename="?([^";]+)"?/i);
|
||||
return plainMatch?.[1] || '';
|
||||
}
|
||||
|
||||
export function apiUrl(url: string): string {
|
||||
if (!API_BASE || /^https?:\/\//i.test(url)) {
|
||||
return url;
|
||||
}
|
||||
return `${API_BASE}${url.startsWith('/') ? url : `/${url}`}`;
|
||||
}
|
||||
|
||||
export function getApiBaseUrl(): string {
|
||||
return API_BASE || window.location.origin;
|
||||
}
|
||||
|
||||
function resolveApiBase(): string {
|
||||
const configured = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '');
|
||||
if (configured) {
|
||||
return configured;
|
||||
}
|
||||
return isTauriRuntime() ? DESKTOP_API_BASE : '';
|
||||
}
|
||||
|
||||
function isTauriRuntime(): boolean {
|
||||
const win = window as Window & {
|
||||
__TAURI__?: unknown;
|
||||
__TAURI_INTERNALS__?: unknown;
|
||||
};
|
||||
return Boolean(win.__TAURI__ || win.__TAURI_INTERNALS__ || navigator.userAgent.includes('Tauri'));
|
||||
}
|
||||
|
||||
function isLoginRequest(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin);
|
||||
return parsed.pathname === '/api/login';
|
||||
} catch {
|
||||
return url.replace(/^https?:\/\/[^/]+/i, '').split('?')[0] === '/api/login';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,980 @@
|
||||
<template>
|
||||
<n-config-provider :theme="naiveTheme" :theme-overrides="naiveThemeOverrides">
|
||||
<div class="cap-dashboard">
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loadingStatus" class="cap-center">
|
||||
<n-spin size="large" />
|
||||
</div>
|
||||
|
||||
<!-- 无结果表:居中提示 -->
|
||||
<div v-else-if="!ready" class="cap-center cap-empty">
|
||||
<div class="cap-empty-orb"></div>
|
||||
<p class="cap-empty-text">请先进行数据处理</p>
|
||||
<p class="cap-empty-sub">检测到数据库中暂无 4G / 5G 结果表,完成「数据处理」后即可查看容量看板。</p>
|
||||
</div>
|
||||
|
||||
<!-- 看板主体 -->
|
||||
<div v-else class="cap-body">
|
||||
<div class="cap-overview">
|
||||
<n-spin :show="loadingOverview">
|
||||
<!-- 汇总卡片 -->
|
||||
<section class="cap-cards">
|
||||
<div
|
||||
v-for="(card, idx) in cards"
|
||||
:key="card.key"
|
||||
class="cap-card"
|
||||
:class="card.tone"
|
||||
:style="{ animationDelay: idx * 50 + 'ms' }"
|
||||
>
|
||||
<div class="cap-card-icon"><n-icon><component :is="card.icon" /></n-icon></div>
|
||||
<div class="cap-card-body">
|
||||
<div class="cap-card-value">{{ card.display }}<span v-if="card.unit" class="cap-card-unit">{{ card.unit }}</span></div>
|
||||
<div class="cap-card-label">{{ card.label }}</div>
|
||||
</div>
|
||||
<div class="cap-card-glow"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 图表区:两行三列 -->
|
||||
<section class="cap-charts">
|
||||
<div class="cap-panel">
|
||||
<div class="cap-panel-head"><span class="bar"></span>负荷问题分布</div>
|
||||
<e-chart :option="problemOption" :height="chartHeight" />
|
||||
</div>
|
||||
<div class="cap-panel">
|
||||
<div class="cap-panel-head"><span class="bar"></span>上行利用率分布({{ overview?.labels.ul }})</div>
|
||||
<e-chart :option="ulHistOption" :height="chartHeight" />
|
||||
</div>
|
||||
<div class="cap-panel">
|
||||
<div class="cap-panel-head"><span class="bar"></span>下行利用率分布({{ overview?.labels.dl }})</div>
|
||||
<e-chart :option="dlHistOption" :height="chartHeight" />
|
||||
</div>
|
||||
<div class="cap-panel">
|
||||
<div class="cap-panel-head"><span class="bar"></span>制式分布(总数 / 高负荷)</div>
|
||||
<e-chart :option="systemOption" :height="chartHeight" />
|
||||
</div>
|
||||
<div class="cap-panel">
|
||||
<div class="cap-panel-head"><span class="bar"></span>站型分布</div>
|
||||
<e-chart :option="stationOption" :height="chartHeight" />
|
||||
</div>
|
||||
<div class="cap-panel">
|
||||
<div class="cap-panel-head"><span class="bar"></span>频段标记小区 Top</div>
|
||||
<e-chart :option="freqOption" :height="chartHeight" />
|
||||
</div>
|
||||
</section>
|
||||
</n-spin>
|
||||
</div>
|
||||
|
||||
<!-- 高负荷 / 预警 清单 -->
|
||||
<section class="cap-panel cap-list">
|
||||
<div class="cap-list-head">
|
||||
<div class="cap-panel-head"><span class="bar"></span>问题小区清单</div>
|
||||
<div class="cap-list-filters">
|
||||
<div class="cap-seg sm">
|
||||
<button
|
||||
v-for="opt in problemFilters"
|
||||
:key="opt.value"
|
||||
type="button"
|
||||
class="cap-seg-btn"
|
||||
:class="{ active: problemFilter === opt.value }"
|
||||
@click="setProblemFilter(opt.value)"
|
||||
>{{ opt.label }}</button>
|
||||
</div>
|
||||
<n-input
|
||||
v-model:value="keyword"
|
||||
class="cap-search"
|
||||
size="small"
|
||||
clearable
|
||||
placeholder="CGI / 小区名称"
|
||||
@keyup.enter="searchCells"
|
||||
@clear="searchCells"
|
||||
>
|
||||
<template #prefix><n-icon><SearchOutline /></n-icon></template>
|
||||
</n-input>
|
||||
<n-button size="small" type="primary" ghost @click="searchCells">查询</n-button>
|
||||
<n-button size="small" tertiary :loading="exporting" :disabled="!cellTotal" @click="exportCells">
|
||||
<template #icon><n-icon><DownloadOutline /></n-icon></template>
|
||||
导出
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cap-table-wrap">
|
||||
<n-data-table
|
||||
class="cap-table"
|
||||
size="small"
|
||||
:columns="columns"
|
||||
:data="cells"
|
||||
:loading="loadingCells"
|
||||
:bordered="false"
|
||||
:single-line="false"
|
||||
:scroll-x="1180"
|
||||
:row-props="rowProps"
|
||||
flex-height
|
||||
style="height: 100%"
|
||||
/>
|
||||
</div>
|
||||
<div class="cap-pager">
|
||||
<span class="cap-pager-total">共 {{ cellTotal }} 个问题小区</span>
|
||||
<n-pagination
|
||||
:page="page"
|
||||
:page-count="pageCount"
|
||||
:page-size="pageSize"
|
||||
size="small"
|
||||
@update:page="changePage"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 小区详情抽屉 -->
|
||||
<n-drawer v-model:show="detailVisible" :width="detailWidth" placement="right" class="cap-drawer">
|
||||
<n-drawer-content :native-scrollbar="false" closable>
|
||||
<template #header>
|
||||
<div class="cap-detail-head">
|
||||
<span class="cap-detail-id">{{ detail?.id }}</span>
|
||||
<span v-if="detail?.name" class="cap-detail-name">{{ detail?.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="loadingDetail" class="cap-center"><n-spin /></div>
|
||||
<div v-else-if="detail" class="cap-detail">
|
||||
<div class="cap-detail-tags">
|
||||
<span class="cap-tag" :class="problemClass(detailProblem)">{{ detailProblem || '正常' }}</span>
|
||||
<span class="cap-tag plain">{{ detailValue('制式') }}</span>
|
||||
<span class="cap-tag plain">{{ detailValue('带宽') }}</span>
|
||||
<span class="cap-tag plain">{{ detailValue('站型') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="cap-detail-grid">
|
||||
<div v-for="m in detailMetrics" :key="m.label" class="cap-metric">
|
||||
<div class="cap-metric-label">{{ m.label }}</div>
|
||||
<div class="cap-metric-value">{{ m.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="detailSuggestion" class="cap-suggest">
|
||||
<div class="cap-suggest-head">
|
||||
<span class="cap-suggest-title"><n-icon><BulbOutline /></n-icon> 优化建议</span>
|
||||
<button class="cap-icon-btn" type="button" title="复制建议" @click="copySuggestion">
|
||||
<n-icon><CopyOutline /></n-icon>
|
||||
</button>
|
||||
</div>
|
||||
<p class="cap-suggest-text">{{ detailSuggestion }}</p>
|
||||
</div>
|
||||
|
||||
<div class="cap-sib">
|
||||
<div class="cap-panel-head sm"><span class="bar"></span>同扇区同运营商小区({{ detail.siblings.length }})</div>
|
||||
<n-empty v-if="!detail.siblings.length" size="small" description="无同扇区其他小区" />
|
||||
<div v-else class="cap-sib-list">
|
||||
<div v-for="sib in detail.siblings" :key="sib.id" class="cap-sib-item">
|
||||
<div class="cap-sib-body">
|
||||
<div class="cap-sib-main">
|
||||
<span class="cap-sib-id">{{ sib.id }}</span>
|
||||
<span class="cap-sib-name">{{ sib.name }}</span>
|
||||
</div>
|
||||
<div class="cap-sib-meta">
|
||||
<span class="cap-tag plain xs">{{ sib.band || '-' }}</span>
|
||||
<span class="cap-tag plain xs">{{ sib.freq || '-' }}</span>
|
||||
<span class="cap-sib-stat">下行 {{ sib.dl }}%</span>
|
||||
<span class="cap-sib-stat">流量 {{ sib.flow }}GB</span>
|
||||
<span class="cap-tag xs" :class="problemClass(sib.problem)">{{ sib.problem }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="cap-icon-btn" type="button" title="查看该小区详情" @click="openDetail(sib.id)">
|
||||
<n-icon><OpenOutline /></n-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
</div>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onBeforeUnmount, onMounted, reactive, ref, watch, type Component } from 'vue';
|
||||
import { NTag, darkTheme, lightTheme, useMessage } from 'naive-ui';
|
||||
import type { DataTableColumns } from 'naive-ui';
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import {
|
||||
ArrowDownOutline,
|
||||
ArrowUpOutline,
|
||||
BulbOutline,
|
||||
CellularOutline,
|
||||
CloudUploadOutline,
|
||||
CopyOutline,
|
||||
DownloadOutline,
|
||||
FlashOutline,
|
||||
OpenOutline,
|
||||
PulseOutline,
|
||||
RefreshOutline,
|
||||
SearchOutline,
|
||||
TrendingUpOutline
|
||||
} from '@vicons/ionicons5';
|
||||
|
||||
import { apiGet, downloadGet } from '../api/client';
|
||||
import { writeClipboardText } from '../composables/clipboard';
|
||||
import { themeName } from '../composables/theme';
|
||||
import { resetPageHeader, setPageHeader, type PageHeaderAction } from '../composables/pageHeader';
|
||||
import EChart from './EChart.vue';
|
||||
|
||||
type Rat = '4g' | '5g';
|
||||
interface NameVal { name: string; value: number }
|
||||
interface CatStat { name: string; total: number; high: number; flagged: number }
|
||||
interface HistBucket { bucket: string; value: number }
|
||||
interface TopCell { id: string; name: string; system: string; band: string; station: string; ul: number; dl: number; flow: number; users: number; problem: string }
|
||||
interface Overview {
|
||||
rat: string;
|
||||
labels: { ul: string; dl: string };
|
||||
summary: { total: number; high_load: number; util_warn: number; flow_warn: number; normal: number; avg_ul: number; avg_dl: number; max_dl: number; avg_flow: number; total_flow: number };
|
||||
problem_pie: NameVal[];
|
||||
by_system: CatStat[];
|
||||
by_station: CatStat[];
|
||||
by_freq: CatStat[];
|
||||
ul_hist: HistBucket[];
|
||||
dl_hist: HistBucket[];
|
||||
top_cells: TopCell[];
|
||||
}
|
||||
interface CellRow { id: string; name: string; system: string; band: string; station: string; freq: string; ul: number; dl: number; flow: number; users: number; problem: string; is_high: string }
|
||||
interface Sibling { id: string; name: string; band: string; freq: string; ul: number; dl: number; flow: number; problem: string }
|
||||
interface CellDetail {
|
||||
rat: string; id: string; name: string; labels: { ul: string; dl: string };
|
||||
ul_field: string; dl_field: string; users_field: string; flow_field: string;
|
||||
detail: Record<string, unknown>; siblings: Sibling[];
|
||||
}
|
||||
|
||||
const message = useMessage();
|
||||
|
||||
// ---------- 主题(跟随应用日/夜切换) ----------
|
||||
const isLight = computed(() => themeName.value === 'light');
|
||||
const naiveTheme = computed(() => (isLight.value ? lightTheme : darkTheme));
|
||||
|
||||
// 看板内 Naive 组件配色覆盖:青色强调色 + 对应明暗底色
|
||||
const naiveDark = {
|
||||
common: { primaryColor: '#22d3ee', primaryColorHover: '#38bdf8', primaryColorPressed: '#0891b2', primaryColorSuppl: '#38bdf8', borderRadius: '8px' },
|
||||
DataTable: {
|
||||
thColor: 'rgba(30, 41, 59, 0.85)', thColorHover: 'rgba(30, 41, 59, 0.95)', thTextColor: '#cbd5e1', thFontWeight: '600',
|
||||
tdColor: 'transparent', tdColorHover: 'rgba(56, 189, 248, 0.08)', tdTextColor: '#dbe4f0',
|
||||
borderColor: 'rgba(148, 163, 184, 0.12)', loadingColor: 'rgba(8, 14, 26, 0.6)'
|
||||
},
|
||||
Input: {
|
||||
color: 'rgba(15, 23, 42, 0.45)', colorFocus: 'rgba(15, 23, 42, 0.7)',
|
||||
border: '1px solid rgba(148, 163, 184, 0.18)', borderHover: '1px solid rgba(56, 189, 248, 0.5)', borderFocus: '1px solid rgba(56, 189, 248, 0.7)',
|
||||
boxShadowFocus: '0 0 0 2px rgba(56, 189, 248, 0.18)', textColor: '#e2e8f0', placeholderColor: '#64748b'
|
||||
},
|
||||
Pagination: {
|
||||
itemColor: 'rgba(15, 23, 42, 0.4)', itemColorHover: 'rgba(56, 189, 248, 0.12)', itemColorPressed: 'rgba(56, 189, 248, 0.18)', itemColorActive: 'rgba(56, 189, 248, 0.16)',
|
||||
itemBorder: '1px solid rgba(148, 163, 184, 0.16)', itemBorderActive: '1px solid #22d3ee',
|
||||
itemTextColor: '#94a3b8', itemTextColorHover: '#e2e8f0', itemTextColorActive: '#22d3ee'
|
||||
},
|
||||
Drawer: { color: '#0c1422' },
|
||||
Empty: { textColor: '#64748b', iconColor: '#475569' }
|
||||
};
|
||||
const naiveLight = {
|
||||
common: { primaryColor: '#0891b2', primaryColorHover: '#06b6d4', primaryColorPressed: '#0e7490', primaryColorSuppl: '#06b6d4', borderRadius: '8px' },
|
||||
DataTable: {
|
||||
thColor: 'rgba(241, 245, 249, 0.9)', thColorHover: 'rgba(226, 232, 240, 0.95)', thTextColor: '#475569', thFontWeight: '600',
|
||||
tdColor: 'transparent', tdColorHover: 'rgba(8, 145, 178, 0.07)', tdTextColor: '#334155',
|
||||
borderColor: 'rgba(15, 23, 42, 0.08)', loadingColor: 'rgba(255, 255, 255, 0.6)'
|
||||
},
|
||||
Input: {
|
||||
color: 'rgba(255, 255, 255, 0.85)', colorFocus: '#ffffff',
|
||||
border: '1px solid rgba(15, 23, 42, 0.12)', borderHover: '1px solid rgba(8, 145, 178, 0.5)', borderFocus: '1px solid rgba(8, 145, 178, 0.7)',
|
||||
boxShadowFocus: '0 0 0 2px rgba(8, 145, 178, 0.15)', textColor: '#1e293b', placeholderColor: '#94a3b8'
|
||||
},
|
||||
Pagination: {
|
||||
itemColor: 'rgba(255, 255, 255, 0.7)', itemColorHover: 'rgba(8, 145, 178, 0.08)', itemColorPressed: 'rgba(8, 145, 178, 0.14)', itemColorActive: 'rgba(8, 145, 178, 0.12)',
|
||||
itemBorder: '1px solid rgba(15, 23, 42, 0.12)', itemBorderActive: '1px solid #0891b2',
|
||||
itemTextColor: '#64748b', itemTextColorHover: '#1e293b', itemTextColorActive: '#0891b2'
|
||||
},
|
||||
Drawer: { color: '#ffffff' },
|
||||
Empty: { textColor: '#94a3b8', iconColor: '#cbd5e1' }
|
||||
};
|
||||
const naiveThemeOverrides = computed(() => (isLight.value ? naiveLight : naiveDark));
|
||||
|
||||
// ECharts 明暗配色
|
||||
const chartPalette = computed(() => (isLight.value
|
||||
? { axisText: '#64748b', axisLine: 'rgba(15,23,42,0.15)', splitLine: 'rgba(15,23,42,0.06)', tooltipBg: 'rgba(255,255,255,0.97)', tooltipText: '#1e293b', tooltipBorder: 'rgba(8,145,178,0.4)', legend: '#64748b', pieBorder: '#ffffff', barTrack: 'rgba(8,145,178,0.18)' }
|
||||
: { axisText: '#94a3b8', axisLine: 'rgba(148,163,184,0.25)', splitLine: 'rgba(148,163,184,0.12)', tooltipBg: 'rgba(15,23,42,0.92)', tooltipText: '#e2e8f0', tooltipBorder: 'rgba(56,189,248,0.35)', legend: '#94a3b8', pieBorder: 'rgba(2,6,23,0.6)', barTrack: 'rgba(56,189,248,0.18)' }
|
||||
));
|
||||
|
||||
const chartHeight = 'clamp(108px, 13vh, 160px)';
|
||||
|
||||
const loadingStatus = ref(true);
|
||||
const ready = ref(false);
|
||||
const rat = ref<Rat>('5g');
|
||||
|
||||
const loadingOverview = ref(false);
|
||||
const overview = ref<Overview | null>(null);
|
||||
|
||||
const cells = ref<CellRow[]>([]);
|
||||
const cellTotal = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = 20;
|
||||
const loadingCells = ref(false);
|
||||
const problemFilter = ref('');
|
||||
const keyword = ref('');
|
||||
const exporting = ref(false);
|
||||
const problemFilters = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '高负荷', value: '高负荷' },
|
||||
{ label: '高流量预警', value: '高流量预警' },
|
||||
{ label: '利用率预警', value: '利用率预警' }
|
||||
];
|
||||
|
||||
const detailVisible = ref(false);
|
||||
const loadingDetail = ref(false);
|
||||
const detail = ref<CellDetail | null>(null);
|
||||
const detailWidth = ref(Math.min(560, typeof window !== 'undefined' ? window.innerWidth - 24 : 560));
|
||||
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(cellTotal.value / pageSize)));
|
||||
|
||||
const PALETTE = ['#38bdf8', '#22d3ee', '#818cf8', '#c084fc', '#fbbf24', '#fb7185', '#34d399', '#f472b6'];
|
||||
const PROBLEM_COLOR: Record<string, string> = {
|
||||
高负荷: '#fb7185',
|
||||
高流量预警: '#fbbf24',
|
||||
利用率预警: '#38bdf8',
|
||||
正常: '#94a3b8'
|
||||
};
|
||||
|
||||
// ---------- 汇总卡片(含数字滚动) ----------
|
||||
const animated = reactive<Record<string, number>>({ total: 0, high_load: 0, util_warn: 0, flow_warn: 0, avg_ul: 0, avg_dl: 0, total_flow: 0 });
|
||||
|
||||
const cards = computed(() => {
|
||||
const defs: { key: keyof typeof animated; label: string; icon: Component; tone: string; unit?: string; digits?: number; flow?: boolean }[] = [
|
||||
{ key: 'total', label: '小区总数', icon: CellularOutline, tone: 'tone-cyan' },
|
||||
{ key: 'high_load', label: '高负荷小区', icon: FlashOutline, tone: 'tone-rose' },
|
||||
{ key: 'util_warn', label: '利用率预警', icon: PulseOutline, tone: 'tone-blue' },
|
||||
{ key: 'flow_warn', label: '高流量预警', icon: TrendingUpOutline, tone: 'tone-amber' },
|
||||
{ key: 'avg_ul', label: '平均上行利用率', icon: ArrowUpOutline, tone: 'tone-violet', unit: '%', digits: 1 },
|
||||
{ key: 'avg_dl', label: '平均下行利用率', icon: ArrowDownOutline, tone: 'tone-cyan', unit: '%', digits: 1 },
|
||||
{ key: 'total_flow', label: '总日均流量', icon: CloudUploadOutline, tone: 'tone-emerald', flow: true }
|
||||
];
|
||||
return defs.map(d => {
|
||||
if (d.flow) {
|
||||
const f = formatFlow(animated[d.key]);
|
||||
return { ...d, display: f.value, unit: f.unit };
|
||||
}
|
||||
return { ...d, display: formatNum(animated[d.key], d.digits ?? 0) };
|
||||
});
|
||||
});
|
||||
|
||||
function formatNum(value: number, digits: number): string {
|
||||
const fixed = Number(value).toFixed(digits);
|
||||
const [int, dec] = fixed.split('.');
|
||||
const withSep = int.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
return dec ? `${withSep}.${dec}` : withSep;
|
||||
}
|
||||
|
||||
// 入参单位为 GB,自动换算到合适单位(B/KB/MB/GB/TB/PB)
|
||||
function formatFlow(gb: number): { value: string; unit: string } {
|
||||
const v = Number(gb) || 0;
|
||||
if (v >= 1) {
|
||||
const units = ['GB', 'TB', 'PB', 'EB'];
|
||||
let i = 0;
|
||||
let n = v;
|
||||
while (n >= 1024 && i < units.length - 1) { n /= 1024; i += 1; }
|
||||
const digits = n >= 100 ? 0 : n >= 10 ? 1 : 2;
|
||||
return { value: formatNum(n, digits), unit: units[i] };
|
||||
}
|
||||
const units = ['GB', 'MB', 'KB', 'B'];
|
||||
let i = 0;
|
||||
let n = v;
|
||||
while (n < 1 && i < units.length - 1) { n *= 1024; i += 1; }
|
||||
const digits = n >= 100 ? 0 : 1;
|
||||
return { value: formatNum(n, digits), unit: units[i] };
|
||||
}
|
||||
|
||||
function animateCards(s: Overview['summary']) {
|
||||
const targets: Record<string, number> = {
|
||||
total: s.total, high_load: s.high_load, util_warn: s.util_warn,
|
||||
flow_warn: s.flow_warn, avg_ul: s.avg_ul, avg_dl: s.avg_dl, total_flow: s.total_flow
|
||||
};
|
||||
const start = performance.now();
|
||||
const from = { ...animated };
|
||||
const step = (now: number) => {
|
||||
const t = Math.min(1, (now - start) / 700);
|
||||
const ease = 1 - Math.pow(1 - t, 3);
|
||||
for (const k of Object.keys(targets)) {
|
||||
animated[k] = from[k] + (targets[k] - from[k]) * ease;
|
||||
}
|
||||
if (t < 1) requestAnimationFrame(step);
|
||||
else Object.assign(animated, targets);
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
// ---------- echarts 通用主题 ----------
|
||||
function baseGrid(): EChartsOption['grid'] {
|
||||
return { left: 8, right: 16, top: 24, bottom: 6, containLabel: true };
|
||||
}
|
||||
function axisLine() {
|
||||
return { lineStyle: { color: chartPalette.value.axisLine } };
|
||||
}
|
||||
function axisLabel() {
|
||||
return { color: chartPalette.value.axisText, fontSize: 11 };
|
||||
}
|
||||
function splitLine() {
|
||||
return { lineStyle: { color: chartPalette.value.splitLine } };
|
||||
}
|
||||
function tooltipBase(): EChartsOption['tooltip'] {
|
||||
const p = chartPalette.value;
|
||||
return {
|
||||
backgroundColor: p.tooltipBg,
|
||||
borderColor: p.tooltipBorder,
|
||||
borderWidth: 1,
|
||||
textStyle: { color: p.tooltipText, fontSize: 12 }
|
||||
};
|
||||
}
|
||||
function legendStyle() {
|
||||
return { color: chartPalette.value.legend, fontSize: 11 };
|
||||
}
|
||||
// 矮图表的数值轴缩写,避免刻度文字过长 / 纵向重叠
|
||||
function abbrNum(v: number): string {
|
||||
const n = Math.abs(v);
|
||||
if (n >= 1e8) return `${(v / 1e8).toFixed(1).replace(/\.0$/, '')}亿`;
|
||||
if (n >= 1e4) return `${(v / 1e4).toFixed(1).replace(/\.0$/, '')}万`;
|
||||
if (n >= 1e3) return `${(v / 1e3).toFixed(1).replace(/\.0$/, '')}k`;
|
||||
return String(v);
|
||||
}
|
||||
function valueAxis(): EChartsOption['yAxis'] {
|
||||
return { type: 'value', splitNumber: 3, splitLine: splitLine(), axisLabel: { ...axisLabel(), formatter: (v: number) => abbrNum(v) } };
|
||||
}
|
||||
|
||||
const problemOption = computed<EChartsOption>(() => {
|
||||
const data = (overview.value?.problem_pie || []).map(d => ({
|
||||
name: d.name, value: d.value,
|
||||
itemStyle: { color: PROBLEM_COLOR[d.name] || '#64748b' }
|
||||
}));
|
||||
return {
|
||||
tooltip: { ...tooltipBase(), trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
legend: { bottom: 0, textStyle: legendStyle(), icon: 'circle', itemWidth: 9, itemHeight: 9 },
|
||||
series: [{
|
||||
type: 'pie', radius: ['46%', '72%'], center: ['50%', '42%'], avoidLabelOverlap: true,
|
||||
itemStyle: { borderColor: chartPalette.value.pieBorder, borderWidth: 2 },
|
||||
label: { show: false }, labelLine: { show: false },
|
||||
data
|
||||
}]
|
||||
};
|
||||
});
|
||||
|
||||
function makeHist(buckets: HistBucket[], c0: string, c1: string): EChartsOption {
|
||||
return {
|
||||
tooltip: { ...tooltipBase(), trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
grid: baseGrid(),
|
||||
xAxis: { type: 'category', data: buckets.map(b => b.bucket), axisLine: axisLine(), axisLabel: { ...axisLabel(), rotate: 30 }, axisTick: { show: false } },
|
||||
yAxis: valueAxis(),
|
||||
series: [{
|
||||
type: 'bar', barWidth: '58%', data: buckets.map(b => b.value),
|
||||
itemStyle: {
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: c0 }, { offset: 1, color: c1 }] }
|
||||
}
|
||||
}]
|
||||
};
|
||||
}
|
||||
const ulHistOption = computed<EChartsOption>(() => makeHist(overview.value?.ul_hist || [], '#818cf8', 'rgba(129,140,248,0.15)'));
|
||||
const dlHistOption = computed<EChartsOption>(() => makeHist(overview.value?.dl_hist || [], '#22d3ee', 'rgba(56,189,248,0.15)'));
|
||||
|
||||
const systemOption = computed<EChartsOption>(() => {
|
||||
const stats = overview.value?.by_system || [];
|
||||
return {
|
||||
tooltip: { ...tooltipBase(), trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
legend: { top: 0, right: 0, textStyle: legendStyle(), itemWidth: 10, itemHeight: 10 },
|
||||
grid: baseGrid(),
|
||||
xAxis: { type: 'category', data: stats.map(s => s.name), axisLine: axisLine(), axisLabel: { ...axisLabel(), interval: 0, rotate: stats.length > 5 ? 24 : 0 }, axisTick: { show: false } },
|
||||
yAxis: valueAxis(),
|
||||
series: [
|
||||
{ name: '总数', type: 'bar', barGap: '-100%', barWidth: '46%', itemStyle: { borderRadius: [3, 3, 0, 0], color: chartPalette.value.barTrack }, data: stats.map(s => s.total) },
|
||||
{ name: '高负荷', type: 'bar', barWidth: '46%', itemStyle: { borderRadius: [3, 3, 0, 0], color: '#fb7185' }, data: stats.map(s => s.high) }
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
const stationOption = computed<EChartsOption>(() => {
|
||||
const data = (overview.value?.by_station || []).map((s, i) => ({ name: s.name, value: s.total, itemStyle: { color: PALETTE[i % PALETTE.length] } }));
|
||||
return {
|
||||
tooltip: { ...tooltipBase(), trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
legend: { bottom: 0, textStyle: legendStyle(), icon: 'circle', itemWidth: 9, itemHeight: 9 },
|
||||
series: [{
|
||||
type: 'pie', radius: ['42%', '70%'], center: ['50%', '42%'],
|
||||
itemStyle: { borderColor: chartPalette.value.pieBorder, borderWidth: 2 },
|
||||
label: { show: false }, labelLine: { show: false }, data
|
||||
}]
|
||||
};
|
||||
});
|
||||
|
||||
const freqOption = computed<EChartsOption>(() => {
|
||||
const stats = [...(overview.value?.by_freq || [])].sort((a, b) => a.flagged - b.flagged);
|
||||
return {
|
||||
tooltip: { ...tooltipBase(), trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
grid: { left: 8, right: 24, top: 10, bottom: 6, containLabel: true },
|
||||
xAxis: { type: 'value', splitNumber: 3, splitLine: splitLine(), axisLabel: { ...axisLabel(), formatter: (v: number) => abbrNum(v) } },
|
||||
yAxis: { type: 'category', data: stats.map(s => s.name), axisLine: axisLine(), axisLabel: axisLabel(), axisTick: { show: false } },
|
||||
series: [{
|
||||
type: 'bar', barWidth: '56%', data: stats.map(s => s.flagged),
|
||||
itemStyle: {
|
||||
borderRadius: [0, 4, 4, 0],
|
||||
color: { type: 'linear', x: 0, y: 0, x2: 1, y2: 0, colorStops: [{ offset: 0, color: 'rgba(129,140,248,0.2)' }, { offset: 1, color: '#818cf8' }] }
|
||||
}
|
||||
}]
|
||||
};
|
||||
});
|
||||
|
||||
// ---------- 清单表格 ----------
|
||||
const columns = computed<DataTableColumns<CellRow>>(() => [
|
||||
{ title: overview.value?.rat === '5g' ? 'NCGI' : 'CGI', key: 'id', width: 180, ellipsis: { tooltip: true }, fixed: 'left' },
|
||||
{ title: '小区名称', key: 'name', minWidth: 160, ellipsis: { tooltip: true } },
|
||||
{ title: '制式', key: 'system', width: 70 },
|
||||
{ title: '带宽', key: 'band', width: 70 },
|
||||
{ title: '站型', key: 'station', width: 70 },
|
||||
{ title: '频段', key: 'freq', width: 90, ellipsis: { tooltip: true } },
|
||||
{ title: '上行%', key: 'ul', width: 80, render: r => `${r.ul}%` },
|
||||
{ title: '下行%', key: 'dl', width: 80, render: r => `${r.dl}%` },
|
||||
{ title: '日均流量', key: 'flow', width: 96, render: r => `${r.flow}` },
|
||||
{ title: '用户数', key: 'users', width: 80 },
|
||||
{
|
||||
title: '问题', key: 'problem', width: 110, fixed: 'right',
|
||||
render: r => h(NTag, { size: 'small', round: true, bordered: false, color: tagColor(r.problem) }, { default: () => r.problem })
|
||||
}
|
||||
]);
|
||||
|
||||
function tagColor(problem: string) {
|
||||
const c = PROBLEM_COLOR[problem] || '#64748b';
|
||||
return { color: isLight.value ? 'rgba(100,116,139,0.1)' : 'rgba(148,163,184,0.14)', textColor: c, borderColor: 'transparent' };
|
||||
}
|
||||
function problemClass(problem: string) {
|
||||
if (problem === '高负荷') return 'is-rose';
|
||||
if (problem === '高流量预警') return 'is-amber';
|
||||
if (problem === '利用率预警') return 'is-blue';
|
||||
return 'is-slate';
|
||||
}
|
||||
function rowProps(row: CellRow) {
|
||||
return { class: 'cap-row', style: 'cursor:pointer', onClick: () => openDetail(row.id) };
|
||||
}
|
||||
|
||||
// ---------- 详情 ----------
|
||||
const detailProblem = computed(() => detailValue('高负荷问题'));
|
||||
const detailSuggestion = computed(() => detailValue('优化建议'));
|
||||
const detailMetrics = computed(() => {
|
||||
const d = detail.value;
|
||||
if (!d) return [] as { label: string; value: string }[];
|
||||
const det = d.detail;
|
||||
const pct = (key: string) => { const v = det[key]; return v === '' || v === null || v === undefined ? '-' : `${(Number(v) * 100).toFixed(1)}%`; };
|
||||
const raw = (key: string) => { const v = det[key]; return v === '' || v === null || v === undefined ? '-' : String(v); };
|
||||
return [
|
||||
{ label: '物理站', value: raw('物理站') },
|
||||
{ label: '扇区', value: raw('扇区') },
|
||||
{ label: d.labels.ul, value: pct(d.ul_field) },
|
||||
{ label: d.labels.dl, value: pct(d.dl_field) },
|
||||
{ label: '日均流量(GB)', value: raw(d.flow_field) },
|
||||
{ label: '用户数', value: raw(d.users_field) },
|
||||
{ label: '小区功率', value: raw('功率') },
|
||||
{ label: '是否高负荷', value: raw('是否高负荷小区') }
|
||||
];
|
||||
});
|
||||
function detailValue(key: string): string {
|
||||
const v = detail.value?.detail?.[key];
|
||||
return v === undefined || v === null ? '' : String(v);
|
||||
}
|
||||
|
||||
// ---------- 数据加载 ----------
|
||||
async function loadStatus() {
|
||||
loadingStatus.value = true;
|
||||
try {
|
||||
const status = await apiGet<{ ready: boolean }>('/api/dashboard/status');
|
||||
ready.value = status.ready;
|
||||
if (ready.value) await loadAll();
|
||||
} catch (error) {
|
||||
ready.value = false;
|
||||
message.error(error instanceof Error ? error.message : '加载失败');
|
||||
} finally {
|
||||
loadingStatus.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
await Promise.all([loadOverview(), loadCells()]);
|
||||
}
|
||||
|
||||
async function loadOverview() {
|
||||
loadingOverview.value = true;
|
||||
try {
|
||||
const data = await apiGet<Overview>(`/api/dashboard/overview?rat=${rat.value}`);
|
||||
overview.value = data;
|
||||
animateCards(data.summary);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '加载概览失败');
|
||||
} finally {
|
||||
loadingOverview.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCells() {
|
||||
loadingCells.value = true;
|
||||
try {
|
||||
const qs = `rat=${rat.value}&problem=${encodeURIComponent(problemFilter.value)}&keyword=${encodeURIComponent(keyword.value.trim())}&page=${page.value}&page_size=${pageSize}`;
|
||||
const data = await apiGet<{ items: CellRow[]; total: number }>(`/api/dashboard/cells?${qs}`);
|
||||
cells.value = data.items;
|
||||
cellTotal.value = data.total;
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '加载清单失败');
|
||||
} finally {
|
||||
loadingCells.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function exportCells() {
|
||||
if (!cellTotal.value) return;
|
||||
exporting.value = true;
|
||||
try {
|
||||
const qs = `rat=${rat.value}&problem=${encodeURIComponent(problemFilter.value)}&keyword=${encodeURIComponent(keyword.value.trim())}`;
|
||||
const filename = `问题小区清单_${rat.value.toUpperCase()}.csv`;
|
||||
const result = await downloadGet(`/api/dashboard/export?${qs}`, filename);
|
||||
if (result.saved) message.success('问题小区清单已导出');
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '导出失败');
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copySuggestion() {
|
||||
if (!detailSuggestion.value) return;
|
||||
try {
|
||||
await writeClipboardText(detailSuggestion.value);
|
||||
message.success('优化建议已复制');
|
||||
} catch {
|
||||
message.error('复制失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(id: string) {
|
||||
detailVisible.value = true;
|
||||
loadingDetail.value = true;
|
||||
detail.value = null;
|
||||
try {
|
||||
detail.value = await apiGet<CellDetail>(`/api/dashboard/cell?rat=${rat.value}&id=${encodeURIComponent(id)}`);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '加载详情失败');
|
||||
detailVisible.value = false;
|
||||
} finally {
|
||||
loadingDetail.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 顶部框架页头(标题/制式切换/刷新) ----------
|
||||
function buildHeaderActions(): PageHeaderAction[] {
|
||||
return [
|
||||
{ key: 'rat-4g', label: '4G', type: rat.value === '4g' ? 'primary' : 'default', variant: rat.value === '4g' ? 'solid' : 'outline', onClick: () => switchRat('4g') },
|
||||
{ key: 'rat-5g', label: '5G', type: rat.value === '5g' ? 'primary' : 'default', variant: rat.value === '5g' ? 'solid' : 'outline', onClick: () => switchRat('5g') },
|
||||
{ key: 'dashboard-refresh', label: '刷新', icon: RefreshOutline, variant: 'outline', loading: loadingOverview, disabled: loadingOverview, onClick: reload }
|
||||
];
|
||||
}
|
||||
function applyPageHeader() {
|
||||
if (!ready.value) {
|
||||
resetPageHeader();
|
||||
return;
|
||||
}
|
||||
setPageHeader({ actions: buildHeaderActions() });
|
||||
}
|
||||
watch([rat, ready], applyPageHeader);
|
||||
|
||||
function switchRat(value: Rat) {
|
||||
if (rat.value === value) return;
|
||||
rat.value = value;
|
||||
page.value = 1;
|
||||
problemFilter.value = '';
|
||||
keyword.value = '';
|
||||
void loadAll();
|
||||
}
|
||||
function setProblemFilter(value: string) {
|
||||
if (problemFilter.value === value) return;
|
||||
problemFilter.value = value;
|
||||
page.value = 1;
|
||||
void loadCells();
|
||||
}
|
||||
function searchCells() {
|
||||
page.value = 1;
|
||||
void loadCells();
|
||||
}
|
||||
function changePage(value: number) {
|
||||
page.value = value;
|
||||
void loadCells();
|
||||
}
|
||||
function reload() {
|
||||
void loadAll();
|
||||
}
|
||||
|
||||
onMounted(loadStatus);
|
||||
onBeforeUnmount(resetPageHeader);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 主题变量定义在 documentElement 上,使 teleport 到 body 的详情抽屉也能继承 */
|
||||
:global([data-theme='dark']) {
|
||||
--cap-bg:
|
||||
radial-gradient(1200px 600px at 12% -10%, rgba(56, 189, 248, 0.12), transparent 60%),
|
||||
radial-gradient(1000px 600px at 100% 0%, rgba(129, 140, 248, 0.12), transparent 55%),
|
||||
linear-gradient(180deg, #0b1220 0%, #070b15 100%);
|
||||
--cap-panel-bg: linear-gradient(160deg, rgba(30, 41, 59, 0.5), rgba(15, 23, 42, 0.32));
|
||||
--cap-card-bg: linear-gradient(160deg, rgba(30, 41, 59, 0.55), rgba(15, 23, 42, 0.35));
|
||||
--cap-panel-border: rgba(148, 163, 184, 0.14);
|
||||
--cap-shadow: none;
|
||||
--cap-icon-bg: rgba(148, 163, 184, 0.1);
|
||||
--cap-control-bg: rgba(15, 23, 42, 0.6);
|
||||
--cap-control-border: rgba(148, 163, 184, 0.16);
|
||||
--cap-text: #e2e8f0;
|
||||
--cap-text-strong: #f1f5f9;
|
||||
--cap-text-sub: #94a3b8;
|
||||
--cap-text-muted: #64748b;
|
||||
--cap-soft-bg: rgba(148, 163, 184, 0.08);
|
||||
--cap-tag-plain-bg: rgba(148, 163, 184, 0.16);
|
||||
--cap-glow-opacity: 0.5;
|
||||
--cap-table-fixed-bg: #111c2e;
|
||||
--cap-table-fixed-bg-hover: #18283f;
|
||||
}
|
||||
:global([data-theme='light']) {
|
||||
--cap-bg:
|
||||
radial-gradient(1200px 600px at 12% -10%, rgba(56, 189, 248, 0.16), transparent 60%),
|
||||
radial-gradient(1000px 600px at 100% 0%, rgba(129, 140, 248, 0.14), transparent 55%),
|
||||
linear-gradient(180deg, #eef2f9 0%, #e3e9f3 100%);
|
||||
--cap-panel-bg: rgba(255, 255, 255, 0.82);
|
||||
--cap-card-bg: rgba(255, 255, 255, 0.9);
|
||||
--cap-panel-border: rgba(15, 23, 42, 0.08);
|
||||
--cap-shadow: 0 6px 20px rgba(15, 23, 42, 0.06);
|
||||
--cap-icon-bg: rgba(15, 23, 42, 0.05);
|
||||
--cap-control-bg: rgba(255, 255, 255, 0.7);
|
||||
--cap-control-border: rgba(15, 23, 42, 0.1);
|
||||
--cap-text: #334155;
|
||||
--cap-text-strong: #0f172a;
|
||||
--cap-text-sub: #64748b;
|
||||
--cap-text-muted: #94a3b8;
|
||||
--cap-soft-bg: rgba(15, 23, 42, 0.04);
|
||||
--cap-tag-plain-bg: rgba(15, 23, 42, 0.06);
|
||||
--cap-glow-opacity: 0.32;
|
||||
--cap-table-fixed-bg: #ffffff;
|
||||
--cap-table-fixed-bg-hover: #eaf6fb;
|
||||
}
|
||||
|
||||
.cap-dashboard {
|
||||
position: relative;
|
||||
height: calc(100vh - 64px);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 视口足够高时整页不滚动;过矮时出现纵向滚动条,保证能看到清单 */
|
||||
overflow-y: auto;
|
||||
padding: 14px 18px;
|
||||
color: var(--cap-text);
|
||||
background: var(--cap-bg);
|
||||
}
|
||||
|
||||
.cap-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.cap-empty-orb {
|
||||
width: 92px;
|
||||
height: 92px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 50% 40%, rgba(56, 189, 248, 0.55), rgba(56, 189, 248, 0) 70%);
|
||||
box-shadow: 0 0 60px rgba(56, 189, 248, 0.45);
|
||||
animation: pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
.cap-empty-text {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
color: var(--cap-text-strong);
|
||||
}
|
||||
.cap-empty-sub {
|
||||
margin: 0;
|
||||
max-width: 460px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--cap-text-muted);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 0.85; }
|
||||
50% { transform: scale(1.12); opacity: 1; }
|
||||
}
|
||||
|
||||
.cap-body { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.cap-seg {
|
||||
display: inline-flex;
|
||||
padding: 3px;
|
||||
border-radius: 10px;
|
||||
background: var(--cap-control-bg);
|
||||
border: 1px solid var(--cap-control-border);
|
||||
}
|
||||
.cap-seg.sm { padding: 2px; border-radius: 8px; }
|
||||
.cap-seg-btn {
|
||||
padding: 5px 16px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--cap-text-sub);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
}
|
||||
.cap-seg.sm .cap-seg-btn { padding: 4px 12px; font-size: 12px; }
|
||||
.cap-seg-btn:hover { color: var(--cap-text-strong); }
|
||||
.cap-seg-btn.active {
|
||||
color: #061018;
|
||||
background: linear-gradient(135deg, #22d3ee, #38bdf8);
|
||||
box-shadow: 0 0 18px rgba(56, 189, 248, 0.45);
|
||||
}
|
||||
|
||||
/* 概览区(卡片 + 图表),固定高度,由 n-spin 包裹 */
|
||||
.cap-overview { flex: 0 0 auto; }
|
||||
.cap-overview :deep(.n-spin-content) { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
/* 汇总卡片 */
|
||||
.cap-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.cap-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: var(--cap-card-bg);
|
||||
border: 1px solid var(--cap-panel-border);
|
||||
box-shadow: var(--cap-shadow);
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(8px);
|
||||
animation: cardIn 0.5s ease both;
|
||||
}
|
||||
@keyframes cardIn { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.cap-card-icon {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 34px; height: 34px; flex: 0 0 auto;
|
||||
border-radius: 9px; font-size: 18px;
|
||||
background: var(--cap-icon-bg);
|
||||
}
|
||||
.cap-card-body { min-width: 0; }
|
||||
.cap-card-value { font-size: 19px; font-weight: 800; line-height: 1.15; color: var(--cap-text-strong); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.cap-card-unit { font-size: 11px; font-weight: 600; margin-left: 2px; color: var(--cap-text-sub); }
|
||||
.cap-card-label { margin-top: 2px; font-size: 11px; color: var(--cap-text-sub); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.cap-card-glow { position: absolute; right: -30px; top: -30px; width: 90px; height: 90px; border-radius: 50%; opacity: var(--cap-glow-opacity); filter: blur(8px); }
|
||||
.tone-cyan .cap-card-icon { color: #06b6d4; } .tone-cyan .cap-card-glow { background: rgba(34, 211, 238, 0.35); }
|
||||
.tone-rose .cap-card-icon { color: #f43f5e; } .tone-rose .cap-card-glow { background: rgba(251, 113, 133, 0.35); }
|
||||
.tone-blue .cap-card-icon { color: #0ea5e9; } .tone-blue .cap-card-glow { background: rgba(56, 189, 248, 0.32); }
|
||||
.tone-amber .cap-card-icon { color: #f59e0b; } .tone-amber .cap-card-glow { background: rgba(251, 191, 36, 0.3); }
|
||||
.tone-violet .cap-card-icon { color: #8b5cf6; } .tone-violet .cap-card-glow { background: rgba(192, 132, 252, 0.3); }
|
||||
.tone-emerald .cap-card-icon { color: #10b981; } .tone-emerald .cap-card-glow { background: rgba(52, 211, 153, 0.3); }
|
||||
.cap-card::after { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: currentColor; opacity: 0.55; }
|
||||
.tone-cyan::after { color: #06b6d4; } .tone-rose::after { color: #f43f5e; } .tone-blue::after { color: #0ea5e9; }
|
||||
.tone-amber::after { color: #f59e0b; } .tone-violet::after { color: #8b5cf6; } .tone-emerald::after { color: #10b981; }
|
||||
|
||||
/* 图表区:两行三列 */
|
||||
.cap-charts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.cap-panel {
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
background: var(--cap-panel-bg);
|
||||
border: 1px solid var(--cap-panel-border);
|
||||
box-shadow: var(--cap-shadow);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.cap-panel-head {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
margin-bottom: 4px; font-size: 13px; font-weight: 600; color: var(--cap-text);
|
||||
}
|
||||
.cap-panel-head.sm { font-size: 12px; margin: 14px 0 8px; }
|
||||
.cap-panel-head .bar { width: 4px; height: 13px; border-radius: 2px; background: linear-gradient(180deg, #22d3ee, #818cf8); }
|
||||
|
||||
/* 清单:占据剩余高度,表体内部滚动(表头固定);min-height 保证矮屏下不被压没、触发整页滚动 */
|
||||
.cap-list { flex: 1 1 0; min-height: 300px; display: flex; flex-direction: column; padding-bottom: 10px; }
|
||||
.cap-list-head { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; margin-bottom: 8px; }
|
||||
.cap-list-head .cap-panel-head { margin-bottom: 0; }
|
||||
.cap-list-filters { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.cap-search { width: 200px; }
|
||||
.cap-table-wrap { flex: 1 1 0; min-height: 0; }
|
||||
.cap-table { height: 100%; background: transparent; }
|
||||
/* 固定列(CGI 左 / 问题 右)需不透明背景,否则横向滚动时透出下层列造成"重叠" */
|
||||
.cap-table :deep(.n-data-table-th--fixed-left),
|
||||
.cap-table :deep(.n-data-table-th--fixed-right),
|
||||
.cap-table :deep(.n-data-table-td--fixed-left),
|
||||
.cap-table :deep(.n-data-table-td--fixed-right) {
|
||||
background-color: var(--cap-table-fixed-bg) !important;
|
||||
}
|
||||
.cap-table :deep(.n-data-table-tr:hover .n-data-table-td--fixed-left),
|
||||
.cap-table :deep(.n-data-table-tr:hover .n-data-table-td--fixed-right) {
|
||||
background-color: var(--cap-table-fixed-bg-hover) !important;
|
||||
}
|
||||
.cap-pager { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 10px; }
|
||||
.cap-pager-total { font-size: 12px; color: var(--cap-text-muted); }
|
||||
|
||||
/* 详情抽屉 */
|
||||
.cap-detail-head { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.cap-detail-id { font-weight: 700; font-size: 15px; color: var(--cap-text-strong); line-height: 1.3; }
|
||||
.cap-detail-name { font-size: 12px; color: var(--cap-text-sub); line-height: 1.4; }
|
||||
.cap-detail { display: flex; flex-direction: column; gap: 16px; }
|
||||
.cap-detail-tags { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.cap-tag { padding: 3px 12px; border-radius: 999px; font-size: 12px; font-weight: 600; }
|
||||
.cap-tag.xs { padding: 1px 8px; font-size: 11px; }
|
||||
.cap-tag.plain { background: var(--cap-tag-plain-bg); color: var(--cap-text-sub); }
|
||||
.cap-tag.is-rose { background: rgba(251, 113, 133, 0.18); color: #f43f5e; }
|
||||
.cap-tag.is-amber { background: rgba(251, 191, 36, 0.18); color: #d97706; }
|
||||
.cap-tag.is-blue { background: rgba(56, 189, 248, 0.18); color: #0284c7; }
|
||||
.cap-tag.is-slate { background: rgba(100, 116, 139, 0.18); color: #64748b; }
|
||||
.cap-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
.cap-metric { padding: 10px 12px; border-radius: 10px; background: var(--cap-soft-bg); border: 1px solid var(--cap-panel-border); }
|
||||
.cap-metric-label { font-size: 12px; color: var(--cap-text-sub); }
|
||||
.cap-metric-value { margin-top: 4px; font-size: 16px; font-weight: 700; color: var(--cap-text-strong); }
|
||||
.cap-suggest { padding: 12px 14px; border-radius: 12px; background: rgba(251, 191, 36, 0.1); border: 1px solid rgba(251, 191, 36, 0.32); }
|
||||
.cap-suggest-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; }
|
||||
.cap-suggest-title { display: flex; align-items: center; gap: 6px; font-weight: 700; color: #d97706; }
|
||||
.cap-suggest-text { margin: 0; font-size: 13px; line-height: 1.7; color: var(--cap-text); }
|
||||
.cap-icon-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; flex: 0 0 auto;
|
||||
border-radius: 8px; border: 1px solid var(--cap-panel-border);
|
||||
background: var(--cap-soft-bg); color: var(--cap-text-sub);
|
||||
cursor: pointer; font-size: 15px; transition: all 0.18s ease;
|
||||
}
|
||||
.cap-icon-btn:hover { color: #06b6d4; border-color: rgba(56, 189, 248, 0.5); }
|
||||
.cap-sib-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.cap-sib-item { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--cap-panel-border); background: var(--cap-soft-bg); }
|
||||
.cap-sib-body { flex: 1 1 auto; min-width: 0; }
|
||||
.cap-sib-main { display: flex; align-items: baseline; gap: 8px; }
|
||||
.cap-sib-id { font-weight: 600; font-size: 13px; color: var(--cap-text); }
|
||||
.cap-sib-name { font-size: 12px; color: var(--cap-text-sub); overflow: hidden; text-overflow: ellipsis; }
|
||||
.cap-sib-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 6px; font-size: 12px; color: var(--cap-text-sub); }
|
||||
|
||||
@media (max-width: 1366px) {
|
||||
.cap-card-value { font-size: 17px; }
|
||||
.cap-card-icon { width: 30px; height: 30px; font-size: 16px; }
|
||||
}
|
||||
@media (max-width: 1180px) {
|
||||
.cap-cards { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.cap-charts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.cap-list { min-height: 360px; }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.cap-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.cap-charts { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div ref="host" class="echart" :style="{ height: height || '300px' }"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import * as echarts from 'echarts';
|
||||
|
||||
const props = defineProps<{ option: echarts.EChartsOption; height?: string }>();
|
||||
|
||||
const host = ref<HTMLDivElement | null>(null);
|
||||
let chart: echarts.ECharts | null = null;
|
||||
let observer: ResizeObserver | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
if (!host.value) return;
|
||||
chart = echarts.init(host.value, undefined, { renderer: 'canvas' });
|
||||
chart.setOption(props.option);
|
||||
observer = new ResizeObserver(() => chart?.resize());
|
||||
observer.observe(host.value);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.option,
|
||||
option => {
|
||||
chart?.setOption(option, true);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
chart?.dispose();
|
||||
chart = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.echart {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,470 @@
|
||||
<template>
|
||||
<div class="history-page-body">
|
||||
<n-spin v-if="loading" class="page-loading" />
|
||||
|
||||
<div v-else-if="records.length === 0" class="empty-state history-empty-state">
|
||||
<n-icon class="empty-icon"><FileTrayOutline /></n-icon>
|
||||
<p>暂无处理记录</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="history-list">
|
||||
<div v-for="record in records" :key="record.id" class="history-item">
|
||||
<div class="history-status" :class="record.status"></div>
|
||||
<div class="history-info">
|
||||
<div class="history-id">{{ record.id }}</div>
|
||||
<div class="history-meta">
|
||||
{{ formatDate(record.timestamp) }} · {{ record.file_count }} 个文件 · {{ formatElapsed(record.elapsed_time) }}
|
||||
</div>
|
||||
</div>
|
||||
<span class="record-size">{{ statusText(record.status) }}</span>
|
||||
<div class="history-actions">
|
||||
<n-button
|
||||
size="small"
|
||||
tertiary
|
||||
:loading="downloadingRecordId === record.id"
|
||||
:disabled="record.status === 'pending' || record.status === 'processing'"
|
||||
@click="downloadHistory(record)"
|
||||
>
|
||||
<template #icon><n-icon><DownloadOutline /></n-icon></template>
|
||||
下载
|
||||
</n-button>
|
||||
<n-button size="small" tertiary @click="openDetail(record.id)">详情</n-button>
|
||||
<n-button size="small" tertiary type="error" @click="confirmDelete(record.id)">删除</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-modal v-model:show="detailVisible" preset="card" class="history-detail-modal" title="任务详情">
|
||||
<n-empty v-if="!detail" description="暂无详情" />
|
||||
<n-space v-else vertical size="large">
|
||||
<n-tabs v-model:value="detailTab" type="segment" animated>
|
||||
<n-tab-pane name="detail" tab="详情">
|
||||
<div class="detail-info-list">
|
||||
<div class="detail-info-row">
|
||||
<span class="detail-info-label">任务 ID</span>
|
||||
<span class="detail-info-value monospace">{{ detail.id }}</span>
|
||||
</div>
|
||||
<div class="detail-info-row">
|
||||
<span class="detail-info-label">状态</span>
|
||||
<span class="detail-info-value">{{ statusText(detail.status) }}</span>
|
||||
</div>
|
||||
<div class="detail-info-row">
|
||||
<span class="detail-info-label">文件数</span>
|
||||
<span class="detail-info-value">{{ detail.file_count }}</span>
|
||||
</div>
|
||||
<div class="detail-info-row">
|
||||
<span class="detail-info-label">耗时</span>
|
||||
<span class="detail-info-value">{{ formatElapsed(detail.elapsed_time) }}</span>
|
||||
</div>
|
||||
<div class="detail-info-row">
|
||||
<span class="detail-info-label">创建时间</span>
|
||||
<span class="detail-info-value monospace">{{ formatDate(detail.timestamp) }}</span>
|
||||
</div>
|
||||
<div class="detail-info-row">
|
||||
<span class="detail-info-label">占用</span>
|
||||
<span class="detail-info-value" :class="{ muted: recordSizeLoading }">{{ recordSize }}</span>
|
||||
</div>
|
||||
<div class="detail-info-row">
|
||||
<span class="detail-info-label">工作目录</span>
|
||||
<span class="detail-info-value path-value">{{ detail.work_dir }}</span>
|
||||
</div>
|
||||
<div v-if="detail.error" class="detail-info-row error-row">
|
||||
<span class="detail-info-label">错误</span>
|
||||
<span class="detail-info-value">{{ detail.error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="files" tab="文件">
|
||||
<div class="history-file-browser">
|
||||
<div class="history-file-toolbar">
|
||||
<div class="history-file-breadcrumb">
|
||||
<button type="button" class="breadcrumb-link" @click="loadHistoryFiles('')">根目录</button>
|
||||
<template v-for="crumb in fileBreadcrumbs" :key="crumb.path">
|
||||
<n-icon class="breadcrumb-separator"><ChevronForwardOutline /></n-icon>
|
||||
<button type="button" class="breadcrumb-link" @click="loadHistoryFiles(crumb.path)">
|
||||
{{ crumb.name }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="history-file-toolbar-actions">
|
||||
<n-button size="small" tertiary :disabled="fileParentPath === null || fileLoading" @click="loadHistoryFiles(fileParentPath || '')">
|
||||
<template #icon><n-icon><ArrowBackOutline /></n-icon></template>
|
||||
上一级
|
||||
</n-button>
|
||||
<n-button size="small" tertiary :loading="fileLoading" @click="reloadHistoryFiles">
|
||||
<template #icon><n-icon><RefreshOutline /></n-icon></template>
|
||||
刷新
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-spin :show="fileLoading">
|
||||
<n-empty v-if="!fileEntries.length && !fileLoading" description="当前目录为空" />
|
||||
<div v-else class="history-file-list">
|
||||
<div v-for="entry in fileEntries" :key="entry.path" class="history-file-row">
|
||||
<div class="history-file-main">
|
||||
<n-icon class="history-file-icon" :class="entry.type">
|
||||
<FolderOpenOutline v-if="entry.type === 'dir'" />
|
||||
<DocumentOutline v-else />
|
||||
</n-icon>
|
||||
<div class="history-file-text">
|
||||
<button
|
||||
type="button"
|
||||
class="history-file-name"
|
||||
:disabled="entry.type !== 'dir'"
|
||||
@click="entry.type === 'dir' && loadHistoryFiles(entry.path)"
|
||||
>
|
||||
{{ entry.name }}
|
||||
</button>
|
||||
<span class="history-file-meta">
|
||||
{{ entry.type === 'dir' ? '目录' : '文件' }} · {{ entry.size_formatted }} · {{ entry.modified_formatted }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="history-file-actions">
|
||||
<n-button
|
||||
v-if="entry.type === 'dir'"
|
||||
size="small"
|
||||
tertiary
|
||||
@click="loadHistoryFiles(entry.path)"
|
||||
>
|
||||
打开
|
||||
</n-button>
|
||||
<n-button
|
||||
size="small"
|
||||
tertiary
|
||||
:loading="downloadingFilePath === entry.path"
|
||||
:disabled="detailDownloadDisabled"
|
||||
@click="downloadHistoryItem(entry)"
|
||||
>
|
||||
<template #icon><n-icon><DownloadOutline /></n-icon></template>
|
||||
下载
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-spin>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
|
||||
<div>
|
||||
<div class="section-label log-section-header">
|
||||
<span>处理日志</span>
|
||||
<n-button size="small" tertiary circle title="复制日志" :disabled="!detailLogText" @click="copyDetailLogs">
|
||||
<template #icon><n-icon><CopyOutline /></n-icon></template>
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="colored-log-panel" role="log" aria-label="处理日志">
|
||||
<pre class="colored-log-content"><span
|
||||
v-for="(line, index) in detailLogLines"
|
||||
:key="index"
|
||||
class="colored-log-line"
|
||||
:class="`log-level-${line.level}`"
|
||||
>{{ line.text }}</span></pre>
|
||||
</div>
|
||||
</div>
|
||||
</n-space>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useDialog, useMessage } from 'naive-ui';
|
||||
import {
|
||||
ArrowBackOutline,
|
||||
ChevronForwardOutline,
|
||||
CopyOutline,
|
||||
DocumentOutline,
|
||||
DownloadOutline,
|
||||
FileTrayOutline,
|
||||
FolderOpenOutline,
|
||||
RefreshOutline,
|
||||
TrashOutline
|
||||
} from '@vicons/ionicons5';
|
||||
|
||||
import { apiGet, apiPost, download as downloadFile } from '../api/client';
|
||||
import type { CacheSize, HistoryDetail, HistoryFileEntry, HistoryFilesResponse, HistoryRecord } from '../types';
|
||||
import { writeClipboardText } from '../composables/clipboard';
|
||||
import { showDownloadCompleteDialog } from '../composables/downloadFeedback';
|
||||
import { toColoredLogLines } from '../composables/logLines';
|
||||
import { resetPageHeader, setPageHeader } from '../composables/pageHeader';
|
||||
|
||||
const message = useMessage();
|
||||
const dialog = useDialog();
|
||||
const records = ref<HistoryRecord[]>([]);
|
||||
const detail = ref<HistoryDetail | null>(null);
|
||||
const detailVisible = ref(false);
|
||||
const cacheSize = ref<CacheSize | null>(null);
|
||||
const recordSize = ref('-');
|
||||
const recordSizeLoading = ref(false);
|
||||
const loading = ref(false);
|
||||
const downloadingRecordId = ref<string | null>(null);
|
||||
const detailTab = ref<'detail' | 'files'>('detail');
|
||||
const fileEntries = ref<HistoryFileEntry[]>([]);
|
||||
const fileCurrentPath = ref('');
|
||||
const fileParentPath = ref<string | null>(null);
|
||||
const fileLoading = ref(false);
|
||||
const downloadingFilePath = ref<string | null>(null);
|
||||
let recordSizeToken = 0;
|
||||
let fileListToken = 0;
|
||||
|
||||
const totalSizeText = computed(() => `总占用: ${cacheSize.value?.size_formatted || '计算中...'}`);
|
||||
const hasRecords = computed(() => records.value.length > 0);
|
||||
const detailLogText = computed(() => detail.value?.logs?.join('\n') || '');
|
||||
const detailLogLines = computed(() => toColoredLogLines(detailLogText.value));
|
||||
const detailDownloadDisabled = computed(() => {
|
||||
const status = detail.value?.status;
|
||||
return status === 'pending' || status === 'processing';
|
||||
});
|
||||
const fileBreadcrumbs = computed(() => {
|
||||
let currentPath = '';
|
||||
return fileCurrentPath.value
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map(name => {
|
||||
currentPath = currentPath ? `${currentPath}/${name}` : name;
|
||||
return { name, path: currentPath };
|
||||
});
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
setPageHeader({
|
||||
actions: [
|
||||
{ key: 'refresh-history', label: '刷新', icon: RefreshOutline, loading, onClick: loadHistory },
|
||||
{
|
||||
key: 'clear-history',
|
||||
label: '清空历史',
|
||||
icon: TrashOutline,
|
||||
type: 'error',
|
||||
variant: 'solid',
|
||||
disabled: computed(() => !hasRecords.value),
|
||||
onClick: confirmClear
|
||||
},
|
||||
{ key: 'history-size', kind: 'text', label: totalSizeText }
|
||||
]
|
||||
});
|
||||
void loadHistory();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resetPageHeader();
|
||||
});
|
||||
|
||||
watch(detailTab, tab => {
|
||||
if (tab === 'files' && detail.value && fileEntries.value.length === 0 && !fileLoading.value) {
|
||||
void loadHistoryFiles(fileCurrentPath.value);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadHistory() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [historyResult, sizeResult] = await Promise.all([
|
||||
apiPost<{ records: HistoryRecord[] }>('/api/history', { limit: 50 }),
|
||||
apiGet<CacheSize>('/api/cache/size')
|
||||
]);
|
||||
records.value = historyResult.records;
|
||||
cacheSize.value = sizeResult;
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '加载历史记录失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(recordId: string) {
|
||||
try {
|
||||
detail.value = await apiPost<HistoryDetail>('/api/history/detail', { record_id: recordId });
|
||||
detailTab.value = 'detail';
|
||||
resetFileBrowser();
|
||||
detailVisible.value = true;
|
||||
void loadRecordSize(recordId);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '加载任务详情失败');
|
||||
}
|
||||
}
|
||||
|
||||
function resetFileBrowser() {
|
||||
fileEntries.value = [];
|
||||
fileCurrentPath.value = '';
|
||||
fileParentPath.value = null;
|
||||
downloadingFilePath.value = null;
|
||||
fileListToken += 1;
|
||||
}
|
||||
|
||||
async function downloadHistory(record: HistoryRecord) {
|
||||
if (record.status === 'pending' || record.status === 'processing') {
|
||||
message.warning('任务尚未完成,暂不能下载历史数据');
|
||||
return;
|
||||
}
|
||||
|
||||
downloadingRecordId.value = record.id;
|
||||
try {
|
||||
const filename = `${record.id}.zip`;
|
||||
const result = await downloadFile('/api/history/download', { record_id: record.id }, filename);
|
||||
if (result.saved) {
|
||||
showDownloadCompleteDialog(dialog, filename, result.path);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '下载历史数据失败');
|
||||
} finally {
|
||||
downloadingRecordId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistoryFiles(path: string) {
|
||||
if (!detail.value) return;
|
||||
|
||||
const currentToken = ++fileListToken;
|
||||
fileLoading.value = true;
|
||||
try {
|
||||
const result = await apiPost<HistoryFilesResponse>('/api/history/files', {
|
||||
record_id: detail.value.id,
|
||||
path
|
||||
});
|
||||
if (currentToken !== fileListToken) return;
|
||||
fileEntries.value = result.entries;
|
||||
fileCurrentPath.value = result.path;
|
||||
fileParentPath.value = result.parent_path;
|
||||
} catch (error) {
|
||||
if (currentToken !== fileListToken) return;
|
||||
message.error(error instanceof Error ? error.message : '加载历史文件失败');
|
||||
} finally {
|
||||
if (currentToken === fileListToken) {
|
||||
fileLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function reloadHistoryFiles() {
|
||||
void loadHistoryFiles(fileCurrentPath.value);
|
||||
}
|
||||
|
||||
async function downloadHistoryItem(entry: HistoryFileEntry) {
|
||||
if (!detail.value) return;
|
||||
if (detailDownloadDisabled.value) {
|
||||
message.warning('任务尚未完成,暂不能下载历史数据');
|
||||
return;
|
||||
}
|
||||
|
||||
downloadingFilePath.value = entry.path;
|
||||
try {
|
||||
const fallbackFilename = entry.type === 'dir' ? `${entry.name}.zip` : entry.name;
|
||||
const result = await downloadFile(
|
||||
'/api/history/file/download',
|
||||
{ record_id: detail.value.id, path: entry.path },
|
||||
fallbackFilename
|
||||
);
|
||||
if (result.saved) {
|
||||
showDownloadCompleteDialog(dialog, fallbackFilename, result.path);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '下载历史文件失败');
|
||||
} finally {
|
||||
downloadingFilePath.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecordSize(recordId: string) {
|
||||
const currentToken = ++recordSizeToken;
|
||||
recordSize.value = '计算中...';
|
||||
recordSizeLoading.value = true;
|
||||
try {
|
||||
const result = await apiPost<{ success: boolean; size_formatted: string }>('/api/history/size', {
|
||||
record_id: recordId
|
||||
});
|
||||
if (currentToken !== recordSizeToken) return;
|
||||
recordSize.value = result.size_formatted;
|
||||
} catch (error) {
|
||||
if (currentToken !== recordSizeToken) return;
|
||||
recordSize.value = '计算失败';
|
||||
message.error(error instanceof Error ? error.message : '计算目录占用失败');
|
||||
} finally {
|
||||
if (currentToken === recordSizeToken) {
|
||||
recordSizeLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function copyDetailLogs() {
|
||||
if (!detailLogText.value) {
|
||||
message.warning('暂无可复制日志');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await writeClipboardText(detailLogText.value);
|
||||
message.success('日志已复制');
|
||||
} catch {
|
||||
message.error('复制日志失败');
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(recordId: string) {
|
||||
dialog.warning({
|
||||
title: '删除历史记录',
|
||||
content: `确认删除任务 ${recordId} 及相关文件?`,
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => deleteRecord(recordId)
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteRecord(recordId: string) {
|
||||
try {
|
||||
await apiPost('/api/history/delete', { record_id: recordId });
|
||||
if (detail.value?.id === recordId) {
|
||||
detail.value = null;
|
||||
detailVisible.value = false;
|
||||
}
|
||||
message.success('删除成功');
|
||||
await loadHistory();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
function confirmClear() {
|
||||
dialog.warning({
|
||||
title: '清空历史记录',
|
||||
content: '确认清空全部历史记录和相关文件?',
|
||||
positiveText: '清空',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: clearHistory
|
||||
});
|
||||
}
|
||||
|
||||
async function clearHistory() {
|
||||
try {
|
||||
await apiPost('/api/history/clear');
|
||||
detail.value = null;
|
||||
detailVisible.value = false;
|
||||
message.success('已清空历史记录');
|
||||
await loadHistory();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '清空失败');
|
||||
}
|
||||
}
|
||||
|
||||
function statusText(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
pending: '待处理',
|
||||
processing: '处理中',
|
||||
completed: '已完成',
|
||||
failed: '失败'
|
||||
};
|
||||
return map[status] || status;
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
if (!value) return '-';
|
||||
return value.replace('T', ' ').slice(0, 19);
|
||||
}
|
||||
|
||||
function formatElapsed(value: number): string {
|
||||
if (!value) return '-';
|
||||
return `${value.toFixed(1)} 秒`;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<n-modal
|
||||
v-model:show="visible"
|
||||
preset="card"
|
||||
title="授权延期"
|
||||
:mask-closable="!activationLoading"
|
||||
:style="{ width: '420px', maxWidth: 'calc(100vw - 32px)' }"
|
||||
>
|
||||
<div class="license-dialog-body">
|
||||
<p class="license-dialog-text">{{ licenseMessage }}</p>
|
||||
<div class="license-key-label">key: {{ activationKeyLabel }}</div>
|
||||
<n-input
|
||||
v-model:value="activationCode"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 5 }"
|
||||
placeholder="请输入激活码"
|
||||
:disabled="activationLoading"
|
||||
/>
|
||||
<div class="license-metrix-toggle">
|
||||
<n-switch :value="metrixEnabled" :loading="savingMetrix" size="small" @update:value="toggleMetrix" />
|
||||
<span class="license-metrix-label">启用 Metrix 平台</span>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="license-dialog-footer">
|
||||
<n-button :disabled="activationLoading" @click="visible = false">取消</n-button>
|
||||
<n-button type="primary" :loading="activationLoading" @click="submitActivation">激活</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useMessage } from 'naive-ui';
|
||||
|
||||
import { apiGet, apiPost } from '../api/client';
|
||||
import type { AppConfig, LicenseStatus } from '../types';
|
||||
|
||||
const visible = defineModel<boolean>('show', { required: true });
|
||||
const emit = defineEmits<{ metrixChanged: [enabled: boolean] }>();
|
||||
const message = useMessage();
|
||||
const activationCode = ref('');
|
||||
const activationLoading = ref(false);
|
||||
const activationKeyLabel = ref('2026/12/30');
|
||||
const licenseMessage = ref('输入激活码可将授权到期日期延长 30 天。');
|
||||
const metrixEnabled = ref(false);
|
||||
const savingMetrix = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
void loadLicenseStatus();
|
||||
void loadMetrixEnabled();
|
||||
});
|
||||
|
||||
async function loadLicenseStatus() {
|
||||
try {
|
||||
const status = await apiGet<LicenseStatus>('/api/license/status');
|
||||
activationKeyLabel.value = status.key_label;
|
||||
licenseMessage.value = `当前授权到期日期:${status.expires_on}。输入激活码可延长 30 天。`;
|
||||
activationCode.value = '';
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '获取授权状态失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMetrixEnabled() {
|
||||
try {
|
||||
const config = await apiGet<AppConfig>('/api/config/full');
|
||||
metrixEnabled.value = config.metrix_enabled ?? false;
|
||||
} catch {
|
||||
metrixEnabled.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleMetrix(target: boolean) {
|
||||
if (target === metrixEnabled.value) return;
|
||||
savingMetrix.value = true;
|
||||
try {
|
||||
await apiPost('/api/config/metrix-enabled', { enabled: target });
|
||||
metrixEnabled.value = target;
|
||||
emit('metrixChanged', target);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '保存失败');
|
||||
} finally {
|
||||
savingMetrix.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitActivation() {
|
||||
const code = activationCode.value.trim();
|
||||
if (!code) {
|
||||
message.warning('请输入激活码');
|
||||
return;
|
||||
}
|
||||
|
||||
activationLoading.value = true;
|
||||
try {
|
||||
const result = await apiPost<LicenseStatus>('/api/license/activate', { code });
|
||||
activationKeyLabel.value = result.key_label;
|
||||
licenseMessage.value = `当前授权到期日期:${result.expires_on}。输入激活码可继续延长 30 天。`;
|
||||
activationCode.value = '';
|
||||
message.success(`激活成功,到期日期: ${result.expires_on}`);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '激活失败');
|
||||
} finally {
|
||||
activationLoading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.license-metrix-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--n-border-color, #e0e0e6);
|
||||
}
|
||||
.license-metrix-label {
|
||||
font-size: 13px;
|
||||
color: var(--n-text-color-3, #999);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<n-card class="login-card" :bordered="false">
|
||||
<div class="login-brand">
|
||||
<div class="brand-mark large">CR</div>
|
||||
<div>
|
||||
<h1>CapacityReport</h1>
|
||||
<p>容量报表处理系统</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-form ref="formRef" :model="form" :rules="rules" @submit.prevent="submit">
|
||||
<n-form-item label="账号" path="username">
|
||||
<n-input v-model:value="form.username" autofocus />
|
||||
</n-form-item>
|
||||
<n-form-item label="密码" path="password">
|
||||
<n-input v-model:value="form.password" type="password" show-password-on="click" @keydown.enter.prevent="submit" />
|
||||
</n-form-item>
|
||||
<n-button type="primary" block :loading="loading" @click="submit">登录</n-button>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import type { FormInst, FormRules } from 'naive-ui';
|
||||
|
||||
const props = defineProps<{ loading: boolean }>();
|
||||
const emit = defineEmits<{ login: [{ username: string; password: string }] }>();
|
||||
|
||||
const formRef = ref<FormInst | null>(null);
|
||||
const form = reactive({ username: 'root', password: '' });
|
||||
const rules: FormRules = {
|
||||
username: { required: true, message: '请输入账号', trigger: 'blur' },
|
||||
password: { required: true, message: '请输入密码', trigger: 'blur' }
|
||||
};
|
||||
|
||||
async function submit() {
|
||||
if (props.loading) return;
|
||||
await formRef.value?.validate();
|
||||
emit('login', { ...form });
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,403 @@
|
||||
<template>
|
||||
<div class="script-page-body">
|
||||
<div class="script-editor-container">
|
||||
<div ref="editorHost" class="script-editor" />
|
||||
</div>
|
||||
|
||||
<div v-if="taskStatus || taskId" class="script-process-section">
|
||||
<n-collapse>
|
||||
<n-collapse-item name="log">
|
||||
<template #header>
|
||||
<span class="card-title">脚本执行进度</span>
|
||||
<span class="process-status" :class="statusClass" style="margin-left:8px">{{ statusText }}</span>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<n-button size="tiny" quaternary :disabled="!taskId" @click.stop="refreshStatus">刷新</n-button>
|
||||
</template>
|
||||
<div class="colored-log-panel script-log" role="log" aria-label="脚本执行日志">
|
||||
<pre class="colored-log-content"><span
|
||||
v-for="(line, index) in logLines"
|
||||
:key="index"
|
||||
class="colored-log-line"
|
||||
:class="`log-level-${line.level}`"
|
||||
>{{ line.text }}</span></pre>
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</div>
|
||||
|
||||
<div class="script-status-bar">
|
||||
<span>{{ cursorText }}</span>
|
||||
<span>{{ editorStatus }}</span>
|
||||
<span>{{ lineCount }} 行</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef } from 'vue';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import {
|
||||
BrushOutline,
|
||||
PlayOutline,
|
||||
SaveOutline,
|
||||
SwapHorizontalOutline,
|
||||
} from '@vicons/ionicons5';
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js';
|
||||
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
|
||||
import 'monaco-editor/esm/vs/basic-languages/sql/sql.contribution';
|
||||
import { language as mysqlLanguage } from 'monaco-editor/esm/vs/basic-languages/mysql/mysql.js';
|
||||
|
||||
import { apiPost } from '../api/client';
|
||||
import type { ApiMessage, ScriptContent, TaskStatus } from '../types';
|
||||
import { toColoredLogLines } from '../composables/logLines';
|
||||
import { resetPageHeader, setPageHeader } from '../composables/pageHeader';
|
||||
|
||||
type MonacoGlobal = typeof globalThis & {
|
||||
MonacoEnvironment?: {
|
||||
getWorker: (_moduleId: string, _label: string) => Worker;
|
||||
};
|
||||
__capacityReportScriptTaskState?: {
|
||||
taskId: ReturnType<typeof ref<string>>;
|
||||
taskStatus: ReturnType<typeof ref<TaskStatus | null>>;
|
||||
};
|
||||
};
|
||||
|
||||
(globalThis as MonacoGlobal).MonacoEnvironment = {
|
||||
getWorker: () => new editorWorker()
|
||||
};
|
||||
|
||||
const SCRIPT_OPTIONS = [
|
||||
{ key: 'report', label: '容量报表脚本' },
|
||||
{ key: 'celldata', label: 'CellData 脚本' },
|
||||
];
|
||||
|
||||
const message = useMessage();
|
||||
const editorHost = ref<HTMLDivElement | null>(null);
|
||||
const editor = shallowRef<monaco.editor.IStandaloneCodeEditor | null>(null);
|
||||
const disposables: monaco.IDisposable[] = [];
|
||||
const content = ref('');
|
||||
const originalContent = ref('');
|
||||
const scriptPath = ref('');
|
||||
const modified = ref<string | null>(null);
|
||||
const cursorText = ref('行 1,列 1');
|
||||
const lineCount = ref(1);
|
||||
const isModified = ref(false);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const executing = ref(false);
|
||||
const scriptType = ref('report');
|
||||
let timer: number | undefined;
|
||||
let themeObserver: MutationObserver | undefined;
|
||||
let applyingRemoteContent = false;
|
||||
|
||||
const globalWindow = window as MonacoGlobal;
|
||||
const scriptTaskState = globalWindow.__capacityReportScriptTaskState || {
|
||||
taskId: ref(''),
|
||||
taskStatus: ref<TaskStatus | null>(null)
|
||||
};
|
||||
globalWindow.__capacityReportScriptTaskState = scriptTaskState;
|
||||
const taskId = scriptTaskState.taskId;
|
||||
const taskStatus = scriptTaskState.taskStatus;
|
||||
|
||||
const logText = computed(() => taskStatus.value?.logs?.join('\n') || '等待执行日志');
|
||||
const logLines = computed(() => toColoredLogLines(logText.value, '等待执行日志'));
|
||||
const statusClass = computed(() => {
|
||||
if (taskStatus.value?.status === 'completed') return 'completed';
|
||||
if (taskStatus.value?.status === 'failed') return 'failed';
|
||||
return 'processing';
|
||||
});
|
||||
const statusText = computed(() => {
|
||||
const status = taskStatus.value?.status;
|
||||
if (status === 'completed') return '已完成';
|
||||
if (status === 'failed') return '失败';
|
||||
if (status === 'processing') return '执行中';
|
||||
if (status === 'pending') return '等待中';
|
||||
return status || '';
|
||||
});
|
||||
const editorStatus = computed(() => {
|
||||
if (loading.value) return '加载中';
|
||||
if (saving.value) return '保存中';
|
||||
return isModified.value ? '未保存' : '就绪';
|
||||
});
|
||||
const modifiedText = computed(() => (modified.value ? `最后修改: ${modified.value}` : '最后修改: -'));
|
||||
const currentLabel = computed(() => SCRIPT_OPTIONS.find(o => o.key === scriptType.value)?.label || scriptType.value);
|
||||
|
||||
onMounted(async () => {
|
||||
setPageHeader({
|
||||
subtitle: computed(() => scriptPath.value || currentLabel.value),
|
||||
actions: [
|
||||
{ key: 'script-modified', kind: 'text', label: modifiedText },
|
||||
{
|
||||
key: 'switch-script',
|
||||
label: computed(() => currentLabel.value),
|
||||
icon: SwapHorizontalOutline,
|
||||
dropdownOptions: SCRIPT_OPTIONS,
|
||||
onSelect: (key) => void switchScript(String(key)),
|
||||
},
|
||||
{
|
||||
key: 'save-script',
|
||||
label: computed(() => (isModified.value ? '保存 *' : '保存')),
|
||||
icon: SaveOutline,
|
||||
type: 'primary',
|
||||
variant: 'solid',
|
||||
loading: saving,
|
||||
onClick: saveScript
|
||||
},
|
||||
{ key: 'format-script', label: '格式化', icon: BrushOutline, onClick: formatScript },
|
||||
{
|
||||
key: 'run-script',
|
||||
label: '运行',
|
||||
icon: PlayOutline,
|
||||
type: 'success',
|
||||
variant: 'solid',
|
||||
loading: executing,
|
||||
onClick: executeScript
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
createEditor();
|
||||
observeTheme();
|
||||
await loadScript();
|
||||
if (taskId.value && taskStatus.value && !['completed', 'failed'].includes(taskStatus.value.status)) {
|
||||
startPolling();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resetPageHeader();
|
||||
stopPolling();
|
||||
themeObserver?.disconnect();
|
||||
for (const disposable of disposables) {
|
||||
disposable.dispose();
|
||||
}
|
||||
editor.value?.dispose();
|
||||
});
|
||||
|
||||
function createEditor() {
|
||||
if (!editorHost.value || editor.value) return;
|
||||
|
||||
editor.value = monaco.editor.create(editorHost.value, {
|
||||
value: '-- 正在加载脚本...',
|
||||
language: 'sql',
|
||||
theme: currentEditorTheme(),
|
||||
fontSize: 14,
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, 'Courier New', monospace",
|
||||
lineNumbers: 'on',
|
||||
minimap: { enabled: true },
|
||||
automaticLayout: true,
|
||||
scrollBeyondLastLine: false,
|
||||
roundedSelection: true,
|
||||
renderLineHighlight: 'all',
|
||||
tabSize: 4,
|
||||
insertSpaces: true,
|
||||
wordWrap: 'on',
|
||||
folding: true,
|
||||
showFoldingControls: 'always',
|
||||
bracketPairColorization: { enabled: true },
|
||||
guides: {
|
||||
bracketPairs: true,
|
||||
indentation: true
|
||||
}
|
||||
});
|
||||
|
||||
disposables.push(
|
||||
editor.value.onDidChangeCursorPosition(updateCursor),
|
||||
editor.value.onDidChangeModelContent(() => {
|
||||
const value = getEditorContent();
|
||||
content.value = value;
|
||||
lineCount.value = editor.value?.getModel()?.getLineCount() || 1;
|
||||
if (!applyingRemoteContent) {
|
||||
isModified.value = value !== originalContent.value;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
editor.value.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||
void saveScript();
|
||||
});
|
||||
|
||||
const completionItems: Array<{ label: string; kind: monaco.languages.CompletionItemKind }> = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (list: string[] | undefined, kind: monaco.languages.CompletionItemKind) => {
|
||||
for (const item of list || []) {
|
||||
const upper = item.toUpperCase();
|
||||
if (!seen.has(upper)) {
|
||||
seen.add(upper);
|
||||
completionItems.push({ label: upper, kind });
|
||||
}
|
||||
}
|
||||
};
|
||||
add(mysqlLanguage.keywords as string[], monaco.languages.CompletionItemKind.Keyword);
|
||||
add(mysqlLanguage.operators as string[], monaco.languages.CompletionItemKind.Operator);
|
||||
add(mysqlLanguage.builtinFunctions as string[], monaco.languages.CompletionItemKind.Function);
|
||||
disposables.push(
|
||||
monaco.languages.registerCompletionItemProvider('sql', {
|
||||
provideCompletionItems(model, position) {
|
||||
const word = model.getWordUntilPosition(position);
|
||||
const range = {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: word.startColumn,
|
||||
endColumn: word.endColumn,
|
||||
};
|
||||
return {
|
||||
suggestions: completionItems.map(item => ({
|
||||
label: item.label,
|
||||
kind: item.kind,
|
||||
insertText: item.label,
|
||||
range,
|
||||
})),
|
||||
};
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
updateCursor();
|
||||
}
|
||||
|
||||
async function switchScript(type: string) {
|
||||
if (type === scriptType.value) return;
|
||||
if (isModified.value) {
|
||||
const confirm = window.confirm(`当前脚本有未保存的修改,是否放弃?`);
|
||||
if (!confirm) return;
|
||||
}
|
||||
await loadScript(type);
|
||||
}
|
||||
|
||||
async function loadScript(type?: string) {
|
||||
const target = type || scriptType.value;
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await apiPost<ScriptContent>('/api/script/content', { script_type: target });
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '加载脚本失败');
|
||||
}
|
||||
scriptType.value = target;
|
||||
scriptPath.value = result.path || '';
|
||||
modified.value = result.modified;
|
||||
applyScriptContent(result.content);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '加载脚本失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveScript() {
|
||||
saving.value = true;
|
||||
try {
|
||||
const value = getEditorContent();
|
||||
const result = await apiPost<ApiMessage & { modified?: string }>('/api/script/save', {
|
||||
content: value,
|
||||
script_type: scriptType.value
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '保存失败');
|
||||
}
|
||||
originalContent.value = value;
|
||||
isModified.value = false;
|
||||
modified.value = result.modified || modified.value;
|
||||
message.success(result.message || '保存成功');
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '保存脚本失败');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function formatScript() {
|
||||
const action = editor.value?.getAction('editor.action.formatDocument');
|
||||
if (!action) {
|
||||
message.warning('当前编辑器没有可用的格式化动作');
|
||||
return;
|
||||
}
|
||||
|
||||
await action.run();
|
||||
message.success('格式化完成');
|
||||
}
|
||||
|
||||
async function executeScript() {
|
||||
executing.value = true;
|
||||
try {
|
||||
if (isModified.value) {
|
||||
await saveScript();
|
||||
if (isModified.value) return;
|
||||
}
|
||||
|
||||
const result = await apiPost<ApiMessage>('/api/script/execute', {
|
||||
script_type: scriptType.value
|
||||
});
|
||||
if (!result.task_id) {
|
||||
throw new Error(result.message || '脚本任务启动失败');
|
||||
}
|
||||
taskId.value = result.task_id;
|
||||
taskStatus.value = { task_id: result.task_id, status: 'processing', logs: [] };
|
||||
message.success(result.message || '脚本任务已启动');
|
||||
startPolling();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '执行脚本失败');
|
||||
} finally {
|
||||
executing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyScriptContent(value: string) {
|
||||
applyingRemoteContent = true;
|
||||
content.value = value;
|
||||
originalContent.value = value;
|
||||
editor.value?.setValue(value);
|
||||
lineCount.value = editor.value?.getModel()?.getLineCount() || value.split(/\r\n|\r|\n/).length || 1;
|
||||
isModified.value = false;
|
||||
applyingRemoteContent = false;
|
||||
updateCursor();
|
||||
}
|
||||
|
||||
function getEditorContent() {
|
||||
return editor.value?.getValue() ?? content.value;
|
||||
}
|
||||
|
||||
function updateCursor() {
|
||||
const position = editor.value?.getPosition();
|
||||
cursorText.value = position ? `行 ${position.lineNumber},列 ${position.column}` : '行 1,列 1';
|
||||
}
|
||||
|
||||
function observeTheme() {
|
||||
themeObserver = new MutationObserver(() => {
|
||||
monaco.editor.setTheme(currentEditorTheme());
|
||||
});
|
||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
}
|
||||
|
||||
function currentEditorTheme() {
|
||||
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'vs-dark' : 'vs';
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
void refreshStatus();
|
||||
timer = window.setInterval(() => void refreshStatus(), 1500);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (timer) {
|
||||
window.clearInterval(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
if (!taskId.value) return;
|
||||
try {
|
||||
taskStatus.value = await apiPost<TaskStatus>('/api/process/status', { task_id: taskId.value });
|
||||
if (['completed', 'failed'].includes(taskStatus.value.status)) {
|
||||
stopPolling();
|
||||
}
|
||||
} catch (error) {
|
||||
stopPolling();
|
||||
message.error(error instanceof Error ? error.message : '刷新执行状态失败');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
export async function writeClipboardText(text: string): Promise<void> {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.setAttribute('readonly', 'true');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.top = '-1000px';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
if (!document.execCommand('copy')) {
|
||||
throw new Error('copy failed');
|
||||
}
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { h, type VNodeChild } from 'vue';
|
||||
import type { useDialog } from 'naive-ui';
|
||||
|
||||
import { openPathInFileManager } from '../api/client';
|
||||
|
||||
type DownloadDialog = ReturnType<typeof useDialog>;
|
||||
|
||||
export function showDownloadCompleteDialog(dialog: DownloadDialog, filename?: string, path?: string): void {
|
||||
const safeName = filename?.trim();
|
||||
const safePath = path?.trim();
|
||||
const content = safePath
|
||||
? (): VNodeChild => h('div', { class: 'download-complete-content' }, [
|
||||
h('p', safeName ? `文件「${safeName}」已下载完成。` : '文件已下载完成。'),
|
||||
h('button', {
|
||||
class: 'download-path-link',
|
||||
type: 'button',
|
||||
title: safePath,
|
||||
onClick: () => void openPathInFileManager(safePath)
|
||||
}, safePath)
|
||||
])
|
||||
: safeName ? `文件「${safeName}」已下载完成。` : '文件已下载完成。';
|
||||
|
||||
dialog.success({
|
||||
title: '下载完成',
|
||||
content,
|
||||
positiveText: safePath ? '打开所在文件夹' : '知道了',
|
||||
negativeText: safePath ? '关闭' : undefined,
|
||||
closable: true,
|
||||
maskClosable: true,
|
||||
onPositiveClick: safePath ? () => openPathInFileManager(safePath) : undefined
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export type LogLevel = 'default' | 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
export interface ColoredLogLine {
|
||||
text: string;
|
||||
level: LogLevel;
|
||||
}
|
||||
|
||||
export function toColoredLogLines(text: string, fallback = '暂无日志'): ColoredLogLine[] {
|
||||
const lines = text ? text.split(/\r?\n/) : [fallback];
|
||||
return lines.map(line => ({
|
||||
text: line,
|
||||
level: resolveLogLevel(line)
|
||||
}));
|
||||
}
|
||||
|
||||
export function resolveLogLevel(line: string): LogLevel {
|
||||
const marker = line.match(/\[(ERROR|WARN|WARNING|SUCCESS|INFO)\]/i)?.[1]?.toUpperCase();
|
||||
if (marker === 'ERROR') return 'error';
|
||||
if (marker === 'WARN' || marker === 'WARNING') return 'warning';
|
||||
if (marker === 'SUCCESS') return 'success';
|
||||
if (marker === 'INFO') return 'info';
|
||||
if (/\b(ERROR|FAILED|FAILURE)\b|错误|失败/i.test(line)) return 'error';
|
||||
if (/\b(WARN|WARNING)\b|警告/i.test(line)) return 'warning';
|
||||
if (/\b(SUCCESS|SUCCEEDED|COMPLETED)\b|成功|已完成|处理完成|下载完成|执行完成/i.test(line)) {
|
||||
return 'success';
|
||||
}
|
||||
return 'default';
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
export const metrixEnabled = ref(false);
|
||||
@@ -0,0 +1,46 @@
|
||||
import { shallowReactive, unref, type Component, type Ref } from 'vue';
|
||||
import type { DropdownOption } from 'naive-ui';
|
||||
|
||||
type HeaderValue<T> = T | Ref<T> | (() => T);
|
||||
|
||||
export interface PageHeaderAction {
|
||||
key: string;
|
||||
label: HeaderValue<string>;
|
||||
icon?: HeaderValue<Component>;
|
||||
title?: HeaderValue<string>;
|
||||
kind?: 'button' | 'text';
|
||||
type?: 'default' | 'primary' | 'success' | 'warning' | 'error';
|
||||
variant?: 'solid' | 'outline' | 'text';
|
||||
loading?: HeaderValue<boolean>;
|
||||
disabled?: HeaderValue<boolean>;
|
||||
dropdownOptions?: HeaderValue<DropdownOption[]>;
|
||||
onClick?: () => void | Promise<void>;
|
||||
onSelect?: (key: string | number, option: DropdownOption) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export const pageHeader = shallowReactive<{
|
||||
subtitle: HeaderValue<string> | '';
|
||||
actions: PageHeaderAction[];
|
||||
}>({
|
||||
subtitle: '',
|
||||
actions: []
|
||||
});
|
||||
|
||||
export function setPageHeader(config: { subtitle?: HeaderValue<string>; actions?: PageHeaderAction[] }) {
|
||||
pageHeader.subtitle = config.subtitle || '';
|
||||
pageHeader.actions = config.actions || [];
|
||||
}
|
||||
|
||||
export function resetPageHeader() {
|
||||
pageHeader.subtitle = '';
|
||||
pageHeader.actions = [];
|
||||
}
|
||||
|
||||
export function resolveHeaderValue<T>(value: HeaderValue<T> | undefined): T | undefined;
|
||||
export function resolveHeaderValue<T>(value: HeaderValue<T> | undefined, fallback: T): T;
|
||||
export function resolveHeaderValue<T>(value: HeaderValue<T> | undefined, fallback?: T): T | undefined {
|
||||
if (typeof value === 'function') {
|
||||
return (value as () => T)();
|
||||
}
|
||||
return value === undefined ? fallback : unref(value);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
export type AppTheme = 'light' | 'dark';
|
||||
|
||||
const THEME_KEY = 'theme';
|
||||
|
||||
export const themeName = ref<AppTheme>(readStoredTheme());
|
||||
|
||||
applyTheme(themeName.value);
|
||||
|
||||
export function setAppTheme(nextTheme: AppTheme) {
|
||||
themeName.value = nextTheme;
|
||||
applyTheme(nextTheme);
|
||||
}
|
||||
|
||||
export function toggleAppTheme() {
|
||||
setAppTheme(themeName.value === 'dark' ? 'light' : 'dark');
|
||||
}
|
||||
|
||||
function readStoredTheme(): AppTheme {
|
||||
try {
|
||||
const stored = typeof localStorage === 'undefined' ? null : localStorage.getItem(THEME_KEY);
|
||||
return stored === 'dark' ? 'dark' : 'light';
|
||||
} catch {
|
||||
return 'light';
|
||||
}
|
||||
}
|
||||
|
||||
function applyTheme(nextTheme: AppTheme) {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.setAttribute('data-theme', nextTheme);
|
||||
}
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem(THEME_KEY, nextTheme);
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage failures so theme switching never blocks the UI.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createApp } from 'vue';
|
||||
|
||||
import App from './App.vue';
|
||||
import { router } from './router';
|
||||
import './styles.css';
|
||||
|
||||
if (isTauriRuntime()) {
|
||||
window.addEventListener('contextmenu', event => event.preventDefault());
|
||||
window.addEventListener('keydown', event => {
|
||||
if (event.key === 'F12' || (event.ctrlKey && event.shiftKey && event.key.toLowerCase() === 'i')) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createApp(App).use(router).mount('#app');
|
||||
|
||||
function isTauriRuntime(): boolean {
|
||||
const win = window as Window & {
|
||||
__TAURI__?: unknown;
|
||||
__TAURI_INTERNALS__?: unknown;
|
||||
};
|
||||
return Boolean(win.__TAURI__ || win.__TAURI_INTERNALS__ || navigator.userAgent.includes('Tauri'));
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
|
||||
|
||||
const FileWorkflow = () => import('./components/FileWorkflow.vue');
|
||||
const CapacityDashboard = () => import('./components/CapacityDashboard.vue');
|
||||
const HistoryPanel = () => import('./components/HistoryPanel.vue');
|
||||
const DatabasePanel = () => import('./components/DatabasePanel.vue');
|
||||
const SettingsPanel = () => import('./components/SettingsPanel.vue');
|
||||
const ScriptPanel = () => import('./components/ScriptPanel.vue');
|
||||
|
||||
export const routes: RouteRecordRaw[] = [
|
||||
{ path: '/', redirect: { name: 'workflow' } },
|
||||
{ path: '/upload', name: 'workflow', component: FileWorkflow, meta: { title: '数据处理' } },
|
||||
{ path: '/dashboard', name: 'dashboard', component: CapacityDashboard, meta: { title: '容量看板' } },
|
||||
{ path: '/history', name: 'history', component: HistoryPanel, meta: { title: '处理历史' } },
|
||||
{ path: '/database', name: 'database', component: DatabasePanel, meta: { title: '数据管理' } },
|
||||
{ path: '/script', name: 'script', component: ScriptPanel, meta: { title: '脚本编辑' } },
|
||||
{ path: '/settings', name: 'settings', component: SettingsPanel, meta: { title: '系统设置' } },
|
||||
{ path: '/:pathMatch(.*)*', redirect: { name: 'workflow' } }
|
||||
];
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,271 @@
|
||||
export interface LoginResponse {
|
||||
success: boolean;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
detail?: string | ApiErrorDetail;
|
||||
error?: string;
|
||||
message?: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface ApiErrorDetail {
|
||||
code?: string;
|
||||
message?: string;
|
||||
expires_on?: string;
|
||||
current_date?: string;
|
||||
key_label?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface LicenseStatus {
|
||||
success: boolean;
|
||||
expires_on: string;
|
||||
key_label: string;
|
||||
current_date?: string | null;
|
||||
zip_count?: number;
|
||||
}
|
||||
|
||||
export interface CellDataResult {
|
||||
selected_files: number;
|
||||
parsed_rows: number;
|
||||
imported_rows: number;
|
||||
skipped_rows: number;
|
||||
}
|
||||
|
||||
export interface TaskStatus {
|
||||
task_id: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed' | string;
|
||||
stage?: string | null;
|
||||
logs: string[];
|
||||
elapsed_time?: number;
|
||||
error?: string;
|
||||
error_detail?: ApiErrorDetail;
|
||||
result?: CellDataResult;
|
||||
}
|
||||
|
||||
export interface ActiveTask {
|
||||
has_active: boolean;
|
||||
task_id?: string;
|
||||
stage?: string;
|
||||
started_at?: string;
|
||||
logs?: string[];
|
||||
}
|
||||
|
||||
export interface HistoryRecord {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
status: string;
|
||||
work_dir: string;
|
||||
file_count: number;
|
||||
elapsed_time: number;
|
||||
error?: string | null;
|
||||
result_tables: string[];
|
||||
}
|
||||
|
||||
export interface HistoryDetail extends HistoryRecord {
|
||||
logs: string[];
|
||||
}
|
||||
|
||||
export interface HistoryFileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'dir' | 'file';
|
||||
size: number;
|
||||
size_formatted: string;
|
||||
modified: string;
|
||||
modified_formatted: string;
|
||||
}
|
||||
|
||||
export interface HistoryFilesResponse {
|
||||
success: boolean;
|
||||
record_id: string;
|
||||
path: string;
|
||||
parent_path: string | null;
|
||||
entries: HistoryFileEntry[];
|
||||
}
|
||||
|
||||
export interface CacheSize {
|
||||
success: boolean;
|
||||
size_bytes: number;
|
||||
size_formatted: string;
|
||||
file_count: number;
|
||||
dir_count: number;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
update: string;
|
||||
metrix_enabled: boolean;
|
||||
source_type: 'external' | 'metrix';
|
||||
warehouse_type: 'mysql' | 'metrix';
|
||||
mysql: {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
passwd?: string;
|
||||
dbname: string;
|
||||
};
|
||||
metrix: MetrixConfig;
|
||||
data_mappings: DataMappingsConfig;
|
||||
remote_data: RemoteDataConfig;
|
||||
cell_data: CellDataConfig;
|
||||
history_retention: HistoryRetentionConfig;
|
||||
sheet_filter: string[];
|
||||
extract_fields: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface MetrixConfig {
|
||||
base_url: string;
|
||||
token?: string;
|
||||
storage_id: string;
|
||||
database_conn_id: string;
|
||||
target_database: string;
|
||||
recent_days: number;
|
||||
}
|
||||
|
||||
export interface DataDirectoryMapping {
|
||||
path: string;
|
||||
table: string;
|
||||
ready_rule: 'daily' | 'auto';
|
||||
}
|
||||
|
||||
export interface DataMappingsConfig {
|
||||
directories: DataDirectoryMapping[];
|
||||
table_field_mappings: Record<string, Array<Record<string, unknown>>>;
|
||||
}
|
||||
|
||||
export interface RemoteDataConfig {
|
||||
enabled: boolean;
|
||||
protocol: 'ftp' | 'sftp';
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
passwd?: string;
|
||||
remote_dir: string;
|
||||
passive: boolean;
|
||||
timeout: number;
|
||||
auto_delete_source: boolean;
|
||||
auto_scheduler: RemoteAutoSchedulerConfig;
|
||||
}
|
||||
|
||||
export interface CellDataConfig {
|
||||
remote_data: RemoteDataConfig;
|
||||
mysql: {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
passwd?: string;
|
||||
dbname: string;
|
||||
};
|
||||
scan_paths: string[];
|
||||
year_dir_regex: string;
|
||||
month_dir_regex: string;
|
||||
day_dir_regex: string;
|
||||
file_name_regex: string;
|
||||
file_time_regex: string;
|
||||
mapping: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RemoteAutoSchedulerConfig {
|
||||
enabled: boolean;
|
||||
check_interval_hours: number;
|
||||
expected_directories: string[];
|
||||
week_offset: number;
|
||||
}
|
||||
|
||||
export interface RemoteSchedulerStatus {
|
||||
enabled: boolean;
|
||||
running: boolean;
|
||||
check_interval_hours?: number;
|
||||
expected_directories?: string[];
|
||||
week_offset?: number;
|
||||
auto_delete_source?: boolean;
|
||||
next_check_at?: string | null;
|
||||
last_check_at?: string | null;
|
||||
last_result?: string;
|
||||
last_message?: string;
|
||||
failure_count?: number;
|
||||
task_running?: boolean;
|
||||
task_id?: string | null;
|
||||
ready_flag?: {
|
||||
exists: boolean;
|
||||
ready_at?: string;
|
||||
week_start?: string;
|
||||
week_end?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
target_week?: {
|
||||
start: string;
|
||||
end: string;
|
||||
days?: string[];
|
||||
};
|
||||
directory_status?: Record<
|
||||
string,
|
||||
{
|
||||
ready: boolean;
|
||||
granularity?: 'daily' | 'weekly' | string | null;
|
||||
found_days: string[];
|
||||
missing_days: string[];
|
||||
found_count: number;
|
||||
required_count: number;
|
||||
file_count: number;
|
||||
error?: string | null;
|
||||
skipped?: boolean;
|
||||
skip_reason?: string | null;
|
||||
}
|
||||
>;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface HistoryRetentionConfig {
|
||||
enabled: boolean;
|
||||
keep_count: number;
|
||||
}
|
||||
|
||||
export interface TableInfo {
|
||||
name: string;
|
||||
columns: Array<{
|
||||
Field?: string;
|
||||
Type?: string;
|
||||
Null?: string;
|
||||
Key?: string;
|
||||
Default?: unknown;
|
||||
Extra?: string;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
row_count: number;
|
||||
}
|
||||
|
||||
export interface TableData {
|
||||
data: Array<Record<string, unknown>>;
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface ScriptContent {
|
||||
success: boolean;
|
||||
content: string;
|
||||
modified: string | null;
|
||||
path: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface DatabaseInfo {
|
||||
success: boolean;
|
||||
version?: string;
|
||||
load_data_infile?: boolean;
|
||||
load_data_message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ApiMessage {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
update?: string;
|
||||
task_id?: string;
|
||||
}
|
||||
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module 'monaco-editor/esm/vs/basic-languages/mysql/mysql.js';
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import AutoImport from 'unplugin-auto-import/vite';
|
||||
import Components from 'unplugin-vue-components/vite';
|
||||
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({
|
||||
imports: ['vue', 'vue-router'],
|
||||
resolvers: [NaiveUiResolver()],
|
||||
dts: false
|
||||
}),
|
||||
Components({
|
||||
resolvers: [NaiveUiResolver()],
|
||||
dts: false
|
||||
})
|
||||
],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:9081',
|
||||
'/health': 'http://localhost:9081'
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user