feat: 数据管理列宽可调/行编辑删除/模板导入,CellData 处理日志细化
- 数据表列宽可拖拽 + 新增「操作」列(编辑/删除该行) - 工具栏新增「模板」「导入」:CSV 按模板字段校验,不一致则导入失败 - 后端 DatabaseManager/MetrixWarehouse 增加行更新/删除/CSV 导入 + 对应路由 - CellData 解压/解析/写库日志细化(逐文件、分批进度) - 修复 ScriptPanel monaco mysql 深层导入缺类型声明导致的 vue-tsc 构建失败
This commit is contained in:
@@ -88,6 +88,8 @@
|
||||
</div>
|
||||
<n-space size="small" class="toolbar-actions">
|
||||
<n-button size="small" tertiary :loading="loadingData" @click="reloadTable">刷新</n-button>
|
||||
<n-button size="small" tertiary :loading="downloadingTemplate" @click="downloadTemplate">模板</n-button>
|
||||
<n-button size="small" tertiary type="primary" @click="openImportDialog">导入</n-button>
|
||||
<n-button size="small" tertiary type="warning" @click="confirmTruncate">清空</n-button>
|
||||
<n-button size="small" tertiary type="error" @click="confirmDrop">删除</n-button>
|
||||
</n-space>
|
||||
@@ -251,12 +253,60 @@
|
||||
</button>
|
||||
</n-scrollbar>
|
||||
</n-modal>
|
||||
|
||||
<n-modal
|
||||
v-model:show="editRowVisible"
|
||||
preset="card"
|
||||
class="row-edit-modal"
|
||||
title="编辑数据行"
|
||||
style="width: min(560px, calc(100vw - 32px))"
|
||||
>
|
||||
<div class="row-edit-body">
|
||||
<div v-for="key in editRowKeys" :key="key" class="row-edit-field">
|
||||
<label class="row-edit-label" :title="key">{{ key }}</label>
|
||||
<n-input v-model:value="editRowForm[key]" type="text" :placeholder="key" />
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="row-edit-footer">
|
||||
<n-button size="small" @click="editRowVisible = false">取消</n-button>
|
||||
<n-button size="small" type="primary" :loading="savingRow" @click="saveEditRow">保存</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<n-modal
|
||||
v-model:show="importDialogVisible"
|
||||
preset="card"
|
||||
class="table-import-modal"
|
||||
title="导入 CSV"
|
||||
style="width: min(460px, calc(100vw - 32px))"
|
||||
>
|
||||
<div class="import-dialog-body">
|
||||
<p class="import-dialog-hint">
|
||||
请先下载「模板」,按模板填写数据后导入到表 <strong>{{ selectedTable }}</strong>。
|
||||
CSV 字段必须与表字段完全一致,否则导入失败。
|
||||
</p>
|
||||
<div class="import-file-row">
|
||||
<input ref="importInputRef" type="file" accept=".csv" class="import-file-input" @change="onImportFileChange" />
|
||||
<n-button size="small" @click="importInputRef?.click()">选择 CSV 文件</n-button>
|
||||
<span class="import-file-name" :title="importFile?.name">{{ importFile?.name || '未选择文件' }}</span>
|
||||
</div>
|
||||
<n-button size="tiny" text type="primary" :loading="downloadingTemplate" @click="downloadTemplate">下载模板</n-button>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="import-dialog-footer">
|
||||
<n-button size="small" @click="importDialogVisible = false">取消</n-button>
|
||||
<n-button size="small" type="primary" :loading="importing" :disabled="!importFile" @click="doImport">导入</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { computed, h, onActivated, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
|
||||
import { onBeforeRouteLeave } from 'vue-router';
|
||||
import { useDialog, useMessage } from 'naive-ui';
|
||||
import { NButton, useDialog, useMessage } from 'naive-ui';
|
||||
import type { DataTableColumns, DropdownOption } from 'naive-ui';
|
||||
import {
|
||||
ChevronForwardOutline,
|
||||
@@ -266,7 +316,7 @@ import {
|
||||
TrashOutline
|
||||
} from '@vicons/ionicons5';
|
||||
|
||||
import { apiGet, apiPost, download } from '../api/client';
|
||||
import { apiGet, apiPost, download, upload } from '../api/client';
|
||||
import type { ApiMessage, AppConfig, DatabaseInfo, TableData, TableInfo } from '../types';
|
||||
import { showDownloadCompleteDialog } from '../composables/downloadFeedback';
|
||||
import { resetPageHeader, setPageHeader } from '../composables/pageHeader';
|
||||
@@ -282,6 +332,7 @@ interface DatabaseSourceOption {
|
||||
}
|
||||
|
||||
const COLUMN_MIN_WIDTH = 140;
|
||||
const ROW_ACTIONS_WIDTH = 120;
|
||||
const message = useMessage();
|
||||
const dialog = useDialog();
|
||||
const databaseInfo = ref<DatabaseInfo | null>(null);
|
||||
@@ -307,6 +358,16 @@ const xlsxDialogVisible = ref(false);
|
||||
const selectedCsvTable = ref('');
|
||||
const selectedXlsxTables = ref<string[]>([]);
|
||||
const columnSchemaExpanded = ref(false);
|
||||
const editRowVisible = ref(false);
|
||||
const editRowOriginal = ref<RowData | null>(null);
|
||||
const editRowForm = reactive<Record<string, string>>({});
|
||||
const editRowKeys = ref<string[]>([]);
|
||||
const savingRow = ref(false);
|
||||
const downloadingTemplate = ref(false);
|
||||
const importDialogVisible = ref(false);
|
||||
const importFile = ref<File | null>(null);
|
||||
const importing = ref(false);
|
||||
const importInputRef = ref<HTMLInputElement | null>(null);
|
||||
let tableLoadToken = 0;
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)));
|
||||
@@ -335,17 +396,31 @@ const columnKeys = computed(() => (
|
||||
? Object.keys(rows.value[0])
|
||||
: tableInfo.value?.columns.map(column => String(column.Field || '')).filter(Boolean) || []
|
||||
));
|
||||
const dataTableScrollX = computed(() => Math.max(columnKeys.value.length * COLUMN_MIN_WIDTH, COLUMN_MIN_WIDTH));
|
||||
const dataTableScrollX = computed(() => (
|
||||
Math.max(columnKeys.value.length * COLUMN_MIN_WIDTH, COLUMN_MIN_WIDTH) + ROW_ACTIONS_WIDTH
|
||||
));
|
||||
const columns = computed<DataTableColumns<RowData>>(() => {
|
||||
const keys = columnKeys.value;
|
||||
|
||||
return keys.map(key => ({
|
||||
const dataColumns: DataTableColumns<RowData> = columnKeys.value.map(key => ({
|
||||
title: key,
|
||||
key,
|
||||
width: COLUMN_MIN_WIDTH,
|
||||
minWidth: COLUMN_MIN_WIDTH,
|
||||
resizable: true,
|
||||
ellipsis: { tooltip: true },
|
||||
render: row => formatCell(row[key])
|
||||
}));
|
||||
dataColumns.push({
|
||||
title: '操作',
|
||||
key: '__row_actions__',
|
||||
width: ROW_ACTIONS_WIDTH,
|
||||
fixed: 'right',
|
||||
align: 'center',
|
||||
render: row => h('div', { class: 'row-action-cell' }, [
|
||||
h(NButton, { size: 'tiny', quaternary: true, type: 'primary', onClick: () => openEditRow(row) }, { default: () => '编辑' }),
|
||||
h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => confirmDeleteRow(row) }, { default: () => '删除' })
|
||||
])
|
||||
});
|
||||
return dataColumns;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
@@ -713,6 +788,119 @@ function formatCell(value: unknown): string {
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function cellToInput(value: unknown): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function openEditRow(row: RowData) {
|
||||
editRowOriginal.value = { ...row };
|
||||
const keys = columnKeys.value;
|
||||
editRowKeys.value = keys;
|
||||
Object.keys(editRowForm).forEach(key => delete editRowForm[key]);
|
||||
keys.forEach(key => { editRowForm[key] = cellToInput(row[key]); });
|
||||
editRowVisible.value = true;
|
||||
}
|
||||
|
||||
async function saveEditRow() {
|
||||
if (!selectedTable.value || !editRowOriginal.value || savingRow.value) return;
|
||||
savingRow.value = true;
|
||||
try {
|
||||
const values: Record<string, string> = {};
|
||||
editRowKeys.value.forEach(key => { values[key] = editRowForm[key] ?? ''; });
|
||||
await apiPost('/api/database/table/row/update', {
|
||||
table_name: selectedTable.value,
|
||||
database_source: selectedDatabaseSource.value,
|
||||
identifier: editRowOriginal.value,
|
||||
values
|
||||
});
|
||||
message.success('已更新该行');
|
||||
editRowVisible.value = false;
|
||||
await loadTableData();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '更新失败');
|
||||
} finally {
|
||||
savingRow.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteRow(row: RowData) {
|
||||
dialog.warning({
|
||||
title: '删除数据行',
|
||||
content: '确认删除选中的这一行数据?',
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => deleteRow(row)
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteRow(row: RowData) {
|
||||
if (!selectedTable.value) return;
|
||||
try {
|
||||
await apiPost('/api/database/table/row/delete', {
|
||||
table_name: selectedTable.value,
|
||||
database_source: selectedDatabaseSource.value,
|
||||
identifier: { ...row }
|
||||
});
|
||||
message.success('已删除该行');
|
||||
await loadTableData();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadTemplate() {
|
||||
if (!selectedTable.value || downloadingTemplate.value) return;
|
||||
downloadingTemplate.value = true;
|
||||
try {
|
||||
const filename = `${selectedTable.value}_模板.csv`;
|
||||
const result = await download('/api/database/table/template', {
|
||||
table_name: selectedTable.value,
|
||||
database_source: selectedDatabaseSource.value
|
||||
}, filename);
|
||||
if (result.saved) {
|
||||
showDownloadCompleteDialog(dialog, filename, result.path);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '模板下载失败');
|
||||
} finally {
|
||||
downloadingTemplate.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openImportDialog() {
|
||||
if (!selectedTable.value) return;
|
||||
importFile.value = null;
|
||||
importDialogVisible.value = true;
|
||||
}
|
||||
|
||||
function onImportFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
importFile.value = input.files?.[0] ?? null;
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
async function doImport() {
|
||||
if (!selectedTable.value || !importFile.value || importing.value) return;
|
||||
importing.value = true;
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', importFile.value);
|
||||
formData.append('table_name', selectedTable.value);
|
||||
formData.append('database_source', selectedDatabaseSource.value);
|
||||
const result = await upload<ApiMessage & { imported_rows?: number }>('/api/database/table/import', formData);
|
||||
message.success(result.message || `导入成功,共 ${result.imported_rows ?? 0} 行`);
|
||||
importDialogVisible.value = false;
|
||||
importFile.value = null;
|
||||
await reloadTable();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '导入失败');
|
||||
} finally {
|
||||
importing.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -1145,6 +1333,76 @@ function formatCell(value: unknown): string {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row-action-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.row-edit-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-height: min(60vh, 480px);
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.row-edit-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.row-edit-label {
|
||||
overflow: hidden;
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.row-edit-footer,
|
||||
.import-dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.import-dialog-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.import-dialog-hint {
|
||||
margin: 0;
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.import-file-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.import-file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.import-file-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.database-workspace {
|
||||
flex-direction: column;
|
||||
|
||||
Vendored
+2
@@ -1 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module 'monaco-editor/esm/vs/basic-languages/mysql/mysql.js';
|
||||
|
||||
Reference in New Issue
Block a user