feat: 优化数据表导出选择流程
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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`。
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user