feat: 数据管理列宽可调/行编辑删除/模板导入,CellData 处理日志细化

- 数据表列宽可拖拽 + 新增「操作」列(编辑/删除该行)
- 工具栏新增「模板」「导入」:CSV 按模板字段校验,不一致则导入失败
- 后端 DatabaseManager/MetrixWarehouse 增加行更新/删除/CSV 导入 + 对应路由
- CellData 解压/解析/写库日志细化(逐文件、分批进度)
- 修复 ScriptPanel monaco mysql 深层导入缺类型声明导致的 vue-tsc 构建失败
This commit is contained in:
2026-06-26 05:32:06 +08:00
parent 639bae694d
commit f0bbc77763
7 changed files with 543 additions and 15 deletions
+126 -1
View File
@@ -1,9 +1,11 @@
import csv
import re import re
from datetime import datetime from datetime import datetime
from pathlib import Path
from typing import Optional from typing import Optional
import pandas as pd import pandas as pd
from fastapi import APIRouter, Body, HTTPException from fastapi import APIRouter, Body, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from starlette.background import BackgroundTask from starlette.background import BackgroundTask
@@ -181,6 +183,129 @@ def drop_all_tables(database_source: DatabaseSource = Body("main", embed=True)):
raise HTTPException(status_code=500, detail=str(exc)) from exc raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.post("/api/database/table/row/update")
def update_table_row(
table_name: str = Body(..., embed=True),
database_source: DatabaseSource = Body("main"),
identifier: dict = Body(...),
values: dict = Body(...),
):
if not identifier:
raise HTTPException(status_code=400, detail="缺少行定位信息")
db = _db(database_source)
try:
affected = db.update_row(table_name, identifier, values)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
if affected == 0:
raise HTTPException(status_code=404, detail="未找到匹配的数据行,可能已被修改或删除")
return {"success": True, "message": "已更新该行", "affected_rows": affected}
@router.post("/api/database/table/row/delete")
def delete_table_row(
table_name: str = Body(..., embed=True),
database_source: DatabaseSource = Body("main"),
identifier: dict = Body(...),
):
if not identifier:
raise HTTPException(status_code=400, detail="缺少行定位信息")
db = _db(database_source)
try:
affected = db.delete_row(table_name, identifier)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
if affected == 0:
raise HTTPException(status_code=404, detail="未找到匹配的数据行,可能已被删除")
return {"success": True, "message": "已删除该行", "affected_rows": affected}
def _table_columns(db, table_name: str) -> list[str]:
info = db.get_table_info(table_name)
return [str(column["Field"]) for column in info.get("columns", [])]
@router.post("/api/database/table/template")
def download_table_template(
table_name: str = Body(..., embed=True),
database_source: DatabaseSource = Body("main"),
):
db = _db(database_source)
try:
columns = _table_columns(db, table_name)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
if not columns:
raise HTTPException(status_code=400, detail="无法获取表字段,无法生成模板")
CACHE_DIR.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filepath = CACHE_DIR / f"{table_name}_template_{timestamp}.csv"
try:
with filepath.open("w", encoding="utf-8-sig", newline="") as handle:
csv.writer(handle).writerow(columns)
except Exception:
remove_file_safely(filepath)
raise
return FileResponse(
path=str(filepath),
filename=f"{table_name}_模板.csv",
media_type="text/csv",
background=BackgroundTask(remove_file_safely, filepath),
)
def _read_csv_header(path: Path) -> list[str]:
with path.open("r", encoding="utf-8-sig", newline="") as handle:
for row in csv.reader(handle):
return [str(cell).strip() for cell in row]
return []
# Sync def so FastAPI runs it in a threadpool: file read + DB insert are blocking.
@router.post("/api/database/table/import")
def import_table_csv(
file: UploadFile = File(...),
table_name: str = Form(...),
database_source: str = Form("main"),
):
if not file.filename or not file.filename.lower().endswith(".csv"):
raise HTTPException(status_code=400, detail="仅支持 CSV 格式文件")
db = _db(database_source)
try:
columns = _table_columns(db, table_name)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
if not columns:
raise HTTPException(status_code=400, detail="无法获取表字段")
CACHE_DIR.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
tmp_path = CACHE_DIR / f"import_{timestamp}.csv"
tmp_path.write_bytes(file.file.read())
try:
header = _read_csv_header(tmp_path)
if not header:
raise HTTPException(status_code=400, detail="CSV 文件为空或缺少表头")
missing = [name for name in columns if name not in header]
extra = [name for name in header if name not in columns]
if missing or extra:
parts = []
if missing:
parts.append("缺少字段: " + ", ".join(missing))
if extra:
parts.append("多余字段: " + ", ".join(extra))
raise HTTPException(status_code=400, detail="CSV 字段与模板不一致,导入失败。" + ";".join(parts))
imported = db.import_csv(str(tmp_path), table_name)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
finally:
remove_file_safely(tmp_path)
return {"success": True, "message": f"导入成功,共 {imported} 行", "imported_rows": imported}
@router.post("/api/database/execute") @router.post("/api/database/execute")
def execute_sql( def execute_sql(
sql: str = Body(..., embed=True), sql: str = Body(..., embed=True),
+57
View File
@@ -1,6 +1,7 @@
""" """
数据库连接与操作模块 数据库连接与操作模块
""" """
import csv
import pymysql import pymysql
from contextlib import contextmanager from contextlib import contextmanager
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
@@ -199,6 +200,62 @@ class DatabaseManager:
"total_pages": (total + page_size - 1) // page_size "total_pages": (total + page_size - 1) // page_size
} }
def _row_conditions(self, identifier: Dict[str, Any]) -> Tuple[str, List[Any]]:
"""按整行原值构造 WHERE:NULL 用 IS NULL;调用方再加 LIMIT 1 只影响单行。"""
parts: List[str] = []
params: List[Any] = []
for col, val in identifier.items():
if val is None:
parts.append(f"`{col}` IS NULL")
else:
parts.append(f"`{col}` = %s")
params.append(val)
return (" AND ".join(parts) if parts else "1 = 0"), params
def update_row(self, table_name: str, identifier: Dict[str, Any], values: Dict[str, Any]) -> int:
"""更新单行:按 identifier(原始整行) 定位、LIMIT 1,避免影响重复行。返回影响行数。"""
if not values:
return 0
set_clause = ", ".join(f"`{col}` = %s" for col in values)
set_params = list(values.values())
where_clause, where_params = self._row_conditions(identifier)
sql = f"UPDATE `{table_name}` SET {set_clause} WHERE {where_clause} LIMIT 1"
with self.get_connection() as conn:
with conn.cursor() as cursor:
cursor.execute(sql, set_params + where_params)
conn.commit()
return cursor.rowcount
def delete_row(self, table_name: str, identifier: Dict[str, Any]) -> int:
"""删除单行:按 identifier(原始整行) 定位、LIMIT 1。返回影响行数。"""
where_clause, where_params = self._row_conditions(identifier)
sql = f"DELETE FROM `{table_name}` WHERE {where_clause} LIMIT 1"
with self.get_connection() as conn:
with conn.cursor() as cursor:
cursor.execute(sql, where_params)
conn.commit()
return cursor.rowcount
def import_csv(self, file_path: str, table_name: str) -> int:
"""按 CSV 表头列追加导入(列须与表字段一致,由调用方校验)。返回导入行数。"""
with open(file_path, "r", encoding="utf-8-sig", newline="") as handle:
reader = csv.reader(handle)
try:
header = next(reader)
except StopIteration:
return 0
columns = [str(name).strip() for name in header]
width = len(columns)
data: List[Tuple] = []
for row in reader:
if not any(str(cell).strip() for cell in row):
continue
cells = list(row[:width]) + [""] * (width - len(row))
data.append(tuple(cells))
if not data:
return 0
return self.bulk_insert(table_name, columns, data)
def truncate_table(self, table_name: str) -> bool: def truncate_table(self, table_name: str) -> bool:
"""清空表""" """清空表"""
with self.get_connection() as conn: with self.get_connection() as conn:
+35 -6
View File
@@ -253,20 +253,28 @@ class CellDataProcessor:
def _parse_zip_files(self, local_files: list[tuple[SelectedZip, Path]], result: CellDataResult) -> list[dict[str, str]]: def _parse_zip_files(self, local_files: list[tuple[SelectedZip, Path]], result: CellDataResult) -> list[dict[str, str]]:
mapping = self.config.mapping mapping = self.config.mapping
key_config = mapping["key"] key_config = mapping["key"]
key_field = str(key_config["field"])
rows_by_key: dict[str, dict[str, str]] = {} rows_by_key: dict[str, dict[str, str]] = {}
self._log(f"开始解压并解析 {len(local_files)} 个 ZIP 文件...")
for selected, local_path in local_files: for selected, local_path in local_files:
band_label = selected.band or "未识别频段"
size_kb = local_path.stat().st_size / 1024 if local_path.exists() else 0
self._log(f"[{band_label}] 解压 {local_path.name}({size_kb:.0f} KB)...")
zip_parsed_before = result.parsed_rows
zip_skipped_before = result.skipped_rows
with zipfile.ZipFile(local_path) as zf: with zipfile.ZipFile(local_path) as zf:
sources = list(mapping["sources"]) sources = list(mapping["sources"])
for info in zf.infolist(): csv_entries = [info for info in zf.infolist() if Path(info.filename).name.lower().endswith(".csv")]
self._log(f" 压缩包内含 {len(csv_entries)} 个 CSV 文件")
for info in csv_entries:
name = Path(info.filename).name name = Path(info.filename).name
if not name.lower().endswith(".csv"):
continue
matching_sources = [ matching_sources = [
source source
for source in sources for source in sources
if name.startswith(source["file_prefix"]) and (not selected.band or source["band"] == selected.band) if name.startswith(source["file_prefix"]) and (not selected.band or source["band"] == selected.band)
] ]
if not matching_sources: if not matching_sources:
self._log(f" 跳过未匹配规则的文件: {name}")
continue continue
if not selected.band and len(matching_sources) > 1: if not selected.band and len(matching_sources) > 1:
self._log(f" 跳过无法识别频段的文件: {name}") self._log(f" 跳过无法识别频段的文件: {name}")
@@ -275,15 +283,28 @@ class CellDataProcessor:
raw = zf.read(info.filename) raw = zf.read(info.filename)
text = self._decode_csv(raw) text = self._decode_csv(raw)
reader = csv.DictReader(text.splitlines()) reader = csv.DictReader(text.splitlines())
added = 0
skipped = 0
for csv_row in reader: for csv_row in reader:
row = self._map_row(source["fields"], csv_row) row = self._map_row(source["fields"], csv_row)
key = self._render_expr(str(key_config["expr"]), row) key = self._render_expr(str(key_config["expr"]), row)
if not key or "--" in key: if not key or "--" in key:
result.skipped_rows += 1 result.skipped_rows += 1
skipped += 1
continue continue
row[str(key_config["field"])] = key row[key_field] = key
rows_by_key[key] = {column: row.get(column, "") for column in CELLINFO_COLUMNS} rows_by_key[key] = {column: row.get(column, "") for column in CELLINFO_COLUMNS}
result.parsed_rows += 1 result.parsed_rows += 1
added += 1
self._log(f" 解析 {name}(频段 {source.get('band', '') or '通用'}):有效 {added} 行,跳过 {skipped} 行")
self._log(
f"[{band_label}] {local_path.name} 解析完成:"
f"本包有效 {result.parsed_rows - zip_parsed_before} 行,跳过 {result.skipped_rows - zip_skipped_before} 行"
)
self._log(
f"全部解析完成:累计有效 {result.parsed_rows} 行,按 {key_field} 去重后 {len(rows_by_key)} 行,"
f"累计跳过 {result.skipped_rows} 行"
)
return list(rows_by_key.values()) return list(rows_by_key.values())
def _map_row(self, fields: dict[str, Any], csv_row: dict[str, str]) -> dict[str, str]: def _map_row(self, fields: dict[str, Any], csv_row: dict[str, str]) -> dict[str, str]:
@@ -320,6 +341,7 @@ class CellDataProcessor:
def _replace_cellinfo(self, rows: list[dict[str, str]]) -> int: def _replace_cellinfo(self, rows: list[dict[str, str]]) -> int:
mysql = self.config.mysql.normalized() mysql = self.config.mysql.normalized()
table = str(self.config.mapping.get("target_table") or "cellinfo") table = str(self.config.mapping.get("target_table") or "cellinfo")
self._log(f"准备写入表 `{table}`(库 {mysql.dbname}@{mysql.host}:{mysql.port}),共 {len(rows)} 行")
conn = pymysql.connect( conn = pymysql.connect(
host=mysql.host, host=mysql.host,
port=mysql.port, port=mysql.port,
@@ -330,22 +352,29 @@ class CellDataProcessor:
cursorclass=pymysql.cursors.DictCursor, cursorclass=pymysql.cursors.DictCursor,
autocommit=False, autocommit=False,
) )
self._log("已连接 CellData 数据库")
try: try:
with conn.cursor() as cursor: with conn.cursor() as cursor:
self._ensure_cellinfo_table(cursor, table) self._ensure_cellinfo_table(cursor, table)
self._log(f"已确认表结构 `{table}`")
cursor.execute(f"TRUNCATE TABLE `{table}`") cursor.execute(f"TRUNCATE TABLE `{table}`")
self._log(f"已清空表 `{table}`(TRUNCATE)")
placeholders = ", ".join(["%s"] * len(CELLINFO_COLUMNS)) placeholders = ", ".join(["%s"] * len(CELLINFO_COLUMNS))
columns = ", ".join(f"`{column}`" for column in CELLINFO_COLUMNS) columns = ", ".join(f"`{column}`" for column in CELLINFO_COLUMNS)
values = [tuple(row.get(column, "") for column in CELLINFO_COLUMNS) for row in rows] values = [tuple(row.get(column, "") for column in CELLINFO_COLUMNS) for row in rows]
for start in range(0, len(values), 1000): total = len(values)
for start in range(0, total, 1000):
cursor.executemany( cursor.executemany(
f"INSERT INTO `{table}` ({columns}) VALUES ({placeholders})", f"INSERT INTO `{table}` ({columns}) VALUES ({placeholders})",
values[start:start + 1000], values[start:start + 1000],
) )
self._log(f"写入中 {min(start + 1000, total)}/{total} 行...")
conn.commit() conn.commit()
self._log(f"已提交,成功写入 {len(rows)} 行到 `{table}`")
return len(rows) return len(rows)
except Exception: except Exception as exc:
conn.rollback() conn.rollback()
self._log(f"写入失败,已回滚: {exc}")
raise raise
finally: finally:
conn.close() conn.close()
+40
View File
@@ -9,6 +9,7 @@ query_table / truncate_table / drop_table / drop_all_tables / execute_sql。
""" """
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from app.config import AppConfig from app.config import AppConfig
@@ -34,6 +35,10 @@ def _quote_value(value: str) -> str:
return "'" + str(value).replace("\\", "\\\\").replace("'", "''") + "'" return "'" + str(value).replace("\\", "\\\\").replace("'", "''") + "'"
def _sql_literal(value: Any) -> str:
return "NULL" if value is None else _quote_value(str(value))
class MetrixWarehouse: class MetrixWarehouse:
"""用 Metrix 数据库 API 实现 DatabaseManager 的只读/管理子集。""" """用 Metrix 数据库 API 实现 DatabaseManager 的只读/管理子集。"""
@@ -153,6 +158,41 @@ class MetrixWarehouse:
self._run(drop_sql) self._run(drop_sql)
return {"success": True, "dropped_count": len(tables), "tables": tables} return {"success": True, "dropped_count": len(tables), "tables": tables}
def _row_where(self, identifier: Dict[str, Any]) -> str:
parts = [
f"{_quote_ident(col)} IS NULL" if val is None else f"{_quote_ident(col)} = {_sql_literal(val)}"
for col, val in identifier.items()
]
return " AND ".join(parts) if parts else "1 = 0"
def update_row(self, table_name: str, identifier: Dict[str, Any], values: Dict[str, Any]) -> int:
if not values:
return 0
set_clause = ", ".join(f"{_quote_ident(col)} = {_sql_literal(val)}" for col, val in values.items())
sql = f"UPDATE {_quote_ident(table_name)} SET {set_clause} WHERE {self._row_where(identifier)} LIMIT 1"
ok, result = self.execute_sql(sql)
if not ok:
raise RuntimeError(str(result))
return int(result.get("affected_rows", 0)) if isinstance(result, dict) else 0
def delete_row(self, table_name: str, identifier: Dict[str, Any]) -> int:
sql = f"DELETE FROM {_quote_ident(table_name)} WHERE {self._row_where(identifier)} LIMIT 1"
ok, result = self.execute_sql(sql)
if not ok:
raise RuntimeError(str(result))
return int(result.get("affected_rows", 0)) if isinstance(result, dict) else 0
def import_csv(self, file_path: str, table_name: str) -> int:
"""走 Metrix 平台导入 API 追加导入到已存在的表(字段映射由平台按列名处理)。"""
job_id = self.client.import_csv(
self.conn_id, table_name, Path(file_path), mode="append",
database=self.database, create_table=False,
)
job = self.client.wait_job(job_id)
if job.get("status") != "success":
raise RuntimeError(job.get("error_code") or job.get("status") or "导入失败")
return int(job.get("row_count") or 0)
def execute_sql(self, sql: str) -> Tuple[bool, Any]: def execute_sql(self, sql: str) -> Tuple[bool, Any]:
try: try:
res = self.client.run_script(self.conn_id, content=sql, database=self.database, run_timeout=600) res = self.client.run_script(self.conn_id, content=sql, database=self.database, run_timeout=600)
+17
View File
@@ -888,3 +888,20 @@
- 数据源合并:`source_type` 从 `ftp | sftp | metrix` 改为 `external | metrix`;FTP/SFTP 协议选择保留在远程数据源配置的 `RemoteData.protocol` 中,`external` 统一代表外部储存。旧值 `ftp`/`sftp` 自动规范化为 `external`。 - 数据源合并:`source_type` 从 `ftp | sftp | metrix` 改为 `external | metrix`;FTP/SFTP 协议选择保留在远程数据源配置的 `RemoteData.protocol` 中,`external` 统一代表外部储存。旧值 `ftp`/`sftp` 自动规范化为 `external`。
- 全局状态:`metrixEnabled` 通过 `composables/metrixEnabled.ts` 共享响应式状态,`AppShell` 登录后和激活弹窗切换时同步更新。 - 全局状态:`metrixEnabled` 通过 `composables/metrixEnabled.ts` 共享响应式状态,`AppShell` 登录后和激活弹窗切换时同步更新。
- 验证:`python -m compileall -q app` 通过;`frontend` `npm run build`(vue-tsc)通过;构建产物已清理。 - 验证:`python -m compileall -q app` 通过;`frontend` `npm run build`(vue-tsc)通过;构建产物已清理。
## 2026-06-26:数据管理增强(列宽可调 + 行编辑/删除 + 模板/导入)与 CellData 处理日志细化
数据管理(`frontend/src/components/DatabasePanel.vue` + `app/api/routers/database.py` + `app/database.py` + `app/warehouse.py`):
- 数据表列宽可拖拽:`n-data-table` 每列加 `resizable + width`,`scroll-x` 计入新增操作列宽度。
- 新增固定右侧「操作」列:每行「编辑 / 删除」。编辑弹窗按列字段逐项填写后保存;删除二次确认;操作后自动刷新当前页。
- 行定位策略:以「整行原值」作为 WHERE 条件 + `LIMIT 1`(NULL 用 `IS NULL`),无主键表也能精确改/删单行、避免误伤重复行。后端 `DatabaseManager.update_row/delete_row`(参数化)与 `MetrixWarehouse.update_row/delete_row`(经 `execute_sql` 字面量 SQL)。
- 工具栏新增「模板」「导入」(在 刷新/清空/删除 同组):模板下载当前表字段的 CSV 表头(`POST /api/database/table/template`,FileResponse);导入上传 CSV(`POST /api/database/table/import`,multipart,sync def 走线程池)。**导入按模板校验**:CSV 表头必须与表字段完全一致,缺字段/多字段一律 400 失败;通过后追加导入(`DatabaseManager.import_csv` 走 `bulk_insert`;Metrix 走平台 `/import` mode=append、create_table=false)。
- 新接口均支持 `database_source`(main/cell_data) 双库;前端用 `client.upload`(multipart) 导入、`download`(POST) 下模板。
CellData 处理日志细化(`app/services/cell_data.py`):
- `_parse_zip_files`(解压/解析):逐 ZIP 打「解压 {频段}/{文件名}(KB)」+「含 N 个 CSV」,逐 CSV 打「解析 {文件名}(频段):有效 X / 跳过 Y 行」,每包小计,末尾打「累计有效 / 去重后 / 累计跳过」。
- `_replace_cellinfo`(上传/导入):打「准备写入表/库」「已连接」「确认表结构」「清空表(TRUNCATE)」「写入中 written/total 行」分批进度「已提交,成功写入 N 行」,失败回滚也记录。
附带修复:`frontend/src/vite-env.d.ts` 增加 `declare module 'monaco-editor/esm/vs/basic-languages/mysql/mysql.js'`,修复 ScriptPanel SQL 补全的深层导入缺类型声明导致 `vue-tsc`(`npm run build`)失败(TS7016)。
- 验证:`python -m compileall -q app` 通过;`frontend` `npm run build`(vue-tsc)通过(DatabasePanel chunk≈124KB)。
+265 -7
View File
@@ -88,6 +88,8 @@
</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-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="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>
@@ -251,12 +253,60 @@
</button> </button>
</n-scrollbar> </n-scrollbar>
</n-modal> </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> </template>
<script setup lang="ts"> <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 { 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 type { DataTableColumns, DropdownOption } from 'naive-ui';
import { import {
ChevronForwardOutline, ChevronForwardOutline,
@@ -266,7 +316,7 @@ import {
TrashOutline TrashOutline
} from '@vicons/ionicons5'; } 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 type { ApiMessage, AppConfig, DatabaseInfo, TableData, TableInfo } from '../types';
import { showDownloadCompleteDialog } from '../composables/downloadFeedback'; import { showDownloadCompleteDialog } from '../composables/downloadFeedback';
import { resetPageHeader, setPageHeader } from '../composables/pageHeader'; import { resetPageHeader, setPageHeader } from '../composables/pageHeader';
@@ -282,6 +332,7 @@ interface DatabaseSourceOption {
} }
const COLUMN_MIN_WIDTH = 140; const COLUMN_MIN_WIDTH = 140;
const ROW_ACTIONS_WIDTH = 120;
const message = useMessage(); const message = useMessage();
const dialog = useDialog(); const dialog = useDialog();
const databaseInfo = ref<DatabaseInfo | null>(null); const databaseInfo = ref<DatabaseInfo | null>(null);
@@ -307,6 +358,16 @@ const xlsxDialogVisible = ref(false);
const selectedCsvTable = ref(''); const selectedCsvTable = ref('');
const selectedXlsxTables = ref<string[]>([]); const selectedXlsxTables = ref<string[]>([]);
const columnSchemaExpanded = ref(false); 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; 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)));
@@ -335,17 +396,31 @@ const columnKeys = computed(() => (
? Object.keys(rows.value[0]) ? Object.keys(rows.value[0])
: tableInfo.value?.columns.map(column => String(column.Field || '')).filter(Boolean) || [] : 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 columns = computed<DataTableColumns<RowData>>(() => {
const keys = columnKeys.value; const dataColumns: DataTableColumns<RowData> = columnKeys.value.map(key => ({
return keys.map(key => ({
title: key, title: key,
key, key,
width: COLUMN_MIN_WIDTH,
minWidth: COLUMN_MIN_WIDTH, minWidth: COLUMN_MIN_WIDTH,
resizable: true,
ellipsis: { tooltip: true }, ellipsis: { tooltip: true },
render: row => formatCell(row[key]) 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(() => { onMounted(() => {
@@ -713,6 +788,119 @@ function formatCell(value: unknown): string {
if (typeof value === 'object') return JSON.stringify(value); if (typeof value === 'object') return JSON.stringify(value);
return String(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> </script>
<style scoped> <style scoped>
@@ -1145,6 +1333,76 @@ function formatCell(value: unknown): string {
gap: 8px; 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) { @media (max-width: 1180px) {
.database-workspace { .database-workspace {
flex-direction: column; flex-direction: column;
+2
View File
@@ -1 +1,3 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
declare module 'monaco-editor/esm/vs/basic-languages/mysql/mysql.js';