refactor: 重构后端结构并迁移前端到 Vue3
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
+1647
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vicons/ionicons5": "^0.13.0",
|
||||
"naive-ui": "^2.42.0",
|
||||
"vue": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"typescript": "^5.9.0",
|
||||
"vite": "^7.0.0",
|
||||
"vue-tsc": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<n-config-provider>
|
||||
<n-message-provider>
|
||||
<n-dialog-provider>
|
||||
<AppShell />
|
||||
</n-dialog-provider>
|
||||
</n-message-provider>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppShell from './AppShell.vue';
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<LoginView v-if="!token" :loading="loginLoading" @login="handleLogin" />
|
||||
<n-layout v-else has-sider class="app-layout">
|
||||
<n-layout-sider
|
||||
bordered
|
||||
collapse-mode="width"
|
||||
:collapsed-width="64"
|
||||
:width="224"
|
||||
show-trigger
|
||||
>
|
||||
<div class="brand">
|
||||
<div class="brand-mark">CR</div>
|
||||
<div class="brand-text">CapacityReport</div>
|
||||
</div>
|
||||
<n-menu
|
||||
v-model:value="activeMenu"
|
||||
:collapsed-width="64"
|
||||
:collapsed-icon-size="22"
|
||||
:options="menuOptions"
|
||||
/>
|
||||
</n-layout-sider>
|
||||
|
||||
<n-layout>
|
||||
<n-layout-header bordered class="topbar">
|
||||
<div>
|
||||
<div class="page-title">{{ currentTitle }}</div>
|
||||
<div class="page-subtitle">容量报表处理与数据维护</div>
|
||||
</div>
|
||||
<n-space align="center">
|
||||
<n-tag size="small" type="success" round>已登录</n-tag>
|
||||
<n-button tertiary circle title="退出登录" @click="logout">
|
||||
<template #icon>
|
||||
<n-icon><LogOutOutline /></n-icon>
|
||||
</template>
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-layout-header>
|
||||
|
||||
<n-layout-content class="content">
|
||||
<FileWorkflow v-if="activeMenu === 'workflow'" />
|
||||
<HistoryPanel v-else-if="activeMenu === 'history'" />
|
||||
<DatabasePanel v-else-if="activeMenu === 'database'" />
|
||||
<ScriptPanel v-else-if="activeMenu === 'script'" />
|
||||
<SettingsPanel v-else />
|
||||
</n-layout-content>
|
||||
</n-layout>
|
||||
</n-layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, ref, type Component } from 'vue';
|
||||
import { useMessage, type MenuOption, NIcon } from 'naive-ui';
|
||||
import {
|
||||
CloudUploadOutline,
|
||||
ConstructOutline,
|
||||
FileTrayFullOutline,
|
||||
LogOutOutline,
|
||||
ServerOutline,
|
||||
SettingsOutline
|
||||
} from '@vicons/ionicons5';
|
||||
|
||||
import { apiPost, clearToken, getToken, setToken, setUnauthorizedHandler } from './api/client';
|
||||
import type { LoginResponse } from './types';
|
||||
import LoginView from './components/LoginView.vue';
|
||||
import FileWorkflow from './components/FileWorkflow.vue';
|
||||
import HistoryPanel from './components/HistoryPanel.vue';
|
||||
import DatabasePanel from './components/DatabasePanel.vue';
|
||||
import ScriptPanel from './components/ScriptPanel.vue';
|
||||
import SettingsPanel from './components/SettingsPanel.vue';
|
||||
|
||||
const message = useMessage();
|
||||
const token = ref(getToken());
|
||||
const activeMenu = ref('workflow');
|
||||
const loginLoading = ref(false);
|
||||
|
||||
const menuOptions: MenuOption[] = [
|
||||
{ label: '处理任务', key: 'workflow', icon: renderIcon(CloudUploadOutline) },
|
||||
{ label: '处理历史', key: 'history', icon: renderIcon(FileTrayFullOutline) },
|
||||
{ label: '数据表', key: 'database', icon: renderIcon(ServerOutline) },
|
||||
{ label: 'SQL 脚本', key: 'script', icon: renderIcon(ConstructOutline) },
|
||||
{ label: '系统设置', key: 'settings', icon: renderIcon(SettingsOutline) }
|
||||
];
|
||||
|
||||
const currentTitle = computed(() => {
|
||||
return String(menuOptions.find(item => item.key === activeMenu.value)?.label || '');
|
||||
});
|
||||
|
||||
setUnauthorizedHandler(() => {
|
||||
token.value = '';
|
||||
message.warning('登录已过期');
|
||||
});
|
||||
|
||||
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('登录成功');
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '登录失败');
|
||||
} finally {
|
||||
loginLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
clearToken();
|
||||
token.value = '';
|
||||
}
|
||||
|
||||
function renderIcon(icon: Component) {
|
||||
return () => h(NIcon, null, { default: () => h(icon) });
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { ApiError } from '../types';
|
||||
|
||||
const TOKEN_KEY = 'capacity_report_token';
|
||||
let onUnauthorized: (() => void) | null = null;
|
||||
|
||||
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 = getToken();
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
const response = await fetch(url, { ...init, headers });
|
||||
if (response.status === 401) {
|
||||
clearToken();
|
||||
onUnauthorized?.();
|
||||
throw new Error('登录已过期,请重新登录');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await readError(response);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
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', 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 Error('登录已过期,请重新登录'));
|
||||
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<void> {
|
||||
const headers = new Headers({ 'Content-Type': 'application/json' });
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readError(response));
|
||||
}
|
||||
|
||||
await saveBlobResponse(response, filename);
|
||||
}
|
||||
|
||||
export async function downloadGet(url: string, fallbackFilename: string): Promise<void> {
|
||||
const headers = new Headers();
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
const response = await fetch(url, { headers });
|
||||
if (response.status === 401) {
|
||||
clearToken();
|
||||
onUnauthorized?.();
|
||||
throw new Error('登录已过期,请重新登录');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readError(response));
|
||||
}
|
||||
|
||||
const filename = parseFilename(response.headers.get('content-disposition')) || fallbackFilename;
|
||||
await saveBlobResponse(response, filename);
|
||||
}
|
||||
|
||||
async function readError(response: Response): Promise<string> {
|
||||
try {
|
||||
const data = (await response.json()) as ApiError;
|
||||
return data.detail || data.error || data.message || response.statusText;
|
||||
} catch {
|
||||
return response.statusText || '请求失败';
|
||||
}
|
||||
}
|
||||
|
||||
function parseXhrError(text: string): string {
|
||||
try {
|
||||
const data = JSON.parse(text) as ApiError;
|
||||
return data.detail || data.error || data.message || '请求失败';
|
||||
} catch {
|
||||
return text || '请求失败';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBlobResponse(response: Response, filename: string): Promise<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
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] || '';
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
<template>
|
||||
<n-grid :cols="12" :x-gap="16" :y-gap="16" responsive="screen">
|
||||
<n-gi :span="12" :m="4">
|
||||
<n-space vertical size="large">
|
||||
<n-card title="数据库连接" size="small">
|
||||
<template #header-extra>
|
||||
<n-button size="small" tertiary :loading="testing" @click="testConnection">测试</n-button>
|
||||
</template>
|
||||
<n-space vertical>
|
||||
<n-alert v-if="databaseInfo" :type="databaseInfo.success ? 'success' : 'error'" :bordered="false">
|
||||
MySQL {{ databaseInfo.version || '-' }} / LOAD DATA:
|
||||
{{ databaseInfo.load_data_infile ? '可用' : '不可用' }}
|
||||
</n-alert>
|
||||
<n-button block :loading="loadingTables" @click="loadTables">刷新数据表</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<n-card title="数据表" size="small">
|
||||
<template #header-extra>
|
||||
<n-button size="small" tertiary type="error" :disabled="tables.length === 0" @click="confirmDropAll">
|
||||
删除全部
|
||||
</n-button>
|
||||
</template>
|
||||
|
||||
<n-space vertical>
|
||||
<n-select
|
||||
v-model:value="selectedTable"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="选择数据表"
|
||||
:options="tableOptions"
|
||||
@update:value="handleSelectTable"
|
||||
/>
|
||||
<n-empty v-if="tables.length === 0 && !loadingTables" description="暂无数据表" />
|
||||
<n-scrollbar v-else class="table-list">
|
||||
<n-list bordered>
|
||||
<n-list-item v-for="table in tables" :key="table">
|
||||
<n-button text @click="loadTable(table)">{{ table }}</n-button>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</n-scrollbar>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-space>
|
||||
</n-gi>
|
||||
|
||||
<n-gi :span="12" :m="8">
|
||||
<n-card title="表数据" size="small">
|
||||
<template #header-extra>
|
||||
<n-space>
|
||||
<n-button size="small" tertiary :disabled="!selectedTable" @click="reloadTable">刷新</n-button>
|
||||
<n-button size="small" tertiary :disabled="!selectedTable" @click="downloadTable('csv')">CSV</n-button>
|
||||
<n-button size="small" tertiary :disabled="!selectedTable" @click="downloadTable('xlsx')">XLSX</n-button>
|
||||
<n-button size="small" tertiary type="warning" :disabled="!selectedTable" @click="confirmTruncate">
|
||||
清空
|
||||
</n-button>
|
||||
<n-button size="small" tertiary type="error" :disabled="!selectedTable" @click="confirmDrop">
|
||||
删除
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<n-empty v-if="!selectedTable" description="请选择数据表" />
|
||||
<n-space v-else vertical size="large">
|
||||
<n-descriptions v-if="tableInfo" bordered size="small" :column="3">
|
||||
<n-descriptions-item label="表名">{{ tableInfo.name }}</n-descriptions-item>
|
||||
<n-descriptions-item label="行数">{{ tableInfo.row_count }}</n-descriptions-item>
|
||||
<n-descriptions-item label="字段数">{{ tableInfo.columns.length }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
|
||||
<n-data-table
|
||||
size="small"
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:loading="loadingData"
|
||||
:bordered="false"
|
||||
:single-line="false"
|
||||
max-height="420"
|
||||
/>
|
||||
<div class="table-footer">
|
||||
<span>共 {{ total }} 行</span>
|
||||
<n-pagination
|
||||
:page="page"
|
||||
:page-count="totalPages"
|
||||
:page-size="pageSize"
|
||||
show-size-picker
|
||||
:page-sizes="[20, 50, 100, 200]"
|
||||
@update:page="changePage"
|
||||
@update:page-size="changePageSize"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<n-collapse v-if="tableInfo">
|
||||
<n-collapse-item title="字段结构" name="columns">
|
||||
<n-table size="small" :bordered="false">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>字段</th>
|
||||
<th>类型</th>
|
||||
<th>允许空</th>
|
||||
<th>键</th>
|
||||
<th>默认值</th>
|
||||
<th>备注</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="column in tableInfo.columns" :key="String(column.Field)">
|
||||
<td>{{ column.Field }}</td>
|
||||
<td>{{ column.Type }}</td>
|
||||
<td>{{ column.Null }}</td>
|
||||
<td>{{ column.Key }}</td>
|
||||
<td>{{ column.Default ?? '' }}</td>
|
||||
<td>{{ column.Extra }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</n-table>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useDialog, useMessage } from 'naive-ui';
|
||||
import type { DataTableColumns } from 'naive-ui';
|
||||
|
||||
import { apiGet, apiPost, download } from '../api/client';
|
||||
import type { ApiMessage, DatabaseInfo, TableData, TableInfo } from '../types';
|
||||
|
||||
type RowData = Record<string, unknown>;
|
||||
type DownloadFormat = 'csv' | 'xlsx';
|
||||
|
||||
const message = useMessage();
|
||||
const dialog = useDialog();
|
||||
const databaseInfo = ref<DatabaseInfo | null>(null);
|
||||
const tables = ref<string[]>([]);
|
||||
const selectedTable = ref<string | null>(null);
|
||||
const tableInfo = ref<TableInfo | null>(null);
|
||||
const rows = ref<RowData[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(50);
|
||||
const loadingTables = ref(false);
|
||||
const loadingData = ref(false);
|
||||
const testing = ref(false);
|
||||
|
||||
const tableOptions = computed(() => tables.value.map(table => ({ label: table, value: table })));
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)));
|
||||
const columns = computed<DataTableColumns<RowData>>(() => {
|
||||
const keys = rows.value.length
|
||||
? Object.keys(rows.value[0])
|
||||
: tableInfo.value?.columns.map(column => String(column.Field || '')).filter(Boolean) || [];
|
||||
|
||||
return keys.map(key => ({
|
||||
title: key,
|
||||
key,
|
||||
minWidth: 120,
|
||||
ellipsis: { tooltip: true },
|
||||
render: row => formatCell(row[key])
|
||||
}));
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void testConnection();
|
||||
void loadTables();
|
||||
});
|
||||
|
||||
async function testConnection() {
|
||||
testing.value = true;
|
||||
try {
|
||||
const result = await apiPost<ApiMessage>('/api/database/test');
|
||||
message[result.success ? 'success' : 'error'](result.message || '连接测试完成');
|
||||
databaseInfo.value = await apiGet<DatabaseInfo>('/api/database/info');
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '数据库连接测试失败');
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTables() {
|
||||
loadingTables.value = true;
|
||||
try {
|
||||
const result = await apiGet<{ tables: string[] }>('/api/database/tables');
|
||||
tables.value = result.tables;
|
||||
if (selectedTable.value && !tables.value.includes(selectedTable.value)) {
|
||||
selectedTable.value = null;
|
||||
tableInfo.value = null;
|
||||
rows.value = [];
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '加载数据表失败');
|
||||
} finally {
|
||||
loadingTables.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectTable(value: string | null) {
|
||||
if (value) {
|
||||
void loadTable(value);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTable(table: string) {
|
||||
selectedTable.value = table;
|
||||
page.value = 1;
|
||||
await Promise.all([loadTableInfo(table), loadTableData()]);
|
||||
}
|
||||
|
||||
async function reloadTable() {
|
||||
if (!selectedTable.value) return;
|
||||
await Promise.all([loadTableInfo(selectedTable.value), loadTableData()]);
|
||||
}
|
||||
|
||||
async function loadTableInfo(table: string) {
|
||||
try {
|
||||
tableInfo.value = await apiPost<TableInfo>('/api/database/table/info', { table_name: table });
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '加载表结构失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTableData() {
|
||||
if (!selectedTable.value) return;
|
||||
loadingData.value = true;
|
||||
try {
|
||||
const result = await apiPost<TableData>('/api/database/table/data', {
|
||||
table_name: selectedTable.value,
|
||||
page: page.value,
|
||||
page_size: pageSize.value
|
||||
});
|
||||
rows.value = result.data;
|
||||
total.value = result.total;
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '加载表数据失败');
|
||||
} finally {
|
||||
loadingData.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function changePage(value: number) {
|
||||
page.value = value;
|
||||
void loadTableData();
|
||||
}
|
||||
|
||||
function changePageSize(value: number) {
|
||||
pageSize.value = value;
|
||||
page.value = 1;
|
||||
void loadTableData();
|
||||
}
|
||||
|
||||
async function downloadTable(format: DownloadFormat) {
|
||||
if (!selectedTable.value) return;
|
||||
try {
|
||||
await download('/api/download', { table_name: selectedTable.value, format }, `${selectedTable.value}.${format}`);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '导出失败');
|
||||
}
|
||||
}
|
||||
|
||||
function confirmTruncate() {
|
||||
if (!selectedTable.value) return;
|
||||
dialog.warning({
|
||||
title: '清空数据表',
|
||||
content: `确认清空 ${selectedTable.value} 的全部数据?`,
|
||||
positiveText: '清空',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: truncateTable
|
||||
});
|
||||
}
|
||||
|
||||
async function truncateTable() {
|
||||
if (!selectedTable.value) return;
|
||||
try {
|
||||
await apiPost('/api/database/table/truncate', { table_name: selectedTable.value });
|
||||
message.success('数据表已清空');
|
||||
await reloadTable();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '清空失败');
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDrop() {
|
||||
if (!selectedTable.value) return;
|
||||
dialog.error({
|
||||
title: '删除数据表',
|
||||
content: `确认删除数据表 ${selectedTable.value}?`,
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: dropTable
|
||||
});
|
||||
}
|
||||
|
||||
async function dropTable() {
|
||||
if (!selectedTable.value) return;
|
||||
try {
|
||||
await apiPost('/api/database/table/drop', { table_name: selectedTable.value });
|
||||
message.success('数据表已删除');
|
||||
selectedTable.value = null;
|
||||
tableInfo.value = null;
|
||||
rows.value = [];
|
||||
total.value = 0;
|
||||
await loadTables();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDropAll() {
|
||||
dialog.error({
|
||||
title: '删除全部数据表',
|
||||
content: '确认删除当前数据库中的全部数据表?',
|
||||
positiveText: '删除全部',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: dropAllTables
|
||||
});
|
||||
}
|
||||
|
||||
async function dropAllTables() {
|
||||
try {
|
||||
const result = await apiPost<ApiMessage & { dropped_count?: number }>('/api/database/table/drop-all');
|
||||
message.success(result.message || `已删除 ${result.dropped_count || 0} 张表`);
|
||||
selectedTable.value = null;
|
||||
tableInfo.value = null;
|
||||
rows.value = [];
|
||||
total.value = 0;
|
||||
await loadTables();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '删除全部数据表失败');
|
||||
}
|
||||
}
|
||||
|
||||
function formatCell(value: unknown): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<n-grid :cols="12" :x-gap="16" :y-gap="16" responsive="screen">
|
||||
<n-gi :span="12" :m="5">
|
||||
<n-card title="文件上传" size="small">
|
||||
<n-space vertical size="large">
|
||||
<n-space>
|
||||
<n-button @click="fileInput?.click()">
|
||||
<template #icon><n-icon><DocumentAttachOutline /></n-icon></template>
|
||||
选择文件
|
||||
</n-button>
|
||||
<n-button @click="folderInput?.click()">
|
||||
<template #icon><n-icon><FolderOpenOutline /></n-icon></template>
|
||||
选择目录
|
||||
</n-button>
|
||||
<n-button tertiary :disabled="files.length === 0 || working" @click="clearFiles">
|
||||
清空
|
||||
</n-button>
|
||||
</n-space>
|
||||
|
||||
<input ref="fileInput" class="hidden-input" type="file" multiple @change="pickFiles" />
|
||||
<input ref="folderInput" class="hidden-input" type="file" multiple @change="pickFiles" />
|
||||
|
||||
<n-statistic label="待上传文件" :value="files.length" />
|
||||
<n-progress v-if="uploadProgress > 0" type="line" :percentage="uploadProgress" />
|
||||
|
||||
<n-scrollbar class="file-list">
|
||||
<n-empty v-if="files.length === 0" description="未选择文件" />
|
||||
<n-list v-else bordered>
|
||||
<n-list-item v-for="item in files" :key="item.path">
|
||||
<n-thing :title="item.path" :description="formatBytes(item.file.size)" />
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</n-scrollbar>
|
||||
|
||||
<n-button type="primary" block :disabled="files.length === 0" :loading="working" @click="uploadAndStart">
|
||||
上传并处理
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
|
||||
<n-gi :span="12" :m="7">
|
||||
<n-card title="处理状态" size="small">
|
||||
<template #header-extra>
|
||||
<n-space>
|
||||
<n-tag v-if="taskStatus" :type="statusType">{{ taskStatus.status }}</n-tag>
|
||||
<n-button size="small" tertiary @click="checkActiveTask">刷新</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<n-space vertical>
|
||||
<n-alert v-if="activeTask?.has_active" type="info" :bordered="false">
|
||||
当前任务:{{ activeTask.task_id }} / {{ activeTask.stage }}
|
||||
</n-alert>
|
||||
<n-alert v-if="taskStatus?.error" type="error" :bordered="false">
|
||||
{{ taskStatus.error }}
|
||||
</n-alert>
|
||||
<n-log :log="logText" language="text" trim class="log-panel" />
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { DocumentAttachOutline, FolderOpenOutline } from '@vicons/ionicons5';
|
||||
|
||||
import { apiGet, apiPost, upload } from '../api/client';
|
||||
import type { ActiveTask, TaskStatus } from '../types';
|
||||
|
||||
interface PickedFile {
|
||||
file: File;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface UploadResponse {
|
||||
success: boolean;
|
||||
task_id: string;
|
||||
file_count: number;
|
||||
}
|
||||
|
||||
const message = useMessage();
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const folderInput = ref<HTMLInputElement | null>(null);
|
||||
const files = ref<PickedFile[]>([]);
|
||||
const uploadProgress = ref(0);
|
||||
const working = ref(false);
|
||||
const taskStatus = ref<TaskStatus | null>(null);
|
||||
const activeTask = ref<ActiveTask | null>(null);
|
||||
let timer: number | undefined;
|
||||
|
||||
const logText = computed(() => taskStatus.value?.logs?.join('\n') || '等待任务开始');
|
||||
const statusType = computed(() => {
|
||||
if (taskStatus.value?.status === 'completed') return 'success';
|
||||
if (taskStatus.value?.status === 'failed') return 'error';
|
||||
return 'info';
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
const folder = folderInput.value as (HTMLInputElement & { webkitdirectory?: boolean }) | null;
|
||||
if (folder) {
|
||||
folder.webkitdirectory = true;
|
||||
}
|
||||
void checkActiveTask();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling();
|
||||
});
|
||||
|
||||
function pickFiles(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const selected = Array.from(input.files || []);
|
||||
for (const file of selected) {
|
||||
const path = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
|
||||
if (!files.value.some(item => item.path === path)) {
|
||||
files.value.push({ file, path });
|
||||
}
|
||||
}
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function clearFiles() {
|
||||
files.value = [];
|
||||
uploadProgress.value = 0;
|
||||
}
|
||||
|
||||
async function uploadAndStart() {
|
||||
const formData = new FormData();
|
||||
for (const item of files.value) {
|
||||
formData.append('files', item.file, item.path);
|
||||
}
|
||||
|
||||
working.value = true;
|
||||
uploadProgress.value = 0;
|
||||
try {
|
||||
const result = await upload<UploadResponse>('/api/upload', formData, value => {
|
||||
uploadProgress.value = value;
|
||||
});
|
||||
message.success(`上传完成:${result.file_count} 个文件`);
|
||||
await apiPost('/api/process/start', { task_id: result.task_id });
|
||||
taskStatus.value = { task_id: result.task_id, status: 'processing', logs: [] };
|
||||
startPolling(result.task_id);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '上传或启动任务失败');
|
||||
} finally {
|
||||
working.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkActiveTask() {
|
||||
try {
|
||||
activeTask.value = await apiGet<ActiveTask>('/api/task/status');
|
||||
if (activeTask.value.has_active && activeTask.value.task_id) {
|
||||
startPolling(activeTask.value.task_id);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '获取任务状态失败');
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling(taskId: string) {
|
||||
stopPolling();
|
||||
void poll(taskId);
|
||||
timer = window.setInterval(() => void poll(taskId), 2000);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (timer) {
|
||||
window.clearInterval(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function poll(taskId: string) {
|
||||
try {
|
||||
taskStatus.value = await apiPost<TaskStatus>('/api/process/status', { task_id: taskId });
|
||||
if (['completed', 'failed'].includes(taskStatus.value.status)) {
|
||||
stopPolling();
|
||||
await checkActiveTask();
|
||||
}
|
||||
} catch (error) {
|
||||
stopPolling();
|
||||
message.error(error instanceof Error ? error.message : '刷新任务状态失败');
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(size: number): string {
|
||||
if (size === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let value = size;
|
||||
for (const unit of units) {
|
||||
if (value < 1024) return `${value.toFixed(1)} ${unit}`;
|
||||
value /= 1024;
|
||||
}
|
||||
return `${value.toFixed(1)} TB`;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<n-grid :cols="12" :x-gap="16" :y-gap="16" responsive="screen">
|
||||
<n-gi :span="12" :m="5">
|
||||
<n-card title="处理历史" size="small">
|
||||
<template #header-extra>
|
||||
<n-space>
|
||||
<n-button size="small" tertiary :loading="loading" @click="loadHistory">刷新</n-button>
|
||||
<n-button size="small" type="error" tertiary :disabled="records.length === 0" @click="confirmClear">
|
||||
清空
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<n-space vertical>
|
||||
<n-alert v-if="cacheSize" type="default" :bordered="false">
|
||||
缓存占用:{{ cacheSize.size_formatted }},目录 {{ cacheSize.dir_count }} 个
|
||||
</n-alert>
|
||||
|
||||
<n-empty v-if="records.length === 0 && !loading" description="暂无历史记录" />
|
||||
<n-scrollbar v-else class="history-list">
|
||||
<n-list bordered>
|
||||
<n-list-item v-for="record in records" :key="record.id">
|
||||
<n-thing :title="record.id" :description="recordSummary(record)">
|
||||
<template #header-extra>
|
||||
<n-tag size="small" :type="statusType(record.status)">{{ statusText(record.status) }}</n-tag>
|
||||
</template>
|
||||
<template #action>
|
||||
<n-space>
|
||||
<n-button size="small" @click="openDetail(record.id)">详情</n-button>
|
||||
<n-button size="small" tertiary type="error" @click="confirmDelete(record.id)">删除</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-thing>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</n-scrollbar>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
|
||||
<n-gi :span="12" :m="7">
|
||||
<n-card title="任务详情" size="small">
|
||||
<template #header-extra>
|
||||
<n-button v-if="detail" size="small" tertiary @click="loadRecordSize(detail.id)">计算占用</n-button>
|
||||
</template>
|
||||
|
||||
<n-empty v-if="!detail" description="请选择一条历史记录" />
|
||||
<n-space v-else vertical size="large">
|
||||
<n-descriptions bordered size="small" :column="2">
|
||||
<n-descriptions-item label="任务 ID">{{ detail.id }}</n-descriptions-item>
|
||||
<n-descriptions-item label="状态">{{ statusText(detail.status) }}</n-descriptions-item>
|
||||
<n-descriptions-item label="文件数">{{ detail.file_count }}</n-descriptions-item>
|
||||
<n-descriptions-item label="耗时">{{ formatElapsed(detail.elapsed_time) }}</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">{{ formatDate(detail.timestamp) }}</n-descriptions-item>
|
||||
<n-descriptions-item label="占用">{{ recordSize }}</n-descriptions-item>
|
||||
<n-descriptions-item label="工作目录" :span="2">{{ detail.work_dir }}</n-descriptions-item>
|
||||
<n-descriptions-item v-if="detail.error" label="错误" :span="2">{{ detail.error }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
|
||||
<div>
|
||||
<div class="section-label">处理日志</div>
|
||||
<n-log :log="detail.logs.join('\n') || '暂无日志'" language="text" trim class="log-panel" />
|
||||
</div>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useDialog, useMessage } from 'naive-ui';
|
||||
import type { TagProps } from 'naive-ui';
|
||||
|
||||
import { apiGet, apiPost } from '../api/client';
|
||||
import type { CacheSize, HistoryDetail, HistoryRecord } from '../types';
|
||||
|
||||
const message = useMessage();
|
||||
const dialog = useDialog();
|
||||
const records = ref<HistoryRecord[]>([]);
|
||||
const detail = ref<HistoryDetail | null>(null);
|
||||
const cacheSize = ref<CacheSize | null>(null);
|
||||
const recordSize = ref('-');
|
||||
const loading = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
void loadHistory();
|
||||
});
|
||||
|
||||
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 });
|
||||
recordSize.value = '-';
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '加载任务详情失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecordSize(recordId: string) {
|
||||
try {
|
||||
const result = await apiPost<{ success: boolean; size_formatted: string }>('/api/history/size', {
|
||||
record_id: recordId
|
||||
});
|
||||
recordSize.value = result.size_formatted;
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '计算目录占用失败');
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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;
|
||||
message.success('已清空历史记录');
|
||||
await loadHistory();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '清空失败');
|
||||
}
|
||||
}
|
||||
|
||||
function statusType(status: string): TagProps['type'] {
|
||||
if (status === 'completed') return 'success';
|
||||
if (status === 'failed') return 'error';
|
||||
if (status === 'processing') return 'info';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
function statusText(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
pending: '待处理',
|
||||
processing: '处理中',
|
||||
completed: '已完成',
|
||||
failed: '失败'
|
||||
};
|
||||
return map[status] || status;
|
||||
}
|
||||
|
||||
function recordSummary(record: HistoryRecord): string {
|
||||
return `${formatDate(record.timestamp)} / ${record.file_count} 个文件 / ${formatElapsed(record.elapsed_time)}`;
|
||||
}
|
||||
|
||||
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,43 @@
|
||||
<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" />
|
||||
</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';
|
||||
|
||||
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() {
|
||||
await formRef.value?.validate();
|
||||
emit('login', { ...form });
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<n-grid :cols="12" :x-gap="16" :y-gap="16" responsive="screen">
|
||||
<n-gi :span="12" :m="8">
|
||||
<n-card title="SQL 脚本" size="small">
|
||||
<template #header-extra>
|
||||
<n-space>
|
||||
<n-button size="small" tertiary :loading="loading" @click="loadScript">重新加载</n-button>
|
||||
<n-button size="small" type="primary" :loading="saving" @click="saveScript">保存</n-button>
|
||||
<n-button size="small" type="warning" :loading="executing" @click="executeScript">执行</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<n-space vertical>
|
||||
<n-alert v-if="scriptPath" type="default" :bordered="false">
|
||||
{{ scriptPath }} / 修改时间:{{ modified || '-' }}
|
||||
</n-alert>
|
||||
<n-input
|
||||
v-model:value="content"
|
||||
type="textarea"
|
||||
class="script-editor"
|
||||
:autosize="{ minRows: 24, maxRows: 32 }"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
|
||||
<n-gi :span="12" :m="4">
|
||||
<n-card title="执行状态" size="small">
|
||||
<template #header-extra>
|
||||
<n-button size="small" tertiary :disabled="!taskId" @click="refreshStatus">刷新</n-button>
|
||||
</template>
|
||||
|
||||
<n-space vertical>
|
||||
<n-tag v-if="taskStatus" :type="statusType">{{ taskStatus.status }}</n-tag>
|
||||
<n-empty v-if="!taskStatus" description="脚本尚未执行" />
|
||||
<n-log v-else :log="logText" language="text" trim class="log-panel" />
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useMessage } from 'naive-ui';
|
||||
|
||||
import { apiGet, apiPost } from '../api/client';
|
||||
import type { ApiMessage, ScriptContent, TaskStatus } from '../types';
|
||||
|
||||
const message = useMessage();
|
||||
const content = ref('');
|
||||
const scriptPath = ref('');
|
||||
const modified = ref<string | null>(null);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const executing = ref(false);
|
||||
const taskId = ref('');
|
||||
const taskStatus = ref<TaskStatus | null>(null);
|
||||
let timer: number | undefined;
|
||||
|
||||
const logText = computed(() => taskStatus.value?.logs?.join('\n') || '等待执行日志');
|
||||
const statusType = computed(() => {
|
||||
if (taskStatus.value?.status === 'completed') return 'success';
|
||||
if (taskStatus.value?.status === 'failed') return 'error';
|
||||
return 'info';
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void loadScript();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling();
|
||||
});
|
||||
|
||||
async function loadScript() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await apiGet<ScriptContent>('/api/script/content');
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '加载脚本失败');
|
||||
}
|
||||
content.value = result.content;
|
||||
scriptPath.value = result.path;
|
||||
modified.value = result.modified;
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '加载脚本失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveScript() {
|
||||
saving.value = true;
|
||||
try {
|
||||
const result = await apiPost<ApiMessage & { modified?: string }>('/api/script/save', {
|
||||
content: content.value
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '保存失败');
|
||||
}
|
||||
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 executeScript() {
|
||||
executing.value = true;
|
||||
try {
|
||||
const result = await apiPost<ApiMessage>('/api/script/execute');
|
||||
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 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>
|
||||
@@ -0,0 +1,313 @@
|
||||
<template>
|
||||
<n-grid :cols="12" :x-gap="16" :y-gap="16" responsive="screen">
|
||||
<n-gi :span="12" :m="6">
|
||||
<n-space vertical size="large">
|
||||
<n-card title="数据库配置" size="small">
|
||||
<template #header-extra>
|
||||
<n-button size="small" tertiary :loading="loading" @click="loadConfig">刷新</n-button>
|
||||
</template>
|
||||
<n-form label-placement="left" label-width="90">
|
||||
<n-form-item label="地址">
|
||||
<n-input v-model:value="mysqlForm.host" />
|
||||
</n-form-item>
|
||||
<n-form-item label="端口">
|
||||
<n-input-number v-model:value="mysqlForm.port" class="full-width" :min="1" :max="65535" />
|
||||
</n-form-item>
|
||||
<n-form-item label="账号">
|
||||
<n-input v-model:value="mysqlForm.user" />
|
||||
</n-form-item>
|
||||
<n-form-item label="密码">
|
||||
<n-input v-model:value="mysqlForm.passwd" type="password" show-password-on="click" />
|
||||
</n-form-item>
|
||||
<n-form-item label="库名">
|
||||
<n-input v-model:value="mysqlForm.dbname" />
|
||||
</n-form-item>
|
||||
<n-button type="primary" :loading="savingMysql" @click="saveMysql">保存数据库配置</n-button>
|
||||
</n-form>
|
||||
<div class="muted-line">配置更新时间:{{ configUpdate || '-' }}</div>
|
||||
</n-card>
|
||||
|
||||
<n-card title="登录密码" size="small">
|
||||
<n-form label-placement="left" label-width="90">
|
||||
<n-form-item label="当前密码">
|
||||
<n-input v-model:value="passwordForm.current_password" type="password" show-password-on="click" />
|
||||
</n-form-item>
|
||||
<n-form-item label="新密码">
|
||||
<n-input v-model:value="passwordForm.new_password" type="password" show-password-on="click" />
|
||||
</n-form-item>
|
||||
<n-form-item label="确认密码">
|
||||
<n-input v-model:value="passwordForm.confirm_password" type="password" show-password-on="click" />
|
||||
</n-form-item>
|
||||
<n-button type="primary" :loading="changingPassword" @click="changePassword">修改密码</n-button>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-card title="配置文件" size="small">
|
||||
<n-space>
|
||||
<n-button @click="downloadConfig">下载配置</n-button>
|
||||
<n-button @click="configInput?.click()">上传配置</n-button>
|
||||
<input ref="configInput" class="hidden-input" type="file" accept=".json" @change="uploadConfigFile" />
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-space>
|
||||
</n-gi>
|
||||
|
||||
<n-gi :span="12" :m="6">
|
||||
<n-space vertical size="large">
|
||||
<n-card title="Sheet 过滤规则" size="small">
|
||||
<n-space vertical>
|
||||
<n-input
|
||||
v-model:value="sheetFilterText"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 6, maxRows: 10 }"
|
||||
placeholder="每行一条规则"
|
||||
/>
|
||||
<n-button type="primary" :loading="savingSheetFilter" @click="saveSheetFilter">保存过滤规则</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<n-card title="字段提取配置" size="small">
|
||||
<n-space vertical>
|
||||
<n-input
|
||||
v-model:value="extractFieldsText"
|
||||
type="textarea"
|
||||
class="json-editor"
|
||||
:autosize="{ minRows: 12, maxRows: 18 }"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<n-space>
|
||||
<n-button :disabled="!extractFieldsText" @click="formatExtractFields">格式化</n-button>
|
||||
<n-button type="primary" :loading="savingExtractFields" @click="saveExtractFields">
|
||||
保存字段配置
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<n-card title="运行状态" size="small">
|
||||
<template #header-extra>
|
||||
<n-button size="small" tertiary @click="loadServiceStatus">刷新</n-button>
|
||||
</template>
|
||||
<n-descriptions v-if="serviceStatus" bordered size="small" :column="2">
|
||||
<n-descriptions-item label="状态">{{ serviceStatus.status }}</n-descriptions-item>
|
||||
<n-descriptions-item label="版本">{{ serviceStatus.version }}</n-descriptions-item>
|
||||
<n-descriptions-item label="系统">{{ serviceStatus.platform }}</n-descriptions-item>
|
||||
<n-descriptions-item label="进程">{{ serviceStatus.pid }}</n-descriptions-item>
|
||||
<n-descriptions-item label="Python">{{ serviceStatus.python_version }}</n-descriptions-item>
|
||||
<n-descriptions-item label="Supervisor">{{ serviceStatus.supervisor ? '是' : '否' }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
<n-button class="restart-button" type="warning" tertiary @click="confirmRestart">重启服务</n-button>
|
||||
</n-card>
|
||||
</n-space>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { useDialog, useMessage } from 'naive-ui';
|
||||
|
||||
import { apiGet, apiPost, downloadGet, upload } from '../api/client';
|
||||
import type { ApiMessage, AppConfig, ServiceStatus } from '../types';
|
||||
|
||||
const message = useMessage();
|
||||
const dialog = useDialog();
|
||||
const configInput = ref<HTMLInputElement | null>(null);
|
||||
const configUpdate = ref('');
|
||||
const loading = ref(false);
|
||||
const savingMysql = ref(false);
|
||||
const savingSheetFilter = ref(false);
|
||||
const savingExtractFields = ref(false);
|
||||
const changingPassword = ref(false);
|
||||
const sheetFilterText = ref('');
|
||||
const extractFieldsText = ref('[]');
|
||||
const serviceStatus = ref<ServiceStatus | null>(null);
|
||||
|
||||
const mysqlForm = reactive({
|
||||
host: '',
|
||||
port: 3306 as number | null,
|
||||
user: '',
|
||||
passwd: '',
|
||||
dbname: ''
|
||||
});
|
||||
|
||||
const passwordForm = reactive({
|
||||
current_password: '',
|
||||
new_password: '',
|
||||
confirm_password: ''
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void loadConfig();
|
||||
void loadServiceStatus();
|
||||
});
|
||||
|
||||
async function loadConfig() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const config = await apiGet<AppConfig>('/api/config/full');
|
||||
configUpdate.value = config.update;
|
||||
mysqlForm.host = config.mysql.host;
|
||||
mysqlForm.port = config.mysql.port;
|
||||
mysqlForm.user = config.mysql.user;
|
||||
mysqlForm.passwd = config.mysql.passwd || '';
|
||||
mysqlForm.dbname = config.mysql.dbname;
|
||||
sheetFilterText.value = config.sheet_filter.join('\n');
|
||||
extractFieldsText.value = JSON.stringify(config.extract_fields, null, 2);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '加载配置失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMysql() {
|
||||
if (!mysqlForm.host || !mysqlForm.user || !mysqlForm.dbname) {
|
||||
message.warning('请填写完整数据库配置');
|
||||
return;
|
||||
}
|
||||
|
||||
savingMysql.value = true;
|
||||
try {
|
||||
const result = await apiPost<ApiMessage>('/api/config/mysql', {
|
||||
...mysqlForm,
|
||||
port: mysqlForm.port || 3306
|
||||
});
|
||||
configUpdate.value = result.update || configUpdate.value;
|
||||
message.success(result.message || '数据库配置已保存');
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '保存数据库配置失败');
|
||||
} finally {
|
||||
savingMysql.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSheetFilter() {
|
||||
savingSheetFilter.value = true;
|
||||
try {
|
||||
const filters = sheetFilterText.value
|
||||
.split(/\r?\n/)
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean);
|
||||
const result = await apiPost<ApiMessage>('/api/config/sheet-filter', filters);
|
||||
configUpdate.value = result.update || configUpdate.value;
|
||||
message.success(result.message || '过滤规则已保存');
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '保存过滤规则失败');
|
||||
} finally {
|
||||
savingSheetFilter.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatExtractFields() {
|
||||
try {
|
||||
extractFieldsText.value = JSON.stringify(JSON.parse(extractFieldsText.value), null, 2);
|
||||
} catch {
|
||||
message.error('字段配置不是有效 JSON');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveExtractFields() {
|
||||
let fields: Array<Record<string, unknown>>;
|
||||
try {
|
||||
const parsed = JSON.parse(extractFieldsText.value) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error('字段配置必须是数组');
|
||||
}
|
||||
fields = parsed as Array<Record<string, unknown>>;
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '字段配置不是有效 JSON');
|
||||
return;
|
||||
}
|
||||
|
||||
savingExtractFields.value = true;
|
||||
try {
|
||||
const result = await apiPost<ApiMessage>('/api/config/extract-fields', fields);
|
||||
configUpdate.value = result.update || configUpdate.value;
|
||||
message.success(result.message || '字段配置已保存');
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '保存字段配置失败');
|
||||
} finally {
|
||||
savingExtractFields.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function changePassword() {
|
||||
if (passwordForm.new_password.length < 4) {
|
||||
message.warning('新密码不能少于 4 位');
|
||||
return;
|
||||
}
|
||||
if (passwordForm.new_password !== passwordForm.confirm_password) {
|
||||
message.warning('两次输入的新密码不一致');
|
||||
return;
|
||||
}
|
||||
|
||||
changingPassword.value = true;
|
||||
try {
|
||||
const result = await apiPost<ApiMessage>('/api/change-password', {
|
||||
current_password: passwordForm.current_password,
|
||||
new_password: passwordForm.new_password
|
||||
});
|
||||
passwordForm.current_password = '';
|
||||
passwordForm.new_password = '';
|
||||
passwordForm.confirm_password = '';
|
||||
message.success(result.message || '密码已修改');
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '修改密码失败');
|
||||
} finally {
|
||||
changingPassword.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadConfig() {
|
||||
try {
|
||||
await downloadGet('/api/config/download', 'Configure.json');
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '下载配置失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadConfigFile(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
try {
|
||||
const result = await upload<ApiMessage>('/api/config/upload', formData);
|
||||
message.success(result.message || '配置文件已上传');
|
||||
await loadConfig();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '上传配置失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadServiceStatus() {
|
||||
try {
|
||||
serviceStatus.value = await apiGet<ServiceStatus>('/api/service/status');
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '读取运行状态失败');
|
||||
}
|
||||
}
|
||||
|
||||
function confirmRestart() {
|
||||
dialog.warning({
|
||||
title: '重启服务',
|
||||
content: '确认重启后端服务?',
|
||||
positiveText: '重启',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: restartService
|
||||
});
|
||||
}
|
||||
|
||||
async function restartService() {
|
||||
try {
|
||||
const result = await apiPost<ApiMessage>('/api/service/restart');
|
||||
message.success(result.message || '服务正在重启');
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '重启服务失败');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createApp } from 'vue';
|
||||
import naive from 'naive-ui';
|
||||
|
||||
import App from './App.vue';
|
||||
import './styles.css';
|
||||
|
||||
createApp(App).use(naive).mount('#app');
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
color: #20242a;
|
||||
background: #eef1f4;
|
||||
font-family:
|
||||
Inter,
|
||||
"Segoe UI",
|
||||
"Microsoft YaHei",
|
||||
Arial,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
.app-layout {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 60px;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid #e6e9ee;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
background: #2458a6;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.brand-mark.large {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
overflow: hidden;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 60px;
|
||||
padding: 0 20px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.page-subtitle,
|
||||
.muted-line {
|
||||
margin-top: 2px;
|
||||
color: #6a7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.content {
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.login-page {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(238, 241, 244, 0.94)),
|
||||
#eef1f4;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: min(420px, 100%);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 18px 48px rgba(21, 38, 62, 0.16);
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.login-brand h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.login-brand p {
|
||||
margin: 3px 0 0;
|
||||
color: #687282;
|
||||
}
|
||||
|
||||
.hidden-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.file-list,
|
||||
.history-list,
|
||||
.table-list {
|
||||
max-height: 360px;
|
||||
}
|
||||
|
||||
.log-panel {
|
||||
min-height: 320px;
|
||||
padding: 12px;
|
||||
background: #101418;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.script-editor textarea,
|
||||
.json-editor textarea {
|
||||
font-family:
|
||||
"Cascadia Code",
|
||||
Consolas,
|
||||
"Courier New",
|
||||
monospace;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
margin-bottom: 8px;
|
||||
color: #333b46;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.table-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.restart-button {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.n-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.topbar {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table-footer {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
export interface LoginResponse {
|
||||
success: boolean;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
detail?: string;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface TaskStatus {
|
||||
task_id: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed' | string;
|
||||
logs: string[];
|
||||
elapsed_time?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
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 CacheSize {
|
||||
success: boolean;
|
||||
size_bytes: number;
|
||||
size_formatted: string;
|
||||
file_count: number;
|
||||
dir_count: number;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
update: string;
|
||||
mysql: {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
passwd?: string;
|
||||
dbname: string;
|
||||
};
|
||||
sheet_filter: string[];
|
||||
extract_fields: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
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 ServiceStatus {
|
||||
status: string;
|
||||
version: string;
|
||||
platform: string;
|
||||
supervisor: boolean;
|
||||
pid: number;
|
||||
python_version: string;
|
||||
}
|
||||
|
||||
export interface ApiMessage {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
update?: string;
|
||||
task_id?: string;
|
||||
}
|
||||
@@ -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,14 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:9081',
|
||||
'/health': 'http://localhost:9081'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user