feat: 数据管理支持切换 CellData 数据库

This commit is contained in:
2026-06-25 02:32:08 +08:00
parent 758741464d
commit 4f90000c36
6 changed files with 182 additions and 42 deletions
+30 -3
View File
@@ -1,13 +1,13 @@
{ {
"Update": "2026/06/24 23:16:46", "Update": "2026/06/25 02:26:49",
"SourceType": "sftp", "SourceType": "sftp",
"WarehouseType": "mysql", "WarehouseType": "mysql",
"MySQL_DBInfo": { "MySQL_DBInfo": {
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 3306, "port": 3306,
"user": "root", "user": "root",
"passwd": "123456", "dbname": "CapacityReport",
"dbname": "CapacityReport" "passwd": "123456"
}, },
"Metrix": { "Metrix": {
"base_url": "http://host.docker.internal:8000", "base_url": "http://host.docker.internal:8000",
@@ -204,6 +204,33 @@
}, },
"passwd": "242520" "passwd": "242520"
}, },
"CellData": {
"remote_data": {
"enabled": false,
"protocol": "sftp",
"host": "",
"port": 22,
"user": "",
"remote_dir": "/CellData",
"passive": true,
"timeout": 30,
"auto_delete_source": false,
"auto_scheduler": {
"enabled": false,
"check_interval_hours": 1,
"expected_directories": [],
"week_offset": 0
},
"passwd": ""
},
"mysql": {
"host": "localhost",
"port": 3306,
"user": "root",
"dbname": "celldata",
"passwd": "123456"
}
},
"HistoryRetention": { "HistoryRetention": {
"enabled": true, "enabled": true,
"keep_count": 1 "keep_count": 1
+46 -23
View File
@@ -12,7 +12,7 @@ from app.config import CACHE_DIR
from app.database import DatabaseManager from app.database import DatabaseManager
from app.services.platform import make_client from app.services.platform import make_client
from app.utils.files import remove_file_safely from app.utils.files import remove_file_safely
from app.warehouse import make_warehouse from app.warehouse import make_cell_data_warehouse, make_warehouse
router = APIRouter(tags=["database"]) router = APIRouter(tags=["database"])
@@ -42,6 +42,9 @@ def _make_sheet_name(table_name: str, used_names: set[str]) -> str:
return sheet_name return sheet_name
DatabaseSource = str
def _dataframe_from_table(db: DatabaseManager, table_name: str) -> pd.DataFrame: def _dataframe_from_table(db: DatabaseManager, table_name: str) -> pd.DataFrame:
result = db.query_table(table_name, page=1, page_size=1000000) result = db.query_table(table_name, page=1, page_size=1000000)
table_info = db.get_table_info(table_name) table_info = db.get_table_info(table_name)
@@ -49,21 +52,26 @@ def _dataframe_from_table(db: DatabaseManager, table_name: str) -> pd.DataFrame:
return pd.DataFrame(result["data"], columns=columns) return pd.DataFrame(result["data"], columns=columns)
def _db(): def _db(database_source: DatabaseSource = "main"):
"""Direct MySQL DatabaseManager, or a Metrix-backed warehouse with the same interface.""" """Direct MySQL DatabaseManager, or a Metrix-backed warehouse with the same interface."""
return make_warehouse(state.current_config()) config = state.current_config()
if database_source == "cell_data":
return make_cell_data_warehouse(config)
if database_source != "main":
raise HTTPException(status_code=400, detail="不支持的数据库来源")
return make_warehouse(config)
@router.post("/api/database/test") @router.post("/api/database/test")
def test_database(): def test_database(database_source: DatabaseSource = Body("main", embed=True)):
db = _db() db = _db(database_source)
success, message = db.test_connection() success, message = db.test_connection()
return {"success": success, "message": message} return {"success": success, "message": message}
@router.get("/api/database/info") @router.get("/api/database/info")
def get_database_info(): def get_database_info(database_source: DatabaseSource = "main"):
db = _db() db = _db(database_source)
try: try:
return {"success": True, **db.get_server_info()} return {"success": True, **db.get_server_info()}
except Exception as exc: except Exception as exc:
@@ -72,8 +80,8 @@ def get_database_info():
@router.get("/api/database/tables") @router.get("/api/database/tables")
@router.post("/api/database/tables") @router.post("/api/database/tables")
def get_tables(): def get_tables(database_source: DatabaseSource = Body("main", embed=True)):
db = _db() db = _db(database_source)
try: try:
return {"tables": db.get_tables()} return {"tables": db.get_tables()}
except Exception as exc: except Exception as exc:
@@ -81,8 +89,11 @@ def get_tables():
@router.post("/api/database/table/info") @router.post("/api/database/table/info")
def get_table_info(table_name: str = Body(..., embed=True)): def get_table_info(
db = _db() table_name: str = Body(..., embed=True),
database_source: DatabaseSource = Body("main"),
):
db = _db(database_source)
try: try:
return db.get_table_info(table_name) return db.get_table_info(table_name)
except Exception as exc: except Exception as exc:
@@ -92,12 +103,13 @@ def get_table_info(table_name: str = Body(..., embed=True)):
@router.post("/api/database/table/data") @router.post("/api/database/table/data")
def query_table_data( def query_table_data(
table_name: str = Body(..., embed=True), table_name: str = Body(..., embed=True),
database_source: DatabaseSource = Body("main"),
page: int = Body(1), page: int = Body(1),
page_size: int = Body(50), page_size: int = Body(50),
order_by: Optional[str] = Body(None), order_by: Optional[str] = Body(None),
order_dir: str = Body("ASC"), order_dir: str = Body("ASC"),
): ):
db = _db() db = _db(database_source)
try: try:
return db.query_table(table_name, page, page_size, order_by=order_by, order_dir=order_dir) return db.query_table(table_name, page, page_size, order_by=order_by, order_dir=order_dir)
except Exception as exc: except Exception as exc:
@@ -107,13 +119,14 @@ def query_table_data(
@router.post("/api/database/table/query") @router.post("/api/database/table/query")
def query_table_with_filter( def query_table_with_filter(
table_name: str = Body(..., embed=True), table_name: str = Body(..., embed=True),
database_source: DatabaseSource = Body("main"),
page: int = Body(1), page: int = Body(1),
page_size: int = Body(50), page_size: int = Body(50),
filters: Optional[dict[str, str]] = Body(None), filters: Optional[dict[str, str]] = Body(None),
order_by: Optional[str] = Body(None), order_by: Optional[str] = Body(None),
order_dir: str = Body("ASC"), order_dir: str = Body("ASC"),
): ):
db = _db() db = _db(database_source)
try: try:
return db.query_table( return db.query_table(
table_name, table_name,
@@ -128,8 +141,11 @@ def query_table_with_filter(
@router.post("/api/database/table/truncate") @router.post("/api/database/table/truncate")
def truncate_table(table_name: str = Body(..., embed=True)): def truncate_table(
db = _db() table_name: str = Body(..., embed=True),
database_source: DatabaseSource = Body("main"),
):
db = _db(database_source)
try: try:
db.truncate_table(table_name) db.truncate_table(table_name)
return {"success": True, "message": f"表 {table_name} 已清空"} return {"success": True, "message": f"表 {table_name} 已清空"}
@@ -138,8 +154,11 @@ def truncate_table(table_name: str = Body(..., embed=True)):
@router.post("/api/database/table/drop") @router.post("/api/database/table/drop")
def drop_table(table_name: str = Body(..., embed=True)): def drop_table(
db = _db() table_name: str = Body(..., embed=True),
database_source: DatabaseSource = Body("main"),
):
db = _db(database_source)
try: try:
db.drop_table(table_name) db.drop_table(table_name)
return {"success": True, "message": f"表 {table_name} 已删除"} return {"success": True, "message": f"表 {table_name} 已删除"}
@@ -148,8 +167,8 @@ def drop_table(table_name: str = Body(..., embed=True)):
@router.post("/api/database/table/drop-all") @router.post("/api/database/table/drop-all")
def drop_all_tables(): def drop_all_tables(database_source: DatabaseSource = Body("main", embed=True)):
db = _db() db = _db(database_source)
try: try:
result = db.drop_all_tables() result = db.drop_all_tables()
return { return {
@@ -163,8 +182,11 @@ def drop_all_tables():
@router.post("/api/database/execute") @router.post("/api/database/execute")
def execute_sql(sql: str = Body(..., embed=True)): def execute_sql(
db = _db() sql: str = Body(..., embed=True),
database_source: DatabaseSource = Body("main"),
):
db = _db(database_source)
try: try:
success, result = db.execute_sql(sql) success, result = db.execute_sql(sql)
if success: if success:
@@ -182,6 +204,7 @@ def execute_sql(sql: str = Body(..., embed=True)):
def download_table( def download_table(
table_name: Optional[str] = Body(None, embed=True), table_name: Optional[str] = Body(None, embed=True),
table_names: Optional[list[str]] = Body(None, embed=True), table_names: Optional[list[str]] = Body(None, embed=True),
database_source: DatabaseSource = Body("main"),
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"}:
@@ -194,10 +217,10 @@ def download_table(
raise HTTPException(status_code=400, detail="CSV 每次只能导出一张表") raise HTTPException(status_code=400, detail="CSV 每次只能导出一张表")
config = state.current_config() config = state.current_config()
if config.warehouse_type == "metrix": if database_source == "main" and config.warehouse_type == "metrix":
return _download_via_metrix(config, requested_tables, file_format) return _download_via_metrix(config, requested_tables, file_format)
db = _db() db = _db(database_source)
try: try:
available_tables = set(db.get_tables()) available_tables = set(db.get_tables())
missing_tables = [name for name in requested_tables if name not in available_tables] missing_tables = [name for name in requested_tables if name not in available_tables]
+5 -4
View File
@@ -5,14 +5,15 @@ 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
from app.config import AppConfig from app.config import AppConfig, MySQLConfig
class DatabaseManager: class DatabaseManager:
"""数据库管理器""" """数据库管理器"""
def __init__(self, config: AppConfig): def __init__(self, config: AppConfig, mysql_config: MySQLConfig | None = None):
self.config = config self.config = config
self.mysql_config = (mysql_config or config.mysql).normalized()
@contextmanager @contextmanager
def get_connection(self): def get_connection(self):
@@ -26,7 +27,7 @@ class DatabaseManager:
如果需要高性能的短连接操作,请使用 engine 属性(连接池) 如果需要高性能的短连接操作,请使用 engine 属性(连接池)
""" """
mysql = self.config.mysql mysql = self.mysql_config
conn = pymysql.connect( conn = pymysql.connect(
host=mysql.host, host=mysql.host,
port=mysql.port, port=mysql.port,
@@ -46,7 +47,7 @@ class DatabaseManager:
@contextmanager @contextmanager
def get_fast_connection(self): def get_fast_connection(self):
"""获取高性能 PyMySQL 连接(用于批量插入)""" """获取高性能 PyMySQL 连接(用于批量插入)"""
mysql = self.config.mysql mysql = self.mysql_config
conn = pymysql.connect( conn = pymysql.connect(
host=mysql.host, host=mysql.host,
port=mysql.port, port=mysql.port,
+4
View File
@@ -22,6 +22,10 @@ def make_warehouse(config: AppConfig):
return DatabaseManager(config) return DatabaseManager(config)
def make_cell_data_warehouse(config: AppConfig):
return DatabaseManager(config, config.cell_data.normalized().mysql)
def _quote_ident(name: str) -> str: def _quote_ident(name: str) -> str:
return "`" + str(name).replace("`", "``") + "`" return "`" + str(name).replace("`", "``") + "`"
+7
View File
@@ -717,3 +717,10 @@
- 设置页说明文案去掉开发实现细节,只保留用户填写配置所需的短提示:主数据源/仓库、Metrix 连接、CellData 数据库/远程源、远程数据源、自动调度、目录映射、Sheet 过滤和字段映射等位置均已压缩。 - 设置页说明文案去掉开发实现细节,只保留用户填写配置所需的短提示:主数据源/仓库、Metrix 连接、CellData 数据库/远程源、远程数据源、自动调度、目录映射、Sheet 过滤和字段映射等位置均已压缩。
- 历史删除确认中的“缓存文件”改为“相关文件”,避免把内部存储实现暴露给用户。 - 历史删除确认中的“缓存文件”改为“相关文件”,避免把内部存储实现暴露给用户。
- 验证:`frontend` `npm run build` 通过;构建产物已清理。 - 验证:`frontend` `npm run build` 通过;构建产物已清理。
## 2026-06-25:数据管理支持切换主数据库与 CellData 数据库
- 数据管理页左侧表列表新增数据库选择,可在主数据库与 CellData 数据库之间切换;选项名称跟随系统设置里的主仓库库名和 `CellData.MySQL_DBInfo.dbname`。
- 后端数据库接口新增 `database_source` 参数,`main` 保持原有直连 MySQL / Metrix 仓库逻辑,`cell_data` 使用 `CellData.MySQL_DBInfo` 创建独立 MySQL 仓库。表列表、表结构、分页数据、清空、删除、删除全部、执行 SQL 和导出均按该参数选择数据库。
- CellData 数据库当前只支持直连 MySQL 配置;Metrix 仓库模式只影响主数据库。
- 验证:`python -m compileall -q app` 通过;`frontend` `npm run build` 通过(仅既有大 chunk 提示);构建产物与 Python 缓存已清理。
+89 -11
View File
@@ -3,7 +3,16 @@
<aside class="database-sidebar-pane"> <aside class="database-sidebar-pane">
<section class="database-table-pane"> <section class="database-table-pane">
<div class="database-table-pane-header"> <div class="database-table-pane-header">
<div class="database-source-header">
<h2>表</h2> <h2>表</h2>
<n-select
v-model:value="selectedDatabaseSource"
size="small"
class="database-source-select"
:options="databaseSourceOptions"
@update:value="switchDatabaseSource"
/>
</div>
<n-button <n-button
quaternary quaternary
circle circle
@@ -221,7 +230,7 @@
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, DropdownOption, SelectOption } from 'naive-ui';
import { import {
ChevronForwardOutline, ChevronForwardOutline,
CloudDownloadOutline, CloudDownloadOutline,
@@ -230,17 +239,23 @@ import {
} from '@vicons/ionicons5'; } from '@vicons/ionicons5';
import { apiGet, apiPost, download } from '../api/client'; import { apiGet, apiPost, download } from '../api/client';
import type { ApiMessage, 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';
type RowData = Record<string, unknown>; type RowData = Record<string, unknown>;
type DownloadFormat = 'csv' | 'xlsx'; type DownloadFormat = 'csv' | 'xlsx';
type DatabaseSource = 'main' | 'cell_data';
const COLUMN_MIN_WIDTH = 140; const COLUMN_MIN_WIDTH = 140;
const message = useMessage(); const message = useMessage();
const dialog = useDialog(); const dialog = useDialog();
const databaseInfo = ref<DatabaseInfo | null>(null); const databaseInfo = ref<DatabaseInfo | null>(null);
const selectedDatabaseSource = ref<DatabaseSource>('main');
const databaseSourceOptions = ref<SelectOption[]>([
{ label: '主数据库', value: 'main' },
{ label: 'CellData', value: 'cell_data' }
]);
const tables = ref<string[]>([]); const tables = ref<string[]>([]);
const selectedTable = ref<string | null>(null); const selectedTable = ref<string | null>(null);
const tableInfo = ref<TableInfo | null>(null); const tableInfo = ref<TableInfo | null>(null);
@@ -320,6 +335,7 @@ onMounted(() => {
} }
] ]
}); });
void loadDatabaseSourceOptions();
void testConnection(); void testConnection();
void loadTables(); void loadTables();
}); });
@@ -339,11 +355,13 @@ onBeforeUnmount(() => {
async function testConnection() { async function testConnection() {
testing.value = true; testing.value = true;
try { try {
const result = await apiPost<ApiMessage>('/api/database/test'); const result = await apiPost<ApiMessage>('/api/database/test', {
database_source: selectedDatabaseSource.value
});
if (!result.success) { if (!result.success) {
message.error(result.message || '数据库连接测试失败'); message.error(result.message || '数据库连接测试失败');
} }
databaseInfo.value = await apiGet<DatabaseInfo>('/api/database/info'); databaseInfo.value = await apiGet<DatabaseInfo>(`/api/database/info?database_source=${selectedDatabaseSource.value}`);
} catch (error) { } catch (error) {
message.error(error instanceof Error ? error.message : '数据库连接测试失败'); message.error(error instanceof Error ? error.message : '数据库连接测试失败');
} finally { } finally {
@@ -351,13 +369,34 @@ async function testConnection() {
} }
} }
async function loadDatabaseSourceOptions() {
try {
const config = await apiGet<AppConfig>('/api/config');
const mainName = config.warehouse_type === 'metrix'
? (config.metrix.target_database || 'Metrix')
: (config.mysql.dbname || '主数据库');
const cellName = config.cell_data?.mysql?.dbname || 'CellData';
databaseSourceOptions.value = [
{ label: `主数据库:${mainName}`, value: 'main' },
{ label: `CellData:${cellName}`, value: 'cell_data' }
];
} catch {
databaseSourceOptions.value = [
{ label: '主数据库', value: 'main' },
{ label: 'CellData', value: 'cell_data' }
];
}
}
async function loadTables() { async function loadTables() {
if (loadingTables.value) return; if (loadingTables.value) return;
const currentToken = ++tableLoadToken; const currentToken = ++tableLoadToken;
loadingTables.value = true; loadingTables.value = true;
try { try {
const result = await apiGet<{ tables: string[] }>('/api/database/tables'); const result = await apiPost<{ tables: string[] }>('/api/database/tables', {
database_source: selectedDatabaseSource.value
});
if (currentToken !== tableLoadToken) return; if (currentToken !== tableLoadToken) return;
tables.value = result.tables; tables.value = result.tables;
if (selectedTable.value && !tables.value.includes(selectedTable.value)) { if (selectedTable.value && !tables.value.includes(selectedTable.value)) {
@@ -377,6 +416,13 @@ async function loadTables() {
} }
} }
function switchDatabaseSource(value: string) {
selectedDatabaseSource.value = value === 'cell_data' ? 'cell_data' : 'main';
resetTableState();
void testConnection();
void loadTables();
}
function resetTableState() { function resetTableState() {
tableLoadToken += 1; tableLoadToken += 1;
tables.value = []; tables.value = [];
@@ -404,7 +450,10 @@ async function reloadTable() {
async function loadTableInfo(table: string) { async function loadTableInfo(table: string) {
try { try {
tableInfo.value = await apiPost<TableInfo>('/api/database/table/info', { table_name: table }); tableInfo.value = await apiPost<TableInfo>('/api/database/table/info', {
table_name: table,
database_source: selectedDatabaseSource.value
});
} catch (error) { } catch (error) {
message.error(error instanceof Error ? error.message : '加载表结构失败'); message.error(error instanceof Error ? error.message : '加载表结构失败');
} }
@@ -416,6 +465,7 @@ async function loadTableData() {
try { try {
const result = await apiPost<TableData>('/api/database/table/data', { const result = await apiPost<TableData>('/api/database/table/data', {
table_name: selectedTable.value, table_name: selectedTable.value,
database_source: selectedDatabaseSource.value,
page: page.value, page: page.value,
page_size: pageSize.value page_size: pageSize.value
}); });
@@ -489,7 +539,11 @@ async function downloadSelectedCsv() {
try { try {
const result = await download( const result = await download(
'/api/download', '/api/download',
{ table_name: selectedCsvTable.value, format: 'csv' }, {
table_name: selectedCsvTable.value,
database_source: selectedDatabaseSource.value,
format: 'csv'
},
filename filename
); );
if (result.saved) { if (result.saved) {
@@ -512,7 +566,11 @@ async function downloadSelectedXlsx() {
try { try {
const result = await download( const result = await download(
'/api/download', '/api/download',
{ table_names: selectedXlsxTables.value, format: 'xlsx' }, {
table_names: selectedXlsxTables.value,
database_source: selectedDatabaseSource.value,
format: 'xlsx'
},
filename filename
); );
if (result.saved) { if (result.saved) {
@@ -540,7 +598,10 @@ function confirmTruncate() {
async function truncateTable() { async function truncateTable() {
if (!selectedTable.value) return; if (!selectedTable.value) return;
try { try {
await apiPost('/api/database/table/truncate', { table_name: selectedTable.value }); await apiPost('/api/database/table/truncate', {
table_name: selectedTable.value,
database_source: selectedDatabaseSource.value
});
message.success('数据表已清空'); message.success('数据表已清空');
await reloadTable(); await reloadTable();
} catch (error) { } catch (error) {
@@ -562,7 +623,10 @@ function confirmDrop() {
async function dropTable() { async function dropTable() {
if (!selectedTable.value) return; if (!selectedTable.value) return;
try { try {
await apiPost('/api/database/table/drop', { table_name: selectedTable.value }); await apiPost('/api/database/table/drop', {
table_name: selectedTable.value,
database_source: selectedDatabaseSource.value
});
message.success('数据表已删除'); message.success('数据表已删除');
selectedTable.value = null; selectedTable.value = null;
tableInfo.value = null; tableInfo.value = null;
@@ -588,7 +652,9 @@ function confirmDropAll() {
async function dropAllTables() { async function dropAllTables() {
try { try {
const result = await apiPost<ApiMessage & { dropped_count?: number }>('/api/database/table/drop-all'); const result = await apiPost<ApiMessage & { dropped_count?: number }>('/api/database/table/drop-all', {
database_source: selectedDatabaseSource.value
});
message.success(result.message || `已删除 ${result.dropped_count || 0} 张表`); message.success(result.message || `已删除 ${result.dropped_count || 0} 张表`);
selectedTable.value = null; selectedTable.value = null;
tableInfo.value = null; tableInfo.value = null;
@@ -658,6 +724,18 @@ function formatCell(value: unknown): string {
line-height: 22px; line-height: 22px;
} }
.database-source-header {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 8px;
}
.database-source-select {
width: 150px;
}
.database-table-list-wrap { .database-table-list-wrap {
display: flex; display: flex;
flex: 1; flex: 1;