feat: 优化数据表导出选择流程
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import re
|
||||
from contextlib import suppress
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -14,6 +15,7 @@ from app.database import DatabaseManager
|
||||
|
||||
|
||||
router = APIRouter(tags=["database"])
|
||||
INVALID_SHEET_NAME_CHARS = re.compile(r"[:\\/?*\[\]]")
|
||||
|
||||
|
||||
def _remove_file(path: Path) -> None:
|
||||
@@ -21,6 +23,36 @@ def _remove_file(path: Path) -> None:
|
||||
path.unlink()
|
||||
|
||||
|
||||
def _resolve_requested_tables(
|
||||
table_name: Optional[str],
|
||||
table_names: Optional[list[str]],
|
||||
) -> list[str]:
|
||||
names = table_names if table_names is not None else ([table_name] if table_name else [])
|
||||
return [name.strip() for name in names if isinstance(name, str) and name.strip()]
|
||||
|
||||
|
||||
def _make_sheet_name(table_name: str, used_names: set[str]) -> str:
|
||||
base = INVALID_SHEET_NAME_CHARS.sub("_", table_name).strip("'").strip() or "Sheet"
|
||||
base = base[:31]
|
||||
sheet_name = base
|
||||
index = 2
|
||||
|
||||
while sheet_name in used_names:
|
||||
suffix = f"_{index}"
|
||||
sheet_name = f"{base[:31 - len(suffix)]}{suffix}" or f"Sheet_{index}"
|
||||
index += 1
|
||||
|
||||
used_names.add(sheet_name)
|
||||
return sheet_name
|
||||
|
||||
|
||||
def _dataframe_from_table(db: DatabaseManager, table_name: str) -> pd.DataFrame:
|
||||
result = db.query_table(table_name, page=1, page_size=1000000)
|
||||
table_info = db.get_table_info(table_name)
|
||||
columns = [str(column["Field"]) for column in table_info["columns"]]
|
||||
return pd.DataFrame(result["data"], columns=columns)
|
||||
|
||||
|
||||
@router.post("/api/database/test")
|
||||
async def test_database():
|
||||
db = DatabaseManager(state.config)
|
||||
@@ -166,32 +198,52 @@ async def execute_sql(sql: str = Body(..., embed=True)):
|
||||
|
||||
@router.post("/api/download")
|
||||
async def download_table(
|
||||
table_name: str = Body(..., embed=True),
|
||||
table_name: Optional[str] = Body(None, embed=True),
|
||||
table_names: Optional[list[str]] = Body(None, embed=True),
|
||||
file_format: str = Body("csv", alias="format"),
|
||||
):
|
||||
if file_format not in {"csv", "xlsx"}:
|
||||
raise HTTPException(status_code=400, detail="不支持的导出格式")
|
||||
|
||||
requested_tables = _resolve_requested_tables(table_name, table_names)
|
||||
if not requested_tables:
|
||||
raise HTTPException(status_code=400, detail="请选择要导出的数据表")
|
||||
if file_format == "csv" and len(requested_tables) != 1:
|
||||
raise HTTPException(status_code=400, detail="CSV 每次只能导出一张表")
|
||||
|
||||
db = DatabaseManager(state.config)
|
||||
try:
|
||||
result = db.query_table(table_name, page=1, page_size=1000000)
|
||||
available_tables = set(db.get_tables())
|
||||
missing_tables = [name for name in requested_tables if name not in available_tables]
|
||||
if missing_tables:
|
||||
raise HTTPException(status_code=400, detail=f"数据表不存在: {', '.join(missing_tables)}")
|
||||
|
||||
table_frames = {
|
||||
name: _dataframe_from_table(db, name)
|
||||
for name in requested_tables
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
finally:
|
||||
db.dispose()
|
||||
|
||||
df = pd.DataFrame(result["data"])
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"{table_name}_{timestamp}.{file_format}"
|
||||
filename_prefix = requested_tables[0] if len(requested_tables) == 1 else "tables"
|
||||
filename = f"{filename_prefix}_{timestamp}.{file_format}"
|
||||
filepath = CACHE_DIR / filename
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
if file_format == "csv":
|
||||
df.to_csv(filepath, index=False, encoding="utf-8-sig")
|
||||
table_frames[requested_tables[0]].to_csv(filepath, index=False, encoding="utf-8-sig")
|
||||
media_type = "text/csv"
|
||||
else:
|
||||
df.to_excel(filepath, index=False)
|
||||
used_sheet_names: set[str] = set()
|
||||
with pd.ExcelWriter(filepath) as writer:
|
||||
for name, df in table_frames.items():
|
||||
df.to_excel(writer, sheet_name=_make_sheet_name(name, used_sheet_names), index=False)
|
||||
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
except Exception:
|
||||
_remove_file(filepath)
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# 项目上下文记录
|
||||
|
||||
## 2026-05-19:调整数据管理导出交互并支持多表 XLSX
|
||||
|
||||
- `frontend/src/components/DatabasePanel.vue` 将导出入口移到页面顶部,并放在“删除全部表”按钮左侧;内容区工具栏只保留刷新、清空、删除当前表。
|
||||
- 点击“导出 CSV”会弹出全部表单选弹窗,用户选择一张表后下载 CSV;点击“导出 XLSX”会弹出全部表多选弹窗,用户可选择多张表并下载同一个 XLSX。
|
||||
- `app/api/routers/database.py` 的 `/api/download` 支持 `table_names`,XLSX 会按表名分 sheet 写入同一工作簿,sheet 名会兼容 Excel 的非法字符和 31 字符限制;CSV 仍限制单表导出。
|
||||
- `frontend/src/api/client.ts` 的 POST 下载会优先使用后端 `Content-Disposition` 文件名,便于多表导出使用服务端生成的文件名。
|
||||
- 已执行 `.venv\Scripts\python.exe -m compileall app` 和 `npm run build`,均通过;构建只保留 Vite 原有大 chunk 提示。
|
||||
|
||||
## 2026-05-19:优化数据表导出临时文件清理
|
||||
|
||||
- `app/api/routers/database.py` 的 `/api/download` 导出接口增加格式校验,只允许 `csv` 和 `xlsx`。
|
||||
|
||||
@@ -113,7 +113,7 @@ export async function download(url: string, body: unknown, filename: string): Pr
|
||||
throw new Error(await readError(response));
|
||||
}
|
||||
|
||||
await saveBlobResponse(response, filename);
|
||||
await saveBlobResponse(response, parseFilename(response.headers.get('content-disposition')) || filename);
|
||||
}
|
||||
|
||||
export async function downloadGet(url: string, fallbackFilename: string): Promise<void> {
|
||||
|
||||
@@ -74,22 +74,6 @@
|
||||
</div>
|
||||
<n-space size="small" class="toolbar-actions">
|
||||
<n-button size="small" tertiary :loading="loadingData" @click="reloadTable">刷新</n-button>
|
||||
<n-dropdown
|
||||
trigger="click"
|
||||
:options="downloadOptions"
|
||||
:disabled="downloading"
|
||||
@select="downloadTable"
|
||||
>
|
||||
<n-button size="small" tertiary :loading="downloading" :disabled="downloading">
|
||||
<template #icon>
|
||||
<n-icon><CloudDownloadOutline /></n-icon>
|
||||
</template>
|
||||
<span class="download-button-content">
|
||||
<span>{{ downloadButtonText }}</span>
|
||||
<n-icon class="download-caret"><ChevronDownOutline /></n-icon>
|
||||
</span>
|
||||
</n-button>
|
||||
</n-dropdown>
|
||||
<n-button size="small" tertiary type="warning" @click="confirmTruncate">清空</n-button>
|
||||
<n-button size="small" tertiary type="error" @click="confirmDrop">删除</n-button>
|
||||
</n-space>
|
||||
@@ -161,15 +145,72 @@
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<n-modal v-model:show="csvDialogVisible" preset="card" class="table-export-modal" title="导出 CSV">
|
||||
<div class="export-dialog-body">
|
||||
<p class="export-dialog-hint">选择要导出的数据表。</p>
|
||||
<n-scrollbar class="export-table-options">
|
||||
<n-radio-group v-model:value="selectedCsvTable" name="csv-export-table">
|
||||
<n-space vertical size="small">
|
||||
<n-radio v-for="table in tables" :key="table" :value="table">
|
||||
{{ table }}
|
||||
</n-radio>
|
||||
</n-space>
|
||||
</n-radio-group>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="export-dialog-footer">
|
||||
<n-button size="small" @click="csvDialogVisible = false">取消</n-button>
|
||||
<n-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="downloadingFormat === 'csv'"
|
||||
:disabled="!selectedCsvTable || downloading"
|
||||
@click="downloadSelectedCsv"
|
||||
>
|
||||
导出 CSV
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<n-modal v-model:show="xlsxDialogVisible" preset="card" class="table-export-modal" title="导出 XLSX">
|
||||
<div class="export-dialog-body">
|
||||
<p class="export-dialog-hint">选择一个或多个数据表,导出后会合并到同一个 XLSX 文件中。</p>
|
||||
<n-scrollbar class="export-table-options">
|
||||
<n-checkbox-group v-model:value="selectedXlsxTables">
|
||||
<n-space vertical size="small">
|
||||
<n-checkbox v-for="table in tables" :key="table" :value="table">
|
||||
{{ table }}
|
||||
</n-checkbox>
|
||||
</n-space>
|
||||
</n-checkbox-group>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="export-dialog-footer">
|
||||
<n-button size="small" @click="xlsxDialogVisible = false">取消</n-button>
|
||||
<n-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="downloadingFormat === 'xlsx'"
|
||||
:disabled="selectedXlsxTables.length === 0 || downloading"
|
||||
@click="downloadSelectedXlsx"
|
||||
>
|
||||
导出 XLSX
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { onBeforeRouteLeave } from 'vue-router';
|
||||
import { useDialog, useMessage } from 'naive-ui';
|
||||
import type { DataTableColumns, DropdownOption } from 'naive-ui';
|
||||
import type { DataTableColumns } from 'naive-ui';
|
||||
import {
|
||||
ChevronDownOutline,
|
||||
ChevronForwardOutline,
|
||||
CloudDownloadOutline,
|
||||
RefreshOutline,
|
||||
@@ -198,18 +239,18 @@ const loadingTables = ref(false);
|
||||
const loadingData = ref(false);
|
||||
const testing = ref(false);
|
||||
const downloadingFormat = ref<DownloadFormat | null>(null);
|
||||
const csvDialogVisible = ref(false);
|
||||
const xlsxDialogVisible = ref(false);
|
||||
const selectedCsvTable = ref('');
|
||||
const selectedXlsxTables = ref<string[]>([]);
|
||||
const columnSchemaExpanded = ref(false);
|
||||
let tableLoadToken = 0;
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)));
|
||||
const downloading = computed(() => Boolean(downloadingFormat.value));
|
||||
const downloadButtonText = computed(() => (
|
||||
downloadingFormat.value ? `导出 ${downloadingFormat.value.toUpperCase()}...` : '导出'
|
||||
));
|
||||
const downloadOptions: DropdownOption[] = [
|
||||
{ label: 'CSV', key: 'csv' },
|
||||
{ label: 'XLSX', key: 'xlsx' }
|
||||
];
|
||||
const csvExportButtonText = computed(() => (downloadingFormat.value === 'csv' ? '导出 CSV...' : '导出 CSV'));
|
||||
const xlsxExportButtonText = computed(() => (downloadingFormat.value === 'xlsx' ? '导出 XLSX...' : '导出 XLSX'));
|
||||
const exportDisabled = computed(() => loadingTables.value || tables.value.length === 0 || downloading.value);
|
||||
const loadDataInfileText = computed(() => {
|
||||
if (testing.value || !databaseInfo.value) return '检测中';
|
||||
return databaseInfo.value.load_data_infile ? '可用' : '未启用';
|
||||
@@ -239,6 +280,26 @@ const columns = computed<DataTableColumns<RowData>>(() => {
|
||||
onMounted(() => {
|
||||
setPageHeader({
|
||||
actions: [
|
||||
{
|
||||
key: 'export-csv',
|
||||
label: csvExportButtonText,
|
||||
icon: CloudDownloadOutline,
|
||||
type: 'primary',
|
||||
variant: 'solid',
|
||||
loading: computed(() => downloadingFormat.value === 'csv'),
|
||||
disabled: exportDisabled,
|
||||
onClick: showCsvExportDialog
|
||||
},
|
||||
{
|
||||
key: 'export-xlsx',
|
||||
label: xlsxExportButtonText,
|
||||
icon: CloudDownloadOutline,
|
||||
type: 'primary',
|
||||
variant: 'outline',
|
||||
loading: computed(() => downloadingFormat.value === 'xlsx'),
|
||||
disabled: exportDisabled,
|
||||
onClick: showXlsxExportDialog
|
||||
},
|
||||
{
|
||||
key: 'drop-all-tables',
|
||||
label: '删除全部表',
|
||||
@@ -373,13 +434,63 @@ function changePageSize(value: number) {
|
||||
void loadTableData();
|
||||
}
|
||||
|
||||
async function downloadTable(format: DownloadFormat) {
|
||||
if (!selectedTable.value) return;
|
||||
async function showCsvExportDialog() {
|
||||
await loadTables();
|
||||
if (tables.value.length === 0) {
|
||||
message.warning('暂无可导出的数据表');
|
||||
return;
|
||||
}
|
||||
|
||||
selectedCsvTable.value = selectedTable.value && tables.value.includes(selectedTable.value)
|
||||
? selectedTable.value
|
||||
: tables.value[0];
|
||||
csvDialogVisible.value = true;
|
||||
}
|
||||
|
||||
async function showXlsxExportDialog() {
|
||||
await loadTables();
|
||||
if (tables.value.length === 0) {
|
||||
message.warning('暂无可导出的数据表');
|
||||
return;
|
||||
}
|
||||
|
||||
selectedXlsxTables.value = selectedTable.value && tables.value.includes(selectedTable.value)
|
||||
? [selectedTable.value]
|
||||
: [];
|
||||
xlsxDialogVisible.value = true;
|
||||
}
|
||||
|
||||
async function downloadSelectedCsv() {
|
||||
if (!selectedCsvTable.value) return;
|
||||
if (downloadingFormat.value) return;
|
||||
|
||||
downloadingFormat.value = format;
|
||||
downloadingFormat.value = 'csv';
|
||||
try {
|
||||
await download('/api/download', { table_name: selectedTable.value, format }, `${selectedTable.value}.${format}`);
|
||||
await download(
|
||||
'/api/download',
|
||||
{ table_name: selectedCsvTable.value, format: 'csv' },
|
||||
`${selectedCsvTable.value}.csv`
|
||||
);
|
||||
csvDialogVisible.value = false;
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '导出失败');
|
||||
} finally {
|
||||
downloadingFormat.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadSelectedXlsx() {
|
||||
if (selectedXlsxTables.value.length === 0) return;
|
||||
if (downloadingFormat.value) return;
|
||||
|
||||
downloadingFormat.value = 'xlsx';
|
||||
try {
|
||||
await download(
|
||||
'/api/download',
|
||||
{ table_names: selectedXlsxTables.value, format: 'xlsx' },
|
||||
selectedXlsxTables.value.length === 1 ? `${selectedXlsxTables.value[0]}.xlsx` : 'tables.xlsx'
|
||||
);
|
||||
xlsxDialogVisible.value = false;
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '导出失败');
|
||||
} finally {
|
||||
@@ -709,16 +820,6 @@ function formatCell(value: unknown): string {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.download-button-content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.download-caret {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.database-data-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -833,6 +934,33 @@ function formatCell(value: unknown): string {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.table-export-modal {
|
||||
width: min(520px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.export-dialog-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.export-dialog-hint {
|
||||
margin: 0;
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.export-table-options {
|
||||
max-height: min(420px, 52vh);
|
||||
padding: 6px 2px;
|
||||
}
|
||||
|
||||
.export-dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.database-workspace {
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user