fix: 对齐新版前端旧版布局
This commit is contained in:
@@ -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:恢复新版数据库页左右工作区布局
|
## 2026-05-15:恢复新版数据库页左右工作区布局
|
||||||
|
|
||||||
- `frontend/src/components/DatabasePanel.vue` 已从纵向卡片堆叠改回旧版主体布局:左侧固定 240px 数据表列表,右侧为表数据面板,左下角显示数据库版本和快速导入状态。
|
- `frontend/src/components/DatabasePanel.vue` 已从纵向卡片堆叠改回旧版主体布局:左侧固定 240px 数据表列表,右侧为表数据面板,左下角显示数据库版本和快速导入状态。
|
||||||
|
|||||||
+122
-13
@@ -4,12 +4,13 @@
|
|||||||
<n-layout-sider
|
<n-layout-sider
|
||||||
class="app-sider"
|
class="app-sider"
|
||||||
collapse-mode="width"
|
collapse-mode="width"
|
||||||
|
:collapsed="sidebarCollapsed"
|
||||||
:collapsed-width="64"
|
:collapsed-width="64"
|
||||||
:width="224"
|
:width="220"
|
||||||
show-trigger
|
@update:collapsed="sidebarCollapsed = $event"
|
||||||
>
|
>
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<div class="brand-mark">CR</div>
|
<div class="brand-mark">📊</div>
|
||||||
<div class="brand-text">CapacityReport</div>
|
<div class="brand-text">CapacityReport</div>
|
||||||
</div>
|
</div>
|
||||||
<n-menu
|
<n-menu
|
||||||
@@ -21,24 +22,55 @@
|
|||||||
@update:value="handleMenuChange"
|
@update:value="handleMenuChange"
|
||||||
/>
|
/>
|
||||||
<div class="sider-footer">
|
<div class="sider-footer">
|
||||||
<span>v2.0</span>
|
<span>版本:v2.0.2</span>
|
||||||
<span>CapacityReport</span>
|
<span>Power by:NIXEVOL</span>
|
||||||
</div>
|
</div>
|
||||||
</n-layout-sider>
|
</n-layout-sider>
|
||||||
|
|
||||||
<n-layout>
|
<n-layout>
|
||||||
<n-layout-header bordered class="topbar">
|
<n-layout-header bordered class="topbar page-header">
|
||||||
<div class="topbar-title">
|
<button class="sidebar-toggle" type="button" title="展开/收起侧边栏" @click="toggleSidebar">
|
||||||
<div class="page-title">{{ currentTitle }}</div>
|
☰
|
||||||
|
</button>
|
||||||
|
<div class="page-header-content">
|
||||||
|
<h1>{{ currentTitle }}</h1>
|
||||||
|
<span v-if="pageSubtitle" class="page-subtitle" :title="pageSubtitle">{{ pageSubtitle }}</span>
|
||||||
</div>
|
</div>
|
||||||
<n-space align="center">
|
<div class="page-header-actions">
|
||||||
<n-tag size="small" type="success" round>已登录</n-tag>
|
<template v-for="action in pageHeader.actions" :key="action.key">
|
||||||
<n-button tertiary circle title="退出登录" @click="logout">
|
<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>
|
<template #icon>
|
||||||
<n-icon><LogOutOutline /></n-icon>
|
<n-icon><LogOutOutline /></n-icon>
|
||||||
</template>
|
</template>
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-space>
|
</div>
|
||||||
</n-layout-header>
|
</n-layout-header>
|
||||||
|
|
||||||
<n-layout-content class="content">
|
<n-layout-content class="content">
|
||||||
@@ -49,7 +81,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { RouterView, useRoute, useRouter } from 'vue-router';
|
||||||
import { useMessage, type MenuOption, NIcon } from 'naive-ui';
|
import { useMessage, type MenuOption, NIcon } from 'naive-ui';
|
||||||
import {
|
import {
|
||||||
@@ -57,6 +89,7 @@ import {
|
|||||||
ConstructOutline,
|
ConstructOutline,
|
||||||
FileTrayFullOutline,
|
FileTrayFullOutline,
|
||||||
LogOutOutline,
|
LogOutOutline,
|
||||||
|
PowerOutline,
|
||||||
ServerOutline,
|
ServerOutline,
|
||||||
SettingsOutline
|
SettingsOutline
|
||||||
} from '@vicons/ionicons5';
|
} from '@vicons/ionicons5';
|
||||||
@@ -64,12 +97,15 @@ import {
|
|||||||
import { apiPost, clearToken, getToken, setToken, setUnauthorizedHandler } from './api/client';
|
import { apiPost, clearToken, getToken, setToken, setUnauthorizedHandler } from './api/client';
|
||||||
import type { LoginResponse } from './types';
|
import type { LoginResponse } from './types';
|
||||||
import LoginView from './components/LoginView.vue';
|
import LoginView from './components/LoginView.vue';
|
||||||
|
import { pageHeader, resetPageHeader, resolveHeaderValue, type PageHeaderAction } from './composables/pageHeader';
|
||||||
|
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const token = ref(getToken());
|
const token = ref(getToken());
|
||||||
const loginLoading = ref(false);
|
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;
|
const menuKeys = ['workflow', 'history', 'database', 'script', 'settings'] as const;
|
||||||
type MenuKey = (typeof menuKeys)[number];
|
type MenuKey = (typeof menuKeys)[number];
|
||||||
|
|
||||||
@@ -88,6 +124,24 @@ const activeMenu = computed<MenuKey>(() => {
|
|||||||
const currentTitle = computed(() => {
|
const currentTitle = computed(() => {
|
||||||
return String(route.meta.title || menuOptions.find(item => item.key === activeMenu.value)?.label || '');
|
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(() => {
|
setUnauthorizedHandler(() => {
|
||||||
token.value = '';
|
token.value = '';
|
||||||
@@ -113,6 +167,26 @@ function logout() {
|
|||||||
token.value = '';
|
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) {
|
function handleMenuChange(key: string | number) {
|
||||||
if (!isMenuKey(key) || key === activeMenu.value) {
|
if (!isMenuKey(key) || key === activeMenu.value) {
|
||||||
return;
|
return;
|
||||||
@@ -127,4 +201,39 @@ function isMenuKey(value: unknown): value is MenuKey {
|
|||||||
function renderIcon(icon: Component) {
|
function renderIcon(icon: Component) {
|
||||||
return () => h(NIcon, null, { default: () => h(icon) });
|
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>
|
</script>
|
||||||
|
|||||||
@@ -2,13 +2,6 @@
|
|||||||
<div class="database-workspace">
|
<div class="database-workspace">
|
||||||
<aside class="database-sidebar-pane">
|
<aside class="database-sidebar-pane">
|
||||||
<section class="database-table-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">
|
<div class="database-table-list-wrap">
|
||||||
<n-empty v-if="tables.length === 0 && !loadingTables" description="暂无数据表" />
|
<n-empty v-if="tables.length === 0 && !loadingTables" description="暂无数据表" />
|
||||||
<n-spin v-else-if="loadingTables" class="database-list-loading" size="small" />
|
<n-spin v-else-if="loadingTables" class="database-list-loading" size="small" />
|
||||||
@@ -126,12 +119,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
import { useDialog, useMessage } from 'naive-ui';
|
import { useDialog, useMessage } from 'naive-ui';
|
||||||
import type { DataTableColumns } from 'naive-ui';
|
import type { DataTableColumns } from 'naive-ui';
|
||||||
|
|
||||||
import { apiGet, apiPost, download } from '../api/client';
|
import { apiGet, apiPost, download } from '../api/client';
|
||||||
import type { ApiMessage, DatabaseInfo, TableData, TableInfo } from '../types';
|
import type { ApiMessage, DatabaseInfo, TableData, TableInfo } from '../types';
|
||||||
|
import { resetPageHeader, setPageHeader } from '../composables/pageHeader';
|
||||||
|
|
||||||
type RowData = Record<string, unknown>;
|
type RowData = Record<string, unknown>;
|
||||||
type DownloadFormat = 'csv' | 'xlsx';
|
type DownloadFormat = 'csv' | 'xlsx';
|
||||||
@@ -166,10 +160,27 @@ const columns = computed<DataTableColumns<RowData>>(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
setPageHeader({
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
key: 'drop-all-tables',
|
||||||
|
label: '删除全部表',
|
||||||
|
icon: '🗑️',
|
||||||
|
type: 'error',
|
||||||
|
variant: 'solid',
|
||||||
|
disabled: computed(() => tables.value.length === 0),
|
||||||
|
onClick: confirmDropAll
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
void testConnection();
|
void testConnection();
|
||||||
void loadTables();
|
void loadTables();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
resetPageHeader();
|
||||||
|
});
|
||||||
|
|
||||||
async function testConnection() {
|
async function testConnection() {
|
||||||
testing.value = true;
|
testing.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -342,9 +353,10 @@ function formatCell(value: unknown): string {
|
|||||||
.database-workspace {
|
.database-workspace {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
height: calc(100vh - 92px);
|
height: 100%;
|
||||||
min-height: 560px;
|
min-height: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
padding: 24px 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.database-sidebar-pane {
|
.database-sidebar-pane {
|
||||||
@@ -358,9 +370,9 @@ function formatCell(value: unknown): string {
|
|||||||
.database-table-pane,
|
.database-table-pane,
|
||||||
.database-content-pane,
|
.database-content-pane,
|
||||||
.database-server-info {
|
.database-server-info {
|
||||||
background: #fff;
|
background: var(--td-bg-color-container);
|
||||||
border: 1px solid #dfe4ec;
|
border: 1px solid var(--td-border-color-light);
|
||||||
border-radius: 6px;
|
border-radius: var(--td-radius-default);
|
||||||
}
|
}
|
||||||
|
|
||||||
.database-table-pane {
|
.database-table-pane {
|
||||||
@@ -370,16 +382,6 @@ function formatCell(value: unknown): string {
|
|||||||
flex-direction: column;
|
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 {
|
.database-table-list-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -403,36 +405,37 @@ function formatCell(value: unknown): string {
|
|||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 8px 14px;
|
padding: 10px 16px;
|
||||||
color: #1f2933;
|
color: var(--td-text-color-primary);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-bottom: 1px solid #e8ecf2;
|
border-bottom: 1px solid var(--td-border-color-light);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: all var(--td-transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.database-table-item:hover {
|
.database-table-item:hover {
|
||||||
background: #f4f7fb;
|
background: var(--td-bg-color-container-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.database-table-item.active {
|
.database-table-item.active {
|
||||||
color: #0052d9;
|
color: var(--td-brand-color);
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
background: #ecf2fe;
|
background: var(--td-brand-color-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-dot {
|
.table-dot {
|
||||||
width: 7px;
|
width: 7px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
background: #9aa4b2;
|
background: var(--td-text-color-placeholder);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.database-table-item.active .table-dot {
|
.database-table-item.active .table-dot {
|
||||||
background: #0052d9;
|
background: var(--td-brand-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-name {
|
.table-name {
|
||||||
@@ -447,7 +450,7 @@ function formatCell(value: unknown): string {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 10px 12px 28px;
|
padding: 10px 12px 28px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
background: #f8fafc;
|
background: var(--td-bg-color-secondarycontainer);
|
||||||
}
|
}
|
||||||
|
|
||||||
.server-info-item {
|
.server-info-item {
|
||||||
@@ -459,11 +462,11 @@ function formatCell(value: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.server-info-item:not(:last-of-type) {
|
.server-info-item:not(:last-of-type) {
|
||||||
border-bottom: 1px dashed #dfe4ec;
|
border-bottom: 1px dashed var(--td-border-color-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-label {
|
.info-label {
|
||||||
color: #4f5b6b;
|
color: var(--td-text-color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-value {
|
.info-value {
|
||||||
@@ -484,18 +487,18 @@ function formatCell(value: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-badge.success {
|
.status-badge.success {
|
||||||
color: #00a870;
|
color: var(--td-success-color);
|
||||||
background: #dff7ed;
|
background: var(--td-success-color-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge.warning {
|
.status-badge.warning {
|
||||||
color: #ed7b2f;
|
color: var(--td-warning-color);
|
||||||
background: #fff1e6;
|
background: var(--td-warning-color-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge.unknown {
|
.status-badge.unknown {
|
||||||
color: #8b95a4;
|
color: var(--td-text-color-placeholder);
|
||||||
background: #edf1f6;
|
background: var(--td-bg-color-container-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.connection-test {
|
.connection-test {
|
||||||
@@ -518,9 +521,9 @@ function formatCell(value: unknown): string {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
min-height: 48px;
|
min-height: 48px;
|
||||||
padding: 8px 14px;
|
padding: 12px 16px;
|
||||||
background: #f8fafc;
|
background: var(--td-bg-color-secondarycontainer);
|
||||||
border-bottom: 1px solid #e6eaf0;
|
border-bottom: 1px solid var(--td-border-color-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-left {
|
.toolbar-left {
|
||||||
@@ -533,8 +536,8 @@ function formatCell(value: unknown): string {
|
|||||||
.current-table {
|
.current-table {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
max-width: 42vw;
|
max-width: 42vw;
|
||||||
color: #1f2933;
|
color: var(--td-text-color-primary);
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
@@ -542,9 +545,9 @@ function formatCell(value: unknown): string {
|
|||||||
.row-count {
|
.row-count {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
color: #667386;
|
color: var(--td-text-color-secondary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
background: #edf1f6;
|
background: var(--td-bg-color-component);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,7 +576,7 @@ function formatCell(value: unknown): string {
|
|||||||
.columns-collapse {
|
.columns-collapse {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 0 14px 12px;
|
padding: 0 14px 12px;
|
||||||
border-top: 1px solid #e6eaf0;
|
border-top: 1px solid var(--td-border-color-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.database-pagination {
|
.database-pagination {
|
||||||
@@ -582,15 +585,15 @@ function formatCell(value: unknown): string {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
min-height: 48px;
|
min-height: 48px;
|
||||||
padding: 8px 14px;
|
padding: 12px 16px;
|
||||||
background: #f8fafc;
|
background: var(--td-bg-color-secondarycontainer);
|
||||||
border-top: 1px solid #e6eaf0;
|
border-top: 1px solid var(--td-border-color-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-total {
|
.pagination-total {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
color: #667386;
|
color: var(--td-text-color-secondary);
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
@@ -598,6 +601,7 @@ function formatCell(value: unknown): string {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: auto;
|
height: auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.database-sidebar-pane {
|
.database-sidebar-pane {
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
@click="openFilePicker"
|
@click="openFilePicker"
|
||||||
@keydown.enter.prevent="openFilePicker"
|
@keydown.enter.prevent="openFilePicker"
|
||||||
@keydown.space.prevent="openFilePicker"
|
@keydown.space.prevent="openFilePicker"
|
||||||
@dragenter.prevent="onDragEnter"
|
@dragenter.prevent.stop="onDragEnter"
|
||||||
@dragover.prevent="onDragOver"
|
@dragover.prevent.stop="onDragOver"
|
||||||
@dragleave="onDragLeave"
|
@dragleave="onDragLeave"
|
||||||
@drop="onDrop"
|
@drop.prevent.stop="onDrop"
|
||||||
>
|
>
|
||||||
<div class="upload-icon">
|
<div class="upload-icon">
|
||||||
<n-icon size="24"><FolderOpenOutline /></n-icon>
|
<n-icon size="24"><FolderOpenOutline /></n-icon>
|
||||||
|
|||||||
@@ -1,92 +1,98 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-grid :cols="12" :x-gap="16" :y-gap="16" responsive="screen">
|
<div class="history-page-body">
|
||||||
<n-gi :span="12" :m="5">
|
<n-spin v-if="loading" class="page-loading" />
|
||||||
<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>
|
<div v-else-if="records.length === 0" class="empty-state history-empty-state">
|
||||||
<n-alert v-if="cacheSize" type="default" :bordered="false">
|
<span class="empty-icon">📭</span>
|
||||||
缓存占用:{{ cacheSize.size_formatted }},目录 {{ cacheSize.dir_count }} 个
|
<p>暂无处理记录</p>
|
||||||
</n-alert>
|
</div>
|
||||||
|
|
||||||
<n-empty v-if="records.length === 0 && !loading" description="暂无历史记录" />
|
<div v-else class="history-list">
|
||||||
<n-scrollbar v-else class="history-list">
|
<div v-for="record in records" :key="record.id" class="history-item">
|
||||||
<n-list bordered>
|
<div class="history-status" :class="record.status"></div>
|
||||||
<n-list-item v-for="record in records" :key="record.id">
|
<div class="history-info">
|
||||||
<n-thing :title="record.id" :description="recordSummary(record)">
|
<div class="history-id">{{ record.id }}</div>
|
||||||
<template #header-extra>
|
<div class="history-meta">
|
||||||
<n-tag size="small" :type="statusType(record.status)">{{ statusText(record.status) }}</n-tag>
|
{{ formatDate(record.timestamp) }} · {{ record.file_count }} 个文件 · {{ formatElapsed(record.elapsed_time) }}
|
||||||
</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>
|
</div>
|
||||||
</n-space>
|
</div>
|
||||||
</n-card>
|
<span class="record-size">{{ statusText(record.status) }}</span>
|
||||||
</n-gi>
|
<div class="history-actions">
|
||||||
</n-grid>
|
<n-button size="small" tertiary @click="openDetail(record.id)">详情</n-button>
|
||||||
|
<n-button size="small" tertiary type="error" @click="confirmDelete(record.id)">删除</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-modal v-model:show="detailVisible" preset="card" class="history-detail-modal" title="任务详情">
|
||||||
|
<n-empty v-if="!detail" description="暂无详情" />
|
||||||
|
<n-space v-else vertical size="large">
|
||||||
|
<n-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="占用">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="section-label">处理日志</div>
|
||||||
|
<n-log :log="detail.logs.join('\n') || '暂无日志'" language="text" trim class="log-panel" />
|
||||||
|
</div>
|
||||||
|
</n-space>
|
||||||
|
</n-modal>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
import { useDialog, useMessage } from 'naive-ui';
|
import { useDialog, useMessage } from 'naive-ui';
|
||||||
import type { TagProps } from 'naive-ui';
|
|
||||||
|
|
||||||
import { apiGet, apiPost } from '../api/client';
|
import { apiGet, apiPost } from '../api/client';
|
||||||
import type { CacheSize, HistoryDetail, HistoryRecord } from '../types';
|
import type { CacheSize, HistoryDetail, HistoryRecord } from '../types';
|
||||||
|
import { resetPageHeader, setPageHeader } from '../composables/pageHeader';
|
||||||
|
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
const dialog = useDialog();
|
const dialog = useDialog();
|
||||||
const records = ref<HistoryRecord[]>([]);
|
const records = ref<HistoryRecord[]>([]);
|
||||||
const detail = ref<HistoryDetail | null>(null);
|
const detail = ref<HistoryDetail | null>(null);
|
||||||
|
const detailVisible = ref(false);
|
||||||
const cacheSize = ref<CacheSize | null>(null);
|
const cacheSize = ref<CacheSize | null>(null);
|
||||||
const recordSize = ref('-');
|
const recordSize = ref('-');
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const totalSizeText = computed(() => `总占用: ${cacheSize.value?.size_formatted || '计算中...'}`);
|
||||||
|
const hasRecords = computed(() => records.value.length > 0);
|
||||||
|
|
||||||
onMounted(() => {
|
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();
|
void loadHistory();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
resetPageHeader();
|
||||||
|
});
|
||||||
|
|
||||||
async function loadHistory() {
|
async function loadHistory() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -107,6 +113,7 @@ async function openDetail(recordId: string) {
|
|||||||
try {
|
try {
|
||||||
detail.value = await apiPost<HistoryDetail>('/api/history/detail', { record_id: recordId });
|
detail.value = await apiPost<HistoryDetail>('/api/history/detail', { record_id: recordId });
|
||||||
recordSize.value = '-';
|
recordSize.value = '-';
|
||||||
|
detailVisible.value = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error instanceof Error ? error.message : '加载任务详情失败');
|
message.error(error instanceof Error ? error.message : '加载任务详情失败');
|
||||||
}
|
}
|
||||||
@@ -138,6 +145,7 @@ async function deleteRecord(recordId: string) {
|
|||||||
await apiPost('/api/history/delete', { record_id: recordId });
|
await apiPost('/api/history/delete', { record_id: recordId });
|
||||||
if (detail.value?.id === recordId) {
|
if (detail.value?.id === recordId) {
|
||||||
detail.value = null;
|
detail.value = null;
|
||||||
|
detailVisible.value = false;
|
||||||
}
|
}
|
||||||
message.success('删除成功');
|
message.success('删除成功');
|
||||||
await loadHistory();
|
await loadHistory();
|
||||||
@@ -160,6 +168,7 @@ async function clearHistory() {
|
|||||||
try {
|
try {
|
||||||
await apiPost('/api/history/clear');
|
await apiPost('/api/history/clear');
|
||||||
detail.value = null;
|
detail.value = null;
|
||||||
|
detailVisible.value = false;
|
||||||
message.success('已清空历史记录');
|
message.success('已清空历史记录');
|
||||||
await loadHistory();
|
await loadHistory();
|
||||||
} catch (error) {
|
} 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 {
|
function statusText(status: string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
pending: '待处理',
|
pending: '待处理',
|
||||||
@@ -184,10 +186,6 @@ function statusText(status: string): string {
|
|||||||
return map[status] || status;
|
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 {
|
function formatDate(value: string): string {
|
||||||
if (!value) return '-';
|
if (!value) return '-';
|
||||||
return value.replace('T', ' ').slice(0, 19);
|
return value.replace('T', ' ').slice(0, 19);
|
||||||
|
|||||||
@@ -1,46 +1,31 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="script-workspace">
|
<div class="script-page-body">
|
||||||
<n-card size="small" class="work-card script-editor-pane">
|
<div class="script-editor-container">
|
||||||
<template #header>
|
<div ref="editorHost" class="script-editor" />
|
||||||
<div class="script-card-title">
|
</div>
|
||||||
<span>脚本编辑</span>
|
|
||||||
<n-tag v-if="isModified" size="small" type="warning">已修改</n-tag>
|
<div class="script-status-bar">
|
||||||
<n-tag v-else size="small" type="success">已保存</n-tag>
|
<span>{{ cursorText }}</span>
|
||||||
|
<span>{{ editorStatus }}</span>
|
||||||
|
<span>{{ lineCount }} 行</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<n-log :log="logText" language="text" trim class="script-log" />
|
||||||
</div>
|
</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>
|
||||||
|
</div>
|
||||||
<div ref="editorHost" class="monaco-editor-host" />
|
|
||||||
|
|
||||||
<div class="script-status-bar">
|
|
||||||
<span>{{ cursorText }}</span>
|
|
||||||
<span>{{ lineCount }} 行</span>
|
|
||||||
<span>{{ editorStatus }}</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>
|
|
||||||
<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>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -53,6 +38,7 @@ import 'monaco-editor/esm/vs/basic-languages/sql/sql.contribution';
|
|||||||
|
|
||||||
import { apiGet, apiPost } from '../api/client';
|
import { apiGet, apiPost } from '../api/client';
|
||||||
import type { ApiMessage, ScriptContent, TaskStatus } from '../types';
|
import type { ApiMessage, ScriptContent, TaskStatus } from '../types';
|
||||||
|
import { resetPageHeader, setPageHeader } from '../composables/pageHeader';
|
||||||
|
|
||||||
type MonacoGlobal = typeof globalThis & {
|
type MonacoGlobal = typeof globalThis & {
|
||||||
MonacoEnvironment?: {
|
MonacoEnvironment?: {
|
||||||
@@ -81,13 +67,14 @@ const executing = ref(false);
|
|||||||
const taskId = ref('');
|
const taskId = ref('');
|
||||||
const taskStatus = ref<TaskStatus | null>(null);
|
const taskStatus = ref<TaskStatus | null>(null);
|
||||||
let timer: number | undefined;
|
let timer: number | undefined;
|
||||||
|
let themeObserver: MutationObserver | undefined;
|
||||||
let applyingRemoteContent = false;
|
let applyingRemoteContent = false;
|
||||||
|
|
||||||
const logText = computed(() => taskStatus.value?.logs?.join('\n') || '等待执行日志');
|
const logText = computed(() => taskStatus.value?.logs?.join('\n') || '等待执行日志');
|
||||||
const statusType = computed(() => {
|
const statusClass = computed(() => {
|
||||||
if (taskStatus.value?.status === 'completed') return 'success';
|
if (taskStatus.value?.status === 'completed') return 'completed';
|
||||||
if (taskStatus.value?.status === 'failed') return 'error';
|
if (taskStatus.value?.status === 'failed') return 'failed';
|
||||||
return 'info';
|
return 'processing';
|
||||||
});
|
});
|
||||||
const statusText = computed(() => {
|
const statusText = computed(() => {
|
||||||
const status = taskStatus.value?.status;
|
const status = taskStatus.value?.status;
|
||||||
@@ -102,15 +89,29 @@ const editorStatus = computed(() => {
|
|||||||
if (saving.value) return '保存中';
|
if (saving.value) return '保存中';
|
||||||
return isModified.value ? '未保存' : '就绪';
|
return isModified.value ? '未保存' : '就绪';
|
||||||
});
|
});
|
||||||
|
const modifiedText = computed(() => (modified.value ? `最后修改: ${modified.value}` : '最后修改: -'));
|
||||||
|
|
||||||
onMounted(async () => {
|
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();
|
await nextTick();
|
||||||
createEditor();
|
createEditor();
|
||||||
|
observeTheme();
|
||||||
await loadScript();
|
await loadScript();
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
resetPageHeader();
|
||||||
stopPolling();
|
stopPolling();
|
||||||
|
themeObserver?.disconnect();
|
||||||
for (const disposable of disposables) {
|
for (const disposable of disposables) {
|
||||||
disposable.dispose();
|
disposable.dispose();
|
||||||
}
|
}
|
||||||
@@ -123,17 +124,25 @@ function createEditor() {
|
|||||||
editor.value = monaco.editor.create(editorHost.value, {
|
editor.value = monaco.editor.create(editorHost.value, {
|
||||||
value: '-- 正在加载 ReportScript.sql',
|
value: '-- 正在加载 ReportScript.sql',
|
||||||
language: 'sql',
|
language: 'sql',
|
||||||
theme: 'vs',
|
theme: currentEditorTheme(),
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: "'Cascadia Code', Consolas, 'Courier New', monospace",
|
fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, 'Courier New', monospace",
|
||||||
lineNumbers: 'on',
|
lineNumbers: 'on',
|
||||||
minimap: { enabled: true },
|
minimap: { enabled: true },
|
||||||
automaticLayout: true,
|
automaticLayout: true,
|
||||||
scrollBeyondLastLine: false,
|
scrollBeyondLastLine: false,
|
||||||
roundedSelection: false,
|
roundedSelection: true,
|
||||||
renderLineHighlight: 'all',
|
renderLineHighlight: 'all',
|
||||||
tabSize: 4,
|
tabSize: 4,
|
||||||
wordWrap: 'off'
|
insertSpaces: true,
|
||||||
|
wordWrap: 'on',
|
||||||
|
folding: true,
|
||||||
|
showFoldingControls: 'always',
|
||||||
|
bracketPairColorization: { enabled: true },
|
||||||
|
guides: {
|
||||||
|
bracketPairs: true,
|
||||||
|
indentation: true
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
disposables.push(
|
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() {
|
async function executeScript() {
|
||||||
executing.value = true;
|
executing.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -238,6 +258,17 @@ function updateCursor() {
|
|||||||
cursorText.value = position ? `行 ${position.lineNumber},列 ${position.column}` : '行 1,列 1';
|
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() {
|
function startPolling() {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
void refreshStatus();
|
void refreshStatus();
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="settings-workspace">
|
<div class="settings-workspace">
|
||||||
|
<input ref="configInput" class="hidden-input" type="file" accept=".json" @change="uploadConfigFile" />
|
||||||
|
|
||||||
<div class="settings-column">
|
<div class="settings-column">
|
||||||
<n-card title="数据库配置" size="small" class="work-card">
|
<n-card title="数据库配置" size="small" class="work-card">
|
||||||
<template #header-extra>
|
<template #header-extra>
|
||||||
<n-space>
|
<span class="muted-line">更新时间:{{ configUpdate || '-' }}</span>
|
||||||
<n-button size="small" tertiary :loading="loading" @click="loadConfig">刷新</n-button>
|
|
||||||
<n-button size="small" tertiary :loading="testingDb" @click="testDatabase">测试连接</n-button>
|
|
||||||
</n-space>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<n-form label-placement="top">
|
<n-form label-placement="top">
|
||||||
@@ -37,15 +36,19 @@
|
|||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-gi>
|
</n-gi>
|
||||||
</n-grid>
|
</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>
|
</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>
|
||||||
|
|
||||||
<n-card title="Sheet 过滤规则" size="small" class="work-card">
|
<n-card title="Sheet 过滤规则" size="small" class="work-card">
|
||||||
<n-space vertical>
|
<n-space vertical>
|
||||||
|
<p class="form-hint">匹配这些关键词的 Sheet 将被跳过处理</p>
|
||||||
<div class="filter-tags">
|
<div class="filter-tags">
|
||||||
<n-tag
|
<n-tag
|
||||||
v-for="(filter, index) in sheetFilters"
|
v-for="(filter, index) in sheetFilters"
|
||||||
@@ -65,14 +68,16 @@
|
|||||||
/>
|
/>
|
||||||
<n-button @click="addSheetFilter">添加</n-button>
|
<n-button @click="addSheetFilter">添加</n-button>
|
||||||
</n-input-group>
|
</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>
|
</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>
|
||||||
|
|
||||||
<n-card title="登录密码" size="small" class="work-card">
|
<n-card title="修改登录密码" size="small" class="work-card">
|
||||||
<n-form label-placement="top">
|
<n-form label-placement="top">
|
||||||
<n-form-item label="当前密码">
|
<n-form-item label="当前密码">
|
||||||
<n-input v-model:value="passwordForm.current_password" type="password" show-password-on="click" />
|
<n-input v-model:value="passwordForm.current_password" type="password" show-password-on="click" />
|
||||||
@@ -80,61 +85,39 @@
|
|||||||
<n-form-item label="新密码">
|
<n-form-item label="新密码">
|
||||||
<n-input v-model:value="passwordForm.new_password" type="password" show-password-on="click" />
|
<n-input v-model:value="passwordForm.new_password" type="password" show-password-on="click" />
|
||||||
</n-form-item>
|
</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-input v-model:value="passwordForm.confirm_password" type="password" show-password-on="click" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<div class="card-actions">
|
|
||||||
<span />
|
|
||||||
<n-button type="primary" :loading="changingPassword" @click="changePassword">修改密码</n-button>
|
|
||||||
</div>
|
|
||||||
</n-form>
|
</n-form>
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<n-card title="配置文件与服务" size="small" class="work-card">
|
<template #footer>
|
||||||
<n-space vertical>
|
<n-space justify="end">
|
||||||
<n-space>
|
<n-button type="primary" :loading="changingPassword" @click="changePassword">修改密码</n-button>
|
||||||
<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-space>
|
||||||
|
</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>
|
|
||||||
|
|
||||||
<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>
|
</n-card>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<n-card size="small" class="work-card field-mapping-card">
|
<n-card size="small" class="work-card field-mapping-card">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="field-card-title">
|
<div class="field-card-header">
|
||||||
<span>字段提取配置</span>
|
<div class="field-card-title-row">
|
||||||
<n-tag size="small" round>{{ extractFields.length }} 个字段</n-tag>
|
<div class="field-card-title">
|
||||||
|
<span>字段映射配置</span>
|
||||||
|
<n-tag size="small" round>{{ extractFields.length }} 个字段</n-tag>
|
||||||
|
</div>
|
||||||
|
<n-input
|
||||||
|
v-model:value="fieldSearch"
|
||||||
|
size="small"
|
||||||
|
clearable
|
||||||
|
class="field-search"
|
||||||
|
placeholder="搜索字段..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="form-hint">定义 Excel 列名到数据库字段的映射规则,提取来源匹配任意一个即可</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #header-extra>
|
|
||||||
<n-space align="center">
|
|
||||||
<n-input
|
|
||||||
v-model:value="fieldSearch"
|
|
||||||
size="small"
|
|
||||||
clearable
|
|
||||||
class="field-search"
|
|
||||||
placeholder="搜索字段或提取来源"
|
|
||||||
/>
|
|
||||||
<n-button size="small" @click="addFieldMapping">添加字段</n-button>
|
|
||||||
<n-button size="small" type="primary" :loading="savingExtractFields" @click="saveExtractFields">
|
|
||||||
保存映射
|
|
||||||
</n-button>
|
|
||||||
</n-space>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<n-scrollbar class="field-mapping-scroll">
|
<n-scrollbar class="field-mapping-scroll">
|
||||||
<n-empty v-if="visibleFieldMappings.length === 0" description="没有匹配的字段配置" />
|
<n-empty v-if="visibleFieldMappings.length === 0" description="没有匹配的字段配置" />
|
||||||
@@ -189,16 +172,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</n-scrollbar>
|
</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>
|
</n-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
|
||||||
import { useDialog, useMessage, type SelectOption } from 'naive-ui';
|
import { useMessage, type SelectOption } from 'naive-ui';
|
||||||
|
|
||||||
import { apiGet, apiPost, downloadGet, upload } from '../api/client';
|
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 {
|
interface ExtractFieldConfig {
|
||||||
Field: string;
|
Field: string;
|
||||||
@@ -208,7 +201,6 @@ interface ExtractFieldConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
const dialog = useDialog();
|
|
||||||
const configInput = ref<HTMLInputElement | null>(null);
|
const configInput = ref<HTMLInputElement | null>(null);
|
||||||
const configUpdate = ref('');
|
const configUpdate = ref('');
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -217,7 +209,6 @@ const savingMysql = ref(false);
|
|||||||
const savingSheetFilter = ref(false);
|
const savingSheetFilter = ref(false);
|
||||||
const savingExtractFields = ref(false);
|
const savingExtractFields = ref(false);
|
||||||
const changingPassword = ref(false);
|
const changingPassword = ref(false);
|
||||||
const serviceStatus = ref<ServiceStatus | null>(null);
|
|
||||||
const sheetFilters = ref<string[]>([]);
|
const sheetFilters = ref<string[]>([]);
|
||||||
const newSheetFilter = ref('');
|
const newSheetFilter = ref('');
|
||||||
const extractFields = ref<ExtractFieldConfig[]>([]);
|
const extractFields = ref<ExtractFieldConfig[]>([]);
|
||||||
@@ -258,8 +249,17 @@ const visibleFieldMappings = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
setPageHeader({
|
||||||
|
actions: [
|
||||||
|
{ key: 'download-config', label: '下载配置', icon: '📥', onClick: downloadConfig },
|
||||||
|
{ key: 'upload-config', label: '上传配置', icon: '📤', onClick: () => configInput.value?.click() }
|
||||||
|
]
|
||||||
|
});
|
||||||
void loadConfig();
|
void loadConfig();
|
||||||
void loadServiceStatus();
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
resetPageHeader();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadConfig() {
|
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[] {
|
function normalizeExtractFields(fields: Array<Record<string, unknown>>): ExtractFieldConfig[] {
|
||||||
return fields.map(field => {
|
return fields.map(field => {
|
||||||
const extract = Array.isArray(field.Extract) ? uniqueStrings(field.Extract) : [];
|
const extract = Array.isArray(field.Extract) ? uniqueStrings(field.Extract) : [];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ const ScriptPanel = () => import('./components/ScriptPanel.vue');
|
|||||||
|
|
||||||
export const routes: RouteRecordRaw[] = [
|
export const routes: RouteRecordRaw[] = [
|
||||||
{ path: '/', redirect: { name: 'workflow' } },
|
{ 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: '/history', name: 'history', component: HistoryPanel, meta: { title: '处理历史' } },
|
||||||
{ path: '/database', name: 'database', component: DatabasePanel, meta: { title: '数据管理' } },
|
{ path: '/database', name: 'database', component: DatabasePanel, meta: { title: '数据管理' } },
|
||||||
{ path: '/script', name: 'script', component: ScriptPanel, meta: { title: '脚本编辑' } },
|
{ path: '/script', name: 'script', component: ScriptPanel, meta: { title: '脚本编辑' } },
|
||||||
|
|||||||
+838
-476
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user