feat: 优化数据表导出选择流程

This commit is contained in:
2026-05-19 15:04:25 +08:00
parent e3e64f466e
commit b575298a3e
4 changed files with 234 additions and 46 deletions
+58 -6
View File
@@ -1,3 +1,4 @@
import re
from contextlib import suppress from contextlib import suppress
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -14,6 +15,7 @@ from app.database import DatabaseManager
router = APIRouter(tags=["database"]) router = APIRouter(tags=["database"])
INVALID_SHEET_NAME_CHARS = re.compile(r"[:\\/?*\[\]]")
def _remove_file(path: Path) -> None: def _remove_file(path: Path) -> None:
@@ -21,6 +23,36 @@ def _remove_file(path: Path) -> None:
path.unlink() 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") @router.post("/api/database/test")
async def test_database(): async def test_database():
db = DatabaseManager(state.config) db = DatabaseManager(state.config)
@@ -166,32 +198,52 @@ async def execute_sql(sql: str = Body(..., embed=True)):
@router.post("/api/download") @router.post("/api/download")
async def download_table( 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"), file_format: str = Body("csv", alias="format"),
): ):
if file_format not in {"csv", "xlsx"}: if file_format not in {"csv", "xlsx"}:
raise HTTPException(status_code=400, detail="不支持的导出格式") 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) db = DatabaseManager(state.config)
try: 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: except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc raise HTTPException(status_code=500, detail=str(exc)) from exc
finally: finally:
db.dispose() db.dispose()
df = pd.DataFrame(result["data"])
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") 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 filepath = CACHE_DIR / filename
CACHE_DIR.mkdir(parents=True, exist_ok=True) CACHE_DIR.mkdir(parents=True, exist_ok=True)
try: try:
if file_format == "csv": 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" media_type = "text/csv"
else: 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" media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
except Exception: except Exception:
_remove_file(filepath) _remove_file(filepath)
+8
View File
@@ -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:优化数据表导出临时文件清理 ## 2026-05-19:优化数据表导出临时文件清理
- `app/api/routers/database.py` 的 `/api/download` 导出接口增加格式校验,只允许 `csv` 和 `xlsx`。 - `app/api/routers/database.py` 的 `/api/download` 导出接口增加格式校验,只允许 `csv` 和 `xlsx`。
+1 -1
View File
@@ -113,7 +113,7 @@ export async function download(url: string, body: unknown, filename: string): Pr
throw new Error(await readError(response)); 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> { export async function downloadGet(url: string, fallbackFilename: string): Promise<void> {
+167 -39
View File
@@ -74,22 +74,6 @@
</div> </div>
<n-space size="small" class="toolbar-actions"> <n-space size="small" class="toolbar-actions">
<n-button size="small" tertiary :loading="loadingData" @click="reloadTable">刷新</n-button> <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="warning" @click="confirmTruncate">清空</n-button>
<n-button size="small" tertiary type="error" @click="confirmDrop">删除</n-button> <n-button size="small" tertiary type="error" @click="confirmDrop">删除</n-button>
</n-space> </n-space>
@@ -161,15 +145,72 @@
</div> </div>
</section> </section>
</div> </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> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onMounted, ref } from 'vue'; import { computed, onActivated, onBeforeUnmount, onMounted, ref } from 'vue';
import { onBeforeRouteLeave } from 'vue-router'; import { onBeforeRouteLeave } from 'vue-router';
import { useDialog, useMessage } from 'naive-ui'; import { useDialog, useMessage } from 'naive-ui';
import type { DataTableColumns, DropdownOption } from 'naive-ui'; import type { DataTableColumns } from 'naive-ui';
import { import {
ChevronDownOutline,
ChevronForwardOutline, ChevronForwardOutline,
CloudDownloadOutline, CloudDownloadOutline,
RefreshOutline, RefreshOutline,
@@ -198,18 +239,18 @@ const loadingTables = ref(false);
const loadingData = ref(false); const loadingData = ref(false);
const testing = ref(false); const testing = ref(false);
const downloadingFormat = ref<DownloadFormat | null>(null); 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); const columnSchemaExpanded = ref(false);
let tableLoadToken = 0; let tableLoadToken = 0;
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value))); const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)));
const downloading = computed(() => Boolean(downloadingFormat.value)); const downloading = computed(() => Boolean(downloadingFormat.value));
const downloadButtonText = computed(() => ( const csvExportButtonText = computed(() => (downloadingFormat.value === 'csv' ? '导出 CSV...' : '导出 CSV'));
downloadingFormat.value ? `导出 ${downloadingFormat.value.toUpperCase()}...` : '导出' const xlsxExportButtonText = computed(() => (downloadingFormat.value === 'xlsx' ? '导出 XLSX...' : '导出 XLSX'));
)); const exportDisabled = computed(() => loadingTables.value || tables.value.length === 0 || downloading.value);
const downloadOptions: DropdownOption[] = [
{ label: 'CSV', key: 'csv' },
{ label: 'XLSX', key: 'xlsx' }
];
const loadDataInfileText = computed(() => { const loadDataInfileText = computed(() => {
if (testing.value || !databaseInfo.value) return '检测中'; if (testing.value || !databaseInfo.value) return '检测中';
return databaseInfo.value.load_data_infile ? '可用' : '未启用'; return databaseInfo.value.load_data_infile ? '可用' : '未启用';
@@ -239,6 +280,26 @@ const columns = computed<DataTableColumns<RowData>>(() => {
onMounted(() => { onMounted(() => {
setPageHeader({ setPageHeader({
actions: [ 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', key: 'drop-all-tables',
label: '删除全部表', label: '删除全部表',
@@ -373,13 +434,63 @@ function changePageSize(value: number) {
void loadTableData(); void loadTableData();
} }
async function downloadTable(format: DownloadFormat) { async function showCsvExportDialog() {
if (!selectedTable.value) return; 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; if (downloadingFormat.value) return;
downloadingFormat.value = format; downloadingFormat.value = 'csv';
try { 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) { } catch (error) {
message.error(error instanceof Error ? error.message : '导出失败'); message.error(error instanceof Error ? error.message : '导出失败');
} finally { } finally {
@@ -709,16 +820,6 @@ function formatCell(value: unknown): string {
flex: 0 0 auto; flex: 0 0 auto;
} }
.download-button-content {
display: inline-flex;
align-items: center;
gap: 4px;
}
.download-caret {
font-size: 13px;
}
.database-data-body { .database-data-body {
display: flex; display: flex;
flex: 1; flex: 1;
@@ -833,6 +934,33 @@ function formatCell(value: unknown): string {
font-size: 13px; 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) { @media (max-width: 1180px) {
.database-workspace { .database-workspace {
flex-direction: column; flex-direction: column;