fix: 对齐新版前端旧版布局

This commit is contained in:
2026-05-15 16:11:35 +08:00
parent da5c92733d
commit 12b8a11f17
10 changed files with 1286 additions and 760 deletions
+8
View File
@@ -1,5 +1,13 @@
# 项目上下文记录
## 2026-05-15:对齐新版前端主体布局并修复全局拖拽默认行为
- 新版 Vue 前端主体内容继续按旧版 `frontend_old/` 的工作区布局对齐:上传页保留旧版上传区尺寸和文件列表结构,数据库页恢复左侧表列表 + 右侧数据区,设置页恢复左右列,脚本页恢复编辑器容器和底部状态栏。
- `frontend/src/composables/pageHeader.ts` 新增页面顶部副标题和动作注册机制;各页面把原本散落在页面内部的主要动作注册到 `AppShell` 顶栏,避免每个页面重复实现标题栏。
- `AppShell.vue` 在捕获阶段统一拦截带 `Files` 的 `dragover/drop` 默认行为,修复文件拖到新版页面空白处时浏览器打开文件或弹出下载的问题;真正的文件处理仍由上传区自己的 `drop` 事件完成。
- 上传区 `FileWorkflow.vue` 明确使用 `.prevent.stop` 处理拖拽事件,并继续支持文件和目录拖拽;目录读取使用 `webkitGetAsEntry()` 递归遍历,文件路径按相对路径去重。
- 已执行 `npm run build`,构建通过,仅有 Monaco/Vite 大 chunk 体积警告;已用 headless Chrome 验证新版 `/upload` 上传区位于 `x=252,y=88,width=1156,height=220`,拖到页面空白处不会跳转,拖到上传区会加入 `drag-test.csv`。
## 2026-05-15:恢复新版数据库页左右工作区布局
- `frontend/src/components/DatabasePanel.vue` 已从纵向卡片堆叠改回旧版主体布局:左侧固定 240px 数据表列表,右侧为表数据面板,左下角显示数据库版本和快速导入状态。
+122 -13
View File
@@ -4,12 +4,13 @@
<n-layout-sider
class="app-sider"
collapse-mode="width"
:collapsed="sidebarCollapsed"
:collapsed-width="64"
:width="224"
show-trigger
:width="220"
@update:collapsed="sidebarCollapsed = $event"
>
<div class="brand">
<div class="brand-mark">CR</div>
<div class="brand-mark">📊</div>
<div class="brand-text">CapacityReport</div>
</div>
<n-menu
@@ -21,24 +22,55 @@
@update:value="handleMenuChange"
/>
<div class="sider-footer">
<span>v2.0</span>
<span>CapacityReport</span>
<span>版本:v2.0.2</span>
<span>Power by:NIXEVOL</span>
</div>
</n-layout-sider>
<n-layout>
<n-layout-header bordered class="topbar">
<div class="topbar-title">
<div class="page-title">{{ currentTitle }}</div>
<n-layout-header bordered class="topbar page-header">
<button class="sidebar-toggle" type="button" title="展开/收起侧边栏" @click="toggleSidebar">
☰
</button>
<div class="page-header-content">
<h1>{{ currentTitle }}</h1>
<span v-if="pageSubtitle" class="page-subtitle" :title="pageSubtitle">{{ pageSubtitle }}</span>
</div>
<n-space align="center">
<n-tag size="small" type="success" round>已登录</n-tag>
<n-button tertiary circle title="退出登录" @click="logout">
<div class="page-header-actions">
<template v-for="action in pageHeader.actions" :key="action.key">
<span v-if="action.kind === 'text'" class="header-info-text">
{{ actionLabel(action) }}
</span>
<n-button
v-else
size="small"
:type="action.type === 'default' ? undefined : action.type"
:tertiary="action.variant !== 'solid'"
:text="action.variant === 'text'"
:loading="actionLoading(action)"
:disabled="actionDisabled(action)"
:title="actionTitle(action)"
@click="runHeaderAction(action)"
>
<template v-if="actionIcon(action)" #icon>
<span class="button-emoji">{{ actionIcon(action) }}</span>
</template>
{{ actionLabel(action) }}
</n-button>
</template>
<button class="theme-toggle" type="button" title="切换主题" @click="toggleTheme">
<span>🌓</span>
</button>
<button class="restart-btn" type="button" title="重启服务" @click="restartService">
<n-icon><PowerOutline /></n-icon>
</button>
<n-button tertiary circle title="退出登录" class="logout-button" @click="logout">
<template #icon>
<n-icon><LogOutOutline /></n-icon>
</template>
</n-button>
</n-space>
</div>
</n-layout-header>
<n-layout-content class="content">
@@ -49,7 +81,7 @@
</template>
<script setup lang="ts">
import { computed, h, ref, type Component } from 'vue';
import { computed, h, onBeforeUnmount, onMounted, ref, watch, type Component } from 'vue';
import { RouterView, useRoute, useRouter } from 'vue-router';
import { useMessage, type MenuOption, NIcon } from 'naive-ui';
import {
@@ -57,6 +89,7 @@ import {
ConstructOutline,
FileTrayFullOutline,
LogOutOutline,
PowerOutline,
ServerOutline,
SettingsOutline
} from '@vicons/ionicons5';
@@ -64,12 +97,15 @@ import {
import { apiPost, clearToken, getToken, setToken, setUnauthorizedHandler } from './api/client';
import type { LoginResponse } from './types';
import LoginView from './components/LoginView.vue';
import { pageHeader, resetPageHeader, resolveHeaderValue, type PageHeaderAction } from './composables/pageHeader';
const message = useMessage();
const route = useRoute();
const router = useRouter();
const token = ref(getToken());
const loginLoading = ref(false);
const sidebarCollapsed = ref(localStorage.getItem('sidebarCollapsed') === 'true');
const theme = ref(localStorage.getItem('theme') || 'light');
const menuKeys = ['workflow', 'history', 'database', 'script', 'settings'] as const;
type MenuKey = (typeof menuKeys)[number];
@@ -88,6 +124,24 @@ const activeMenu = computed<MenuKey>(() => {
const currentTitle = computed(() => {
return String(route.meta.title || menuOptions.find(item => item.key === activeMenu.value)?.label || '');
});
const pageSubtitle = computed(() => resolveHeaderValue(pageHeader.subtitle || '', ''));
document.documentElement.setAttribute('data-theme', theme.value);
onMounted(() => {
window.addEventListener('dragover', preventWindowFileDrop, { capture: true });
window.addEventListener('drop', preventWindowFileDrop, { capture: true });
});
onBeforeUnmount(() => {
window.removeEventListener('dragover', preventWindowFileDrop, { capture: true });
window.removeEventListener('drop', preventWindowFileDrop, { capture: true });
});
watch(
() => route.name,
() => resetPageHeader()
);
setUnauthorizedHandler(() => {
token.value = '';
@@ -113,6 +167,26 @@ function logout() {
token.value = '';
}
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value;
localStorage.setItem('sidebarCollapsed', sidebarCollapsed.value ? 'true' : 'false');
}
function toggleTheme() {
theme.value = theme.value === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', theme.value);
document.documentElement.setAttribute('data-theme', theme.value);
}
async function restartService() {
try {
await apiPost('/api/service/restart');
message.success('服务正在重启');
} catch (error) {
message.error(error instanceof Error ? error.message : '重启服务失败');
}
}
function handleMenuChange(key: string | number) {
if (!isMenuKey(key) || key === activeMenu.value) {
return;
@@ -127,4 +201,39 @@ function isMenuKey(value: unknown): value is MenuKey {
function renderIcon(icon: Component) {
return () => h(NIcon, null, { default: () => h(icon) });
}
function actionLabel(action: PageHeaderAction) {
return resolveHeaderValue(action.label, '');
}
function actionIcon(action: PageHeaderAction) {
return resolveHeaderValue(action.icon, '');
}
function actionTitle(action: PageHeaderAction) {
return resolveHeaderValue(action.title, actionLabel(action));
}
function actionLoading(action: PageHeaderAction) {
return resolveHeaderValue(action.loading, false);
}
function actionDisabled(action: PageHeaderAction) {
return resolveHeaderValue(action.disabled, false);
}
function runHeaderAction(action: PageHeaderAction) {
void action.onClick?.();
}
function preventWindowFileDrop(event: DragEvent) {
const types = Array.from(event.dataTransfer?.types || []);
if (!types.includes('Files')) {
return;
}
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy';
}
}
</script>
+58 -54
View File
@@ -2,13 +2,6 @@
<div class="database-workspace">
<aside class="database-sidebar-pane">
<section class="database-table-pane">
<div class="database-table-actions">
<n-button size="small" tertiary :loading="loadingTables" @click="loadTables">刷新数据表</n-button>
<n-button size="small" tertiary type="error" :disabled="tables.length === 0" @click="confirmDropAll">
删除全部
</n-button>
</div>
<div class="database-table-list-wrap">
<n-empty v-if="tables.length === 0 && !loadingTables" description="暂无数据表" />
<n-spin v-else-if="loadingTables" class="database-list-loading" size="small" />
@@ -126,12 +119,13 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, onBeforeUnmount, 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';
import { resetPageHeader, setPageHeader } from '../composables/pageHeader';
type RowData = Record<string, unknown>;
type DownloadFormat = 'csv' | 'xlsx';
@@ -166,10 +160,27 @@ const columns = computed<DataTableColumns<RowData>>(() => {
});
onMounted(() => {
setPageHeader({
actions: [
{
key: 'drop-all-tables',
label: '删除全部表',
icon: '🗑️',
type: 'error',
variant: 'solid',
disabled: computed(() => tables.value.length === 0),
onClick: confirmDropAll
}
]
});
void testConnection();
void loadTables();
});
onBeforeUnmount(() => {
resetPageHeader();
});
async function testConnection() {
testing.value = true;
try {
@@ -342,9 +353,10 @@ function formatCell(value: unknown): string {
.database-workspace {
display: flex;
gap: 20px;
height: calc(100vh - 92px);
min-height: 560px;
height: 100%;
min-height: 0;
min-width: 0;
padding: 24px 32px;
}
.database-sidebar-pane {
@@ -358,9 +370,9 @@ function formatCell(value: unknown): string {
.database-table-pane,
.database-content-pane,
.database-server-info {
background: #fff;
border: 1px solid #dfe4ec;
border-radius: 6px;
background: var(--td-bg-color-container);
border: 1px solid var(--td-border-color-light);
border-radius: var(--td-radius-default);
}
.database-table-pane {
@@ -370,16 +382,6 @@ function formatCell(value: unknown): string {
flex-direction: column;
}
.database-table-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 46px;
padding: 8px 12px;
border-bottom: 1px solid #e6eaf0;
}
.database-table-list-wrap {
display: flex;
flex: 1;
@@ -403,36 +405,37 @@ function formatCell(value: unknown): string {
min-height: 38px;
align-items: center;
gap: 10px;
padding: 8px 14px;
color: #1f2933;
padding: 10px 16px;
color: var(--td-text-color-primary);
font: inherit;
text-align: left;
background: transparent;
border: 0;
border-bottom: 1px solid #e8ecf2;
border-bottom: 1px solid var(--td-border-color-light);
cursor: pointer;
transition: all var(--td-transition);
}
.database-table-item:hover {
background: #f4f7fb;
background: var(--td-bg-color-container-hover);
}
.database-table-item.active {
color: #0052d9;
font-weight: 600;
background: #ecf2fe;
color: var(--td-brand-color);
font-weight: 500;
background: var(--td-brand-color-light);
}
.table-dot {
width: 7px;
height: 7px;
flex: 0 0 auto;
background: #9aa4b2;
background: var(--td-text-color-placeholder);
border-radius: 50%;
}
.database-table-item.active .table-dot {
background: #0052d9;
background: var(--td-brand-color);
}
.table-name {
@@ -447,7 +450,7 @@ function formatCell(value: unknown): string {
flex-shrink: 0;
padding: 10px 12px 28px;
font-size: 12px;
background: #f8fafc;
background: var(--td-bg-color-secondarycontainer);
}
.server-info-item {
@@ -459,11 +462,11 @@ function formatCell(value: unknown): string {
}
.server-info-item:not(:last-of-type) {
border-bottom: 1px dashed #dfe4ec;
border-bottom: 1px dashed var(--td-border-color-light);
}
.info-label {
color: #4f5b6b;
color: var(--td-text-color-secondary);
}
.info-value {
@@ -484,18 +487,18 @@ function formatCell(value: unknown): string {
}
.status-badge.success {
color: #00a870;
background: #dff7ed;
color: var(--td-success-color);
background: var(--td-success-color-light);
}
.status-badge.warning {
color: #ed7b2f;
background: #fff1e6;
color: var(--td-warning-color);
background: var(--td-warning-color-light);
}
.status-badge.unknown {
color: #8b95a4;
background: #edf1f6;
color: var(--td-text-color-placeholder);
background: var(--td-bg-color-container-hover);
}
.connection-test {
@@ -518,9 +521,9 @@ function formatCell(value: unknown): string {
justify-content: space-between;
gap: 12px;
min-height: 48px;
padding: 8px 14px;
background: #f8fafc;
border-bottom: 1px solid #e6eaf0;
padding: 12px 16px;
background: var(--td-bg-color-secondarycontainer);
border-bottom: 1px solid var(--td-border-color-light);
}
.toolbar-left {
@@ -533,8 +536,8 @@ function formatCell(value: unknown): string {
.current-table {
overflow: hidden;
max-width: 42vw;
color: #1f2933;
font-weight: 700;
color: var(--td-text-color-primary);
font-weight: 600;
white-space: nowrap;
text-overflow: ellipsis;
}
@@ -542,9 +545,9 @@ function formatCell(value: unknown): string {
.row-count {
flex: 0 0 auto;
padding: 2px 8px;
color: #667386;
color: var(--td-text-color-secondary);
font-size: 12px;
background: #edf1f6;
background: var(--td-bg-color-component);
border-radius: 10px;
}
@@ -573,7 +576,7 @@ function formatCell(value: unknown): string {
.columns-collapse {
flex: 0 0 auto;
padding: 0 14px 12px;
border-top: 1px solid #e6eaf0;
border-top: 1px solid var(--td-border-color-light);
}
.database-pagination {
@@ -582,15 +585,15 @@ function formatCell(value: unknown): string {
justify-content: space-between;
gap: 12px;
min-height: 48px;
padding: 8px 14px;
background: #f8fafc;
border-top: 1px solid #e6eaf0;
padding: 12px 16px;
background: var(--td-bg-color-secondarycontainer);
border-top: 1px solid var(--td-border-color-light);
}
.pagination-total {
flex: 0 0 auto;
color: #667386;
font-size: 12px;
color: var(--td-text-color-secondary);
font-size: 13px;
}
@media (max-width: 1180px) {
@@ -598,6 +601,7 @@ function formatCell(value: unknown): string {
flex-direction: column;
height: auto;
min-height: 0;
padding: 16px;
}
.database-sidebar-pane {
+3 -3
View File
@@ -9,10 +9,10 @@
@click="openFilePicker"
@keydown.enter.prevent="openFilePicker"
@keydown.space.prevent="openFilePicker"
@dragenter.prevent="onDragEnter"
@dragover.prevent="onDragOver"
@dragenter.prevent.stop="onDragEnter"
@dragover.prevent.stop="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
@drop.prevent.stop="onDrop"
>
<div class="upload-icon">
<n-icon size="24"><FolderOpenOutline /></n-icon>
+57 -59
View File
@@ -1,50 +1,31 @@
<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>
<div class="history-page-body">
<n-spin v-if="loading" class="page-loading" />
<n-space vertical>
<n-alert v-if="cacheSize" type="default" :bordered="false">
缓存占用:{{ cacheSize.size_formatted }},目录 {{ cacheSize.dir_count }} 个
</n-alert>
<div v-else-if="records.length === 0" class="empty-state history-empty-state">
<span class="empty-icon">📭</span>
<p>暂无处理记录</p>
</div>
<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>
<div v-else class="history-list">
<div v-for="record in records" :key="record.id" class="history-item">
<div class="history-status" :class="record.status"></div>
<div class="history-info">
<div class="history-id">{{ record.id }}</div>
<div class="history-meta">
{{ formatDate(record.timestamp) }} · {{ record.file_count }} 个文件 · {{ formatElapsed(record.elapsed_time) }}
</div>
</div>
<span class="record-size">{{ statusText(record.status) }}</span>
<div class="history-actions">
<n-button size="small" tertiary @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>
</div>
</div>
</div>
<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-modal v-model:show="detailVisible" preset="card" class="history-detail-modal" title="任务详情">
<n-empty v-if="!detail" description="暂无详情" />
<n-space v-else vertical size="large">
<n-descriptions bordered size="small" :column="2">
<n-descriptions-item label="任务 ID">{{ detail.id }}</n-descriptions-item>
@@ -52,7 +33,10 @@
<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="占用">
<n-button v-if="recordSize === '-'" size="tiny" text @click="loadRecordSize(detail.id)">计算占用</n-button>
<span v-else>{{ recordSize }}</span>
</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>
@@ -62,31 +46,53 @@
<n-log :log="detail.logs.join('\n') || '暂无日志'" language="text" trim class="log-panel" />
</div>
</n-space>
</n-card>
</n-gi>
</n-grid>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { computed, onBeforeUnmount, 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';
import { resetPageHeader, setPageHeader } from '../composables/pageHeader';
const message = useMessage();
const dialog = useDialog();
const records = ref<HistoryRecord[]>([]);
const detail = ref<HistoryDetail | null>(null);
const detailVisible = ref(false);
const cacheSize = ref<CacheSize | null>(null);
const recordSize = ref('-');
const loading = ref(false);
const totalSizeText = computed(() => `总占用: ${cacheSize.value?.size_formatted || '计算中...'}`);
const hasRecords = computed(() => records.value.length > 0);
onMounted(() => {
setPageHeader({
actions: [
{ key: 'refresh-history', label: '刷新', icon: '🔄', loading, onClick: loadHistory },
{
key: 'clear-history',
label: '清空历史',
icon: '🗑️',
type: 'error',
variant: 'solid',
disabled: computed(() => !hasRecords.value),
onClick: confirmClear
},
{ key: 'history-size', kind: 'text', label: totalSizeText }
]
});
void loadHistory();
});
onBeforeUnmount(() => {
resetPageHeader();
});
async function loadHistory() {
loading.value = true;
try {
@@ -107,6 +113,7 @@ async function openDetail(recordId: string) {
try {
detail.value = await apiPost<HistoryDetail>('/api/history/detail', { record_id: recordId });
recordSize.value = '-';
detailVisible.value = true;
} catch (error) {
message.error(error instanceof Error ? error.message : '加载任务详情失败');
}
@@ -138,6 +145,7 @@ async function deleteRecord(recordId: string) {
await apiPost('/api/history/delete', { record_id: recordId });
if (detail.value?.id === recordId) {
detail.value = null;
detailVisible.value = false;
}
message.success('删除成功');
await loadHistory();
@@ -160,6 +168,7 @@ async function clearHistory() {
try {
await apiPost('/api/history/clear');
detail.value = null;
detailVisible.value = false;
message.success('已清空历史记录');
await loadHistory();
} catch (error) {
@@ -167,13 +176,6 @@ async function clearHistory() {
}
}
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: '待处理',
@@ -184,10 +186,6 @@ function statusText(status: string): string {
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);
+73 -42
View File
@@ -1,46 +1,31 @@
<template>
<div class="script-workspace">
<n-card size="small" class="work-card script-editor-pane">
<template #header>
<div class="script-card-title">
<span>脚本编辑</span>
<n-tag v-if="isModified" size="small" type="warning">已修改</n-tag>
<n-tag v-else size="small" type="success">已保存</n-tag>
<div class="script-page-body">
<div class="script-editor-container">
<div ref="editorHost" class="script-editor" />
</div>
</template>
<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>
<div class="script-editor-toolbar">
<span class="script-path" :title="scriptPath">{{ scriptPath || 'ReportScript.sql' }}</span>
<span class="script-modified">修改时间:{{ modified || '-' }}</span>
</div>
<div ref="editorHost" class="monaco-editor-host" />
<div class="script-status-bar">
<span>{{ cursorText }}</span>
<span>{{ lineCount }} 行</span>
<span>{{ editorStatus }}</span>
<span>{{ lineCount }} 行</span>
</div>
</n-card>
<n-card title="执行状态" size="small" class="work-card script-log-pane">
<template #header-extra>
<n-space>
<n-tag v-if="taskStatus" :type="statusType">{{ statusText }}</n-tag>
<div v-if="taskStatus" class="process-section script-process-section">
<div class="upload-process-card">
<div class="card-header">
<div class="card-header-left">
<span class="card-title">脚本执行进度</span>
<span class="process-status" :class="statusClass">{{ statusText }}</span>
</div>
<div class="card-header-right">
<n-button size="small" tertiary :disabled="!taskId" @click="refreshStatus">刷新</n-button>
</n-space>
</template>
<n-empty v-if="!taskStatus" description="脚本尚未执行" />
<n-log v-else :log="logText" language="text" trim class="script-log" />
</n-card>
</div>
</div>
<div class="card-body">
<n-log :log="logText" language="text" trim class="script-log" />
</div>
</div>
</div>
</div>
</template>
@@ -53,6 +38,7 @@ import 'monaco-editor/esm/vs/basic-languages/sql/sql.contribution';
import { apiGet, apiPost } from '../api/client';
import type { ApiMessage, ScriptContent, TaskStatus } from '../types';
import { resetPageHeader, setPageHeader } from '../composables/pageHeader';
type MonacoGlobal = typeof globalThis & {
MonacoEnvironment?: {
@@ -81,13 +67,14 @@ const executing = ref(false);
const taskId = ref('');
const taskStatus = ref<TaskStatus | null>(null);
let timer: number | undefined;
let themeObserver: MutationObserver | undefined;
let applyingRemoteContent = false;
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';
const statusClass = computed(() => {
if (taskStatus.value?.status === 'completed') return 'completed';
if (taskStatus.value?.status === 'failed') return 'failed';
return 'processing';
});
const statusText = computed(() => {
const status = taskStatus.value?.status;
@@ -102,15 +89,29 @@ const editorStatus = computed(() => {
if (saving.value) return '保存中';
return isModified.value ? '未保存' : '就绪';
});
const modifiedText = computed(() => (modified.value ? `最后修改: ${modified.value}` : '最后修改: -'));
onMounted(async () => {
setPageHeader({
subtitle: computed(() => scriptPath.value || 'ReportScript.sql'),
actions: [
{ key: 'script-modified', kind: 'text', label: modifiedText },
{ key: 'save-script', label: computed(() => (isModified.value ? '保存 *' : '保存')), icon: '💾', type: 'primary', variant: 'solid', loading: saving, onClick: saveScript },
{ key: 'format-script', label: '格式化', icon: '✨', onClick: formatScript },
{ key: 'run-script', label: '运行脚本', icon: '▶️', type: 'success', variant: 'solid', loading: executing, onClick: executeScript }
]
});
await nextTick();
createEditor();
observeTheme();
await loadScript();
});
onBeforeUnmount(() => {
resetPageHeader();
stopPolling();
themeObserver?.disconnect();
for (const disposable of disposables) {
disposable.dispose();
}
@@ -123,17 +124,25 @@ function createEditor() {
editor.value = monaco.editor.create(editorHost.value, {
value: '-- 正在加载 ReportScript.sql',
language: 'sql',
theme: 'vs',
theme: currentEditorTheme(),
fontSize: 14,
fontFamily: "'Cascadia Code', Consolas, 'Courier New', monospace",
fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, 'Courier New', monospace",
lineNumbers: 'on',
minimap: { enabled: true },
automaticLayout: true,
scrollBeyondLastLine: false,
roundedSelection: false,
roundedSelection: true,
renderLineHighlight: 'all',
tabSize: 4,
wordWrap: 'off'
insertSpaces: true,
wordWrap: 'on',
folding: true,
showFoldingControls: 'always',
bracketPairColorization: { enabled: true },
guides: {
bracketPairs: true,
indentation: true
}
});
disposables.push(
@@ -193,6 +202,17 @@ async function saveScript() {
}
}
async function formatScript() {
const action = editor.value?.getAction('editor.action.formatDocument');
if (!action) {
message.warning('当前编辑器没有可用的格式化动作');
return;
}
await action.run();
message.success('格式化完成');
}
async function executeScript() {
executing.value = true;
try {
@@ -238,6 +258,17 @@ function updateCursor() {
cursorText.value = position ? `行 ${position.lineNumber},列 ${position.column}` : '行 1,列 1';
}
function observeTheme() {
themeObserver = new MutationObserver(() => {
monaco.editor.setTheme(currentEditorTheme());
});
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
}
function currentEditorTheme() {
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'vs-dark' : 'vs';
}
function startPolling() {
stopPolling();
void refreshStatus();
+55 -82
View File
@@ -1,12 +1,11 @@
<template>
<div class="settings-workspace">
<input ref="configInput" class="hidden-input" type="file" accept=".json" @change="uploadConfigFile" />
<div class="settings-column">
<n-card title="数据库配置" size="small" class="work-card">
<template #header-extra>
<n-space>
<n-button size="small" tertiary :loading="loading" @click="loadConfig">刷新</n-button>
<n-button size="small" tertiary :loading="testingDb" @click="testDatabase">测试连接</n-button>
</n-space>
<span class="muted-line">更新时间:{{ configUpdate || '-' }}</span>
</template>
<n-form label-placement="top">
@@ -37,15 +36,19 @@
</n-form-item>
</n-gi>
</n-grid>
<div class="card-actions">
<span class="muted-line">更新时间:{{ configUpdate || '-' }}</span>
<n-button type="primary" :loading="savingMysql" @click="saveMysql">保存配置</n-button>
</div>
</n-form>
<template #footer>
<n-space justify="end">
<n-button type="primary" :loading="savingMysql" @click="saveMysql">保存配置</n-button>
<n-button :loading="testingDb" @click="testDatabase">测试连接</n-button>
</n-space>
</template>
</n-card>
<n-card title="Sheet 过滤规则" size="small" class="work-card">
<n-space vertical>
<p class="form-hint">匹配这些关键词的 Sheet 将被跳过处理</p>
<div class="filter-tags">
<n-tag
v-for="(filter, index) in sheetFilters"
@@ -65,14 +68,16 @@
/>
<n-button @click="addSheetFilter">添加</n-button>
</n-input-group>
<div class="card-actions">
<span class="muted-line">匹配这些关键词的 Sheet 会跳过处理</span>
<n-button type="primary" :loading="savingSheetFilter" @click="saveSheetFilter">保存规则</n-button>
</div>
</n-space>
<template #footer>
<n-space justify="end">
<n-button type="primary" :loading="savingSheetFilter" @click="saveSheetFilter">保存规则</n-button>
</n-space>
</template>
</n-card>
<n-card title="登录密码" size="small" class="work-card">
<n-card title="修改登录密码" size="small" class="work-card">
<n-form label-placement="top">
<n-form-item label="当前密码">
<n-input v-model:value="passwordForm.current_password" type="password" show-password-on="click" />
@@ -80,60 +85,38 @@
<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-form-item label="确认新密码">
<n-input v-model:value="passwordForm.confirm_password" type="password" show-password-on="click" />
</n-form-item>
<div class="card-actions">
<span />
<n-button type="primary" :loading="changingPassword" @click="changePassword">修改密码</n-button>
</div>
</n-form>
<template #footer>
<n-space justify="end">
<n-button type="primary" :loading="changingPassword" @click="changePassword">修改密码</n-button>
</n-space>
</template>
</n-card>
<n-card title="配置文件与服务" size="small" class="work-card">
<n-space vertical>
<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-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>
<div class="card-actions">
<n-button size="small" tertiary @click="loadServiceStatus">刷新状态</n-button>
<n-button type="warning" tertiary @click="confirmRestart">重启服务</n-button>
</div>
</n-space>
</n-card>
</div>
<n-card size="small" class="work-card field-mapping-card">
<template #header>
<div class="field-card-header">
<div class="field-card-title-row">
<div class="field-card-title">
<span>字段提取配置</span>
<span>字段映射配置</span>
<n-tag size="small" round>{{ extractFields.length }} 个字段</n-tag>
</div>
</template>
<template #header-extra>
<n-space align="center">
<n-input
v-model:value="fieldSearch"
size="small"
clearable
class="field-search"
placeholder="搜索字段或提取来源"
placeholder="搜索字段..."
/>
<n-button size="small" @click="addFieldMapping">添加字段</n-button>
<n-button size="small" type="primary" :loading="savingExtractFields" @click="saveExtractFields">
保存映射
</n-button>
</n-space>
</div>
<p class="form-hint">定义 Excel 列名到数据库字段的映射规则,提取来源匹配任意一个即可</p>
</div>
</template>
<n-scrollbar class="field-mapping-scroll">
@@ -189,16 +172,26 @@
</div>
</div>
</n-scrollbar>
<template #footer>
<n-space justify="end">
<n-button @click="addFieldMapping">添加字段</n-button>
<n-button type="primary" :loading="savingExtractFields" @click="saveExtractFields">
保存映射
</n-button>
</n-space>
</template>
</n-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { useDialog, useMessage, type SelectOption } from 'naive-ui';
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { useMessage, type SelectOption } from 'naive-ui';
import { apiGet, apiPost, downloadGet, upload } from '../api/client';
import type { ApiMessage, AppConfig, ServiceStatus } from '../types';
import type { ApiMessage, AppConfig } from '../types';
import { resetPageHeader, setPageHeader } from '../composables/pageHeader';
interface ExtractFieldConfig {
Field: string;
@@ -208,7 +201,6 @@ interface ExtractFieldConfig {
}
const message = useMessage();
const dialog = useDialog();
const configInput = ref<HTMLInputElement | null>(null);
const configUpdate = ref('');
const loading = ref(false);
@@ -217,7 +209,6 @@ const savingMysql = ref(false);
const savingSheetFilter = ref(false);
const savingExtractFields = ref(false);
const changingPassword = ref(false);
const serviceStatus = ref<ServiceStatus | null>(null);
const sheetFilters = ref<string[]>([]);
const newSheetFilter = ref('');
const extractFields = ref<ExtractFieldConfig[]>([]);
@@ -258,8 +249,17 @@ const visibleFieldMappings = computed(() => {
});
onMounted(() => {
setPageHeader({
actions: [
{ key: 'download-config', label: '下载配置', icon: '📥', onClick: downloadConfig },
{ key: 'upload-config', label: '上传配置', icon: '📤', onClick: () => configInput.value?.click() }
]
});
void loadConfig();
void loadServiceStatus();
});
onBeforeUnmount(() => {
resetPageHeader();
});
async function loadConfig() {
@@ -454,33 +454,6 @@ async function uploadConfigFile(event: Event) {
}
}
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 : '重启服务失败');
}
}
function normalizeExtractFields(fields: Array<Record<string, unknown>>): ExtractFieldConfig[] {
return fields.map(field => {
const extract = Array.isArray(field.Extract) ? uniqueStrings(field.Extract) : [];
+41
View File
@@ -0,0 +1,41 @@
import { shallowReactive, unref, type Ref } from 'vue';
type HeaderValue<T> = T | Ref<T> | (() => T);
export interface PageHeaderAction {
key: string;
label: HeaderValue<string>;
icon?: HeaderValue<string>;
title?: HeaderValue<string>;
kind?: 'button' | 'text';
type?: 'default' | 'primary' | 'success' | 'warning' | 'error';
variant?: 'solid' | 'outline' | 'text';
loading?: HeaderValue<boolean>;
disabled?: HeaderValue<boolean>;
onClick?: () => void | Promise<void>;
}
export const pageHeader = shallowReactive<{
subtitle: HeaderValue<string> | '';
actions: PageHeaderAction[];
}>({
subtitle: '',
actions: []
});
export function setPageHeader(config: { subtitle?: HeaderValue<string>; actions?: PageHeaderAction[] }) {
pageHeader.subtitle = config.subtitle || '';
pageHeader.actions = config.actions || [];
}
export function resetPageHeader() {
pageHeader.subtitle = '';
pageHeader.actions = [];
}
export function resolveHeaderValue<T>(value: HeaderValue<T> | undefined, fallback: T): T {
if (typeof value === 'function') {
return (value as () => T)();
}
return value === undefined ? fallback : unref(value);
}
+1 -1
View File
@@ -9,7 +9,7 @@ const ScriptPanel = () => import('./components/ScriptPanel.vue');
export const routes: RouteRecordRaw[] = [
{ path: '/', redirect: { name: 'workflow' } },
{ path: '/upload', name: 'workflow', component: FileWorkflow, meta: { title: '数据上传' } },
{ path: '/upload', name: 'workflow', component: FileWorkflow, meta: { title: '数据上传与处理' } },
{ path: '/history', name: 'history', component: HistoryPanel, meta: { title: '处理历史' } },
{ path: '/database', name: 'database', component: DatabasePanel, meta: { title: '数据管理' } },
{ path: '/script', name: 'script', component: ScriptPanel, meta: { title: '脚本编辑' } },
+838 -476
View File
File diff suppressed because it is too large Load Diff