refactor: 重构后端结构并迁移前端到 Vue3

This commit is contained in:
2026-05-15 11:46:21 +08:00
parent 9107378fcc
commit 9c5a5a54c6
165 changed files with 5057 additions and 62710 deletions
+14
View File
@@ -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>
+115
View File
@@ -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>
+179
View File
@@ -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] || '';
}
+341
View File
@@ -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>
+200
View File
@@ -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>
+200
View File
@@ -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>
+43
View File
@@ -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>
+155
View File
@@ -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>
+313
View File
@@ -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>
+8
View File
@@ -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');
+190
View File
@@ -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;
}
}
+117
View File
@@ -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;
}