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
+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>