Commit 26704c67 authored by Data Governance Dev's avatar Data Governance Dev

feat(web2): 初始化前端 + 后端完整骨架 + 数据库连接 popover

前端 (Vue 3.5 + Element Plus 2.9 + Vite 6, 端口 5174):
- 脚手架: package.json / vite.config.js / index.html / main.js / App.vue
- 路由 + 布局: router/index.js + layouts/MainLayout.vue + views/IdCardTool.vue
- 状态: stores/useDbConnection.js (响应式单例 + localStorage 持久化)
- UI: components/DbConnectionPopover.vue (数据库名 + 真实 logo + 测试/切换)
- 配置: config/dbPresets.js (后端不通兜底) + config/dbBranding.js (logo/icon/颜色)

后端 (FastAPI, 端口 8766):
- 入口: app.py + _logging.py (idempotent logger + RotatingFileHandler)
- API: routes/db.py (GET /api/db-defaults 读 web/configs/db_defaults.yaml,
  POST /api/connect/test 复用 web.core.db_adapter.test_connection)
- 启动: start.py (依赖检查 + uvicorn) / backend/start.bat

一键启动: web2/start.bat / web2/start.sh 同时拉起前后端两个窗口

数据流:
- 前端启动 → 异步 GET /api/db-defaults → 覆盖 presets (4s 超时 fallback)
- 点击测试 → POST /api/connect/test → 后端复用 web.core.db_adapter

安全策略:
- dbPresets.js 中 password 字段留空(真实密码不入库)
- 运行时由 GET /api/db-defaults 注入;后端不通时测试连接因密码空失败(预期)

配套改动:
- .gitignore: 忽略 web2/logs/ (start 脚本的 console 输出目录)
parent 53f6205d
......@@ -39,3 +39,4 @@ work-logs/
tags.lock
tags.temp
Session.vim
web2/logs/
"""web2 后端包
复用 web/ 的核心模块(db_adapter / models / configs),不重复实现。
"""
"""web2 后端统一日志配置
问题修复:
之前 start.py 和 app.py 都调 logging.basicConfig(...),
后调用的那次因为 root logger 已有 handler 被 basicConfig 幂等吞掉,
导致 web2.backend 的日志只写到 start.log、app.log 完全不更新。
解决:
抽出来一个 setup_logging(),检查 logger 是否有 handler,没有才配。
start.py / app.py 都从这里调 —— 重复调用幂等。
设计原则:
- 所有 web2.backend.* 日志同时写 stdout + app.log
- 不接管 start logger(start.py 自己管)
- 不接管 web.core.* / web.api.* logger(让它们 propagate 到 root,
由 root handler 输出,不重复写)
"""
from __future__ import annotations
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
# ── 路径常量 ──
WEB2_DIR = Path(__file__).resolve().parent.parent
LOG_DIR = WEB2_DIR / "logs"
LOG_DIR.mkdir(parents=True, exist_ok=True)
APP_LOG_FILE = LOG_DIR / "app.log"
# 格式:与之前保持一致(用户已习惯)
_LOG_FORMAT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
_DATE_FORMAT = None # 默认 ISO-ish
def setup_logging(level: str = "INFO") -> None:
"""初始化 web2.backend.* logger 的 handlers。
幂等:重复调用不会重复加 handler。
Args:
level: 日志级别(默认 INFO,可由环境变量 LOG_LEVEL 覆盖)
"""
web2_logger = logging.getLogger("web2.backend")
# 幂等:已经有 handler 就直接返回
if web2_logger.handlers:
return
web2_logger.setLevel(getattr(logging, level.upper(), logging.INFO))
# 不 propagate 到 root(避免重复写到 root 的 start.log)
web2_logger.propagate = False
formatter = logging.Formatter(_LOG_FORMAT, _DATE_FORMAT)
# 文件 handler(10MB 滚动,保留 5 份)
fh = RotatingFileHandler(
APP_LOG_FILE,
maxBytes=10 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
fh.setFormatter(formatter)
web2_logger.addHandler(fh)
# 控制台 handler
ch = logging.StreamHandler()
ch.setFormatter(formatter)
web2_logger.addHandler(ch)
# 顺便确保 root 有基础配置(StreamHandler),让 web.core.* 能输出
root = logging.getLogger()
if not root.handlers:
logging.basicConfig(level=level, format=_LOG_FORMAT, datefmt=_DATE_FORMAT)
def get_logger(name: str) -> logging.Logger:
"""便捷:拿 web2.backend.* 子 logger,自动触发 setup_logging()"""
if not name.startswith("web2."):
name = f"web2.{name}"
return logging.getLogger(name)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""web2 后端入口(FastAPI)
启动方式:
python -m web2.backend.app
# 或
cd 数据治理 && python -m web2.backend.app
环境变量:
WEB2_PORT 端口(默认 8766,避免与 web 的 8765 冲突)
WEB2_HOST 主机(默认 0.0.0.0)
LOG_LEVEL 日志级别(默认 INFO)
设计原则:
- 复用 web/core/db_adapter.py(不重复实现 MySQL/达梦/Oracle 驱动)
- 复用 web/core/models.py 的 ConnectRequest/TestConnectionResponse
- 直接读 web/configs/db_defaults.yaml,不复制粘贴
- 当前阶段只暴露数据库相关 API;治理任务 API 后续按需加
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
# ── 把项目根目录加进 sys.path,以便 import web.* ──
BACKEND_DIR = Path(__file__).resolve().parent
WEB2_DIR = BACKEND_DIR.parent
PROJECT_ROOT = WEB2_DIR.parent
sys.path.insert(0, str(PROJECT_ROOT))
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
# ── 日志(统一从 _logging 走,幂等,重复 import 不会重复加 handler) ──
from web2.backend._logging import setup_logging, get_logger # noqa: E402
setup_logging(os.environ.get("LOG_LEVEL", "INFO"))
logger = get_logger("backend") # → web2.backend
logger.info("=" * 60)
logger.info("web2 后端 · 进程启动")
logger.info(f"Python: {sys.version.split()[0]}, 平台: {sys.platform}")
logger.info(f"工作目录: {PROJECT_ROOT}")
from web2.backend._logging import APP_LOG_FILE # noqa: E402
logger.info(f"应用日志: {APP_LOG_FILE}")
# ── 创建 FastAPI 应用 ──
app = FastAPI(
title="数据治理 Web 工具 · web2 后端",
description="数据库连接管理(MySQL / 达梦 / Oracle)。前端在 web2/,由 Vite 5174 提供;本服务仅提供 API。",
version="0.1.0",
)
logger.info("FastAPI 应用已创建")
# CORS(开发模式:5174 是 Vite dev server)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
logger.debug("CORS 中间件已挂载(允许所有来源)")
# ── 注册路由 ──
from web2.backend.routes.db import router as db_router # noqa: E402
app.include_router(db_router, prefix="/api")
logger.info(f"已注册 {len(app.routes)} 个路由(含 db 路由)")
# ── 健康检查 ──
@app.get("/api/health")
async def health():
return {"status": "ok", "service": "web2-backend"}
# ── 静态资源(生产模式:构建后由 FastAPI 直接托管 dist/) ──
DIST_DIR = WEB2_DIR / "dist"
if DIST_DIR.exists():
# 挂载 assets 目录
app.mount("/assets", StaticFiles(directory=str(DIST_DIR / "assets")), name="assets")
@app.get("/", response_class=HTMLResponse)
async def root():
"""服务入口 HTML(仅当 dist/ 存在时生效;开发模式请用 Vite 5174)"""
index = DIST_DIR / "index.html"
if index.exists():
return HTMLResponse(index.read_text(encoding="utf-8"))
return HTMLResponse(
"<h1>web2 后端 API 服务</h1>"
"<p>开发模式请用 <code>npm run dev</code> 启动 Vite(端口 5174)。</p>"
"<p>API 文档:<a href='/docs'>/docs</a></p>"
)
# ── 启动 ──
def main():
import uvicorn
host = os.environ.get("WEB2_HOST", "0.0.0.0")
port = int(os.environ.get("WEB2_PORT", "8766"))
print("=" * 60)
print(f" web2 后端 · FastAPI")
print(f" API: http://localhost:{port}/api")
print(f" API 文档: http://localhost:{port}/docs")
print(f" 前端 dev: http://localhost:5174 (Vite 单独启动)")
print(f" 📋 应用日志: {LOG_DIR / 'app.log'}")
print("=" * 60)
uvicorn.run("web2.backend.app:app", host=host, port=port, reload=False, log_level="info")
if __name__ == "__main__":
main()
"""web2 后端路由包"""
"""数据库连接相关 API
端点:
GET /api/db-defaults 读取 web/configs/db_defaults.yaml,返回三套预设
POST /api/connect/test 测试连接是否可用(复用 web.core.db_adapter)
实现说明:
- 直接读 web/configs/db_defaults.yaml,不在 web2/ 复制一份(避免漂移)
- 测试连接复用 web.core.db_adapter.DBConfig + test_connection()
- 请求/响应模型复用 web.core.models(与 web 端一致)
日志策略:
- 每个请求开始打一行「分隔条 + 请求摘要」(password 打码)
- 测试连接打耗时(ms)
- 失败时用 logger.exception 拿全栈(不止一行 message)
- 日志同时进 stdout 和 web2/logs/app.log
"""
from __future__ import annotations
import time
from pathlib import Path
import yaml
from fastapi import APIRouter
# 复用 web/ 已有模块(不重复实现)
from web.core.db_adapter import DBConfig, test_connection
from web.core.models import TestConnectionRequest, TestConnectionResponse
from web2.backend._logging import get_logger
logger = get_logger("backend.routes.db") # → web2.backend.routes.db
router = APIRouter(tags=["database"])
# ── 路径常量 ────────────────────────────────────────────────
# web2/backend/routes/db.py → 往上 3 级到项目根 → web/configs/db_defaults.yaml
PROJECT_ROOT = Path(__file__).resolve().parents[3]
WEB_DEFAULTS_YAML = PROJECT_ROOT / "web" / "configs" / "db_defaults.yaml"
# ── 工具函数 ────────────────────────────────────────────────
def _mask_password(conn: dict) -> str:
"""连接信息脱敏:把 password 字段替换成 *** 用于日志输出"""
safe = {k: ("***" if k == "password" else v) for k, v in conn.items()}
return (
f"db_type={safe.get('db_type')} "
f"host={safe.get('host')}:{safe.get('port')} "
f"user={safe.get('user')} "
f"password={safe.get('password')} "
f"database={safe.get('database')} "
f"oracle_client_dir={'<set>' if safe.get('oracle_client_dir') else '<empty>'}"
)
# ── 端点 ────────────────────────────────────────────────────
@router.get("/db-defaults", summary="读取数据库连接预设")
async def db_defaults():
"""读取 web/configs/db_defaults.yaml,返回三套预设(mysql/dameng/oracle)
文件不存在或解析失败时返回空 dict,前端保留 JS 内置兜底值。
"""
logger.info("─" * 60)
logger.info("GET /api/db-defaults")
if not WEB_DEFAULTS_YAML.exists():
logger.warning(f"db_defaults.yaml 缺失: {WEB_DEFAULTS_YAML}")
logger.info("─" * 60)
return {}
try:
raw = yaml.safe_load(WEB_DEFAULTS_YAML.read_text(encoding="utf-8")) or {}
except Exception as e:
logger.exception(f"db_defaults.yaml 解析失败: {e}")
logger.info("─" * 60)
return {}
defaults = raw.get("db_defaults", {})
# 旧结构兼容:如果顶层 db_defaults 的 value 不是 dict,就当成「老格式单套」包成 {mysql: ...}
if not isinstance(defaults, dict) or any(
not isinstance(v, dict) for v in defaults.values()
):
logger.warning("db_defaults.yaml 为旧格式(单套配置),仅 mysql 可用")
logger.info(f"返回 keys: ['mysql'](旧格式兼容)")
logger.info("─" * 60)
return {"mysql": defaults}
# 防御性注入 db_type:yaml 里每个预设只有 host/port/user/password/database,
# 没有 db_type 字段(db_type 是 key 名)。前端有时会拿 conn.db_type 用,
# 后端注入后两边都安全(避免前端 JSON.stringify 漏掉 db_type → FastAPI 422)。
for db_type, preset in defaults.items():
if isinstance(preset, dict):
preset.setdefault("db_type", db_type)
logger.info(
f"返回 keys: {sorted(defaults.keys())}({sum(len(v) for v in defaults.values() if isinstance(v, dict))} 个字段)"
)
logger.info("─" * 60)
return defaults
@router.post("/connect/test", response_model=TestConnectionResponse, summary="测试数据库连接")
async def connect_test(req: TestConnectionRequest):
"""复用 web.core.db_adapter.test_connection,验证 db_type/host/port/user/password/database
返回:
TestConnectionResponse(ok, message, db_type, database_name, tables_count, ...)
失败时诊断信息:
- web2/logs/app.log 里有完整 traceback(用 logger.exception 打的)
- 响应 message 字段带异常摘要(前端弹窗显示)
"""
# ── 请求入口日志 ──
conn = req.model_dump()
logger.info("─" * 60)
logger.info(f"POST /api/connect/test {_mask_password(conn)}")
started = time.monotonic()
cfg = DBConfig(
db_type=req.db_type,
host=req.host,
port=req.port,
user=req.user,
password=req.password,
database=req.database,
charset=req.charset,
connect_timeout=req.connect_timeout,
oracle_client_dir=req.oracle_client_dir,
)
try:
ok, msg = test_connection(cfg)
except Exception as e:
elapsed_ms = (time.monotonic() - started) * 1000
# logger.exception 自动带全栈(不止一行 message)
logger.exception(
f"❌ 测试连接抛出未捕获异常(耗时 {elapsed_ms:.0f}ms): {type(e).__name__}: {e}"
)
logger.info("─" * 60)
return TestConnectionResponse(
ok=False,
message=f"测试连接异常: {type(e).__name__}: {e}",
db_type=req.db_type,
)
elapsed_ms = (time.monotonic() - started) * 1000
# ── 结果日志 ──
if not ok:
logger.warning(f"❌ 连接测试失败(耗时 {elapsed_ms:.0f}ms): {msg}")
# 常见原因分类提示(用户友好)
if "WinError 10061" in msg or "Connection refused" in msg:
logger.warning(
" 💡 诊断: 目标端口未监听(连接被拒)。可能原因:\n"
" 1) 主机/端口写错;\n"
" 2) 数据库服务没启;\n"
" 3) 防火墙拦截;\n"
" 4) 数据库不在 192.168.* 内网段(当前网络不可达)"
)
elif "timed out" in msg.lower() or "timeout" in msg.lower():
logger.warning(
" 💡 诊断: 连接超时。可能原因:\n"
" 1) 主机不可达(IP 错 / 不在同一网段);\n"
" 2) 防火墙丢包;\n"
" 3) 数据库服务卡死但不拒绝连接"
)
elif "Access denied" in msg or "1045" in msg:
logger.warning(" 💡 诊断: 用户名或密码错")
elif "Unknown database" in msg or "1049" in msg:
logger.warning(" 💡 诊断: 数据库名不存在")
logger.info("─" * 60)
return TestConnectionResponse(ok=False, message=msg, db_type=req.db_type)
logger.info(f"✅ 连接测试成功(耗时 {elapsed_ms:.0f}ms): {msg}")
logger.info("─" * 60)
return TestConnectionResponse(
ok=True,
message=msg,
db_type=req.db_type,
database_name=req.database,
)
@echo off
REM ============================================================
REM web2 Backend - Windows launcher
REM (REM lines are ASCII-only to avoid cmd.exe encoding issues)
REM ============================================================
REM Switch to project root (parent of web2/backend)
cd /d "%~dp0\..\.."
echo.
echo ============================================================
echo web2 Backend (FastAPI)
echo ============================================================
echo.
REM Check Python
python --version >nul 2>&1
if errorlevel 1 (
echo [ERROR] Python not found. Please install Python 3.10+ first.
pause
exit /b 1
)
REM Check fastapi; install minimal deps if missing
python -c "import fastapi" 2>nul
if errorlevel 1 (
echo [INFO] Installing minimal backend deps...
python -m pip install fastapi uvicorn pydantic pyyaml pymysql
if errorlevel 1 (
echo [ERROR] Failed to install dependencies.
pause
exit /b 1
)
)
REM Launch backend
python web2\backend\start.py
pause
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""web2 后端启动脚本(Windows / 跨平台通用)
用法:
python web2/backend/start.py
# 或
python -m web2.backend.start
环境变量(可选):
WEB2_HOST=0.0.0.0
WEB2_PORT=8766
LOG_LEVEL=INFO
"""
from __future__ import annotations
import logging
import os
import sys
from pathlib import Path
BACKEND_DIR = Path(__file__).resolve().parent
WEB2_DIR = BACKEND_DIR.parent
PROJECT_ROOT = WEB2_DIR.parent
sys.path.insert(0, str(PROJECT_ROOT))
_START_LOG = WEB2_DIR / "logs"
_START_LOG.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
level=os.environ.get("LOG_LEVEL", "INFO"),
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
handlers=[
logging.FileHandler(_START_LOG / "start.log", encoding="utf-8"),
logging.StreamHandler(),
],
)
logger = logging.getLogger("web2.backend.start")
def check_dependencies():
"""启动前检查关键依赖"""
missing = []
for pkg, name in [
("fastapi", "FastAPI"),
("uvicorn", "Uvicorn"),
("pydantic", "Pydantic"),
("yaml", "PyYAML"),
]:
try:
__import__(pkg)
except ImportError:
missing.append((pkg, name))
return missing
def main():
print("=" * 70)
print(" web2 后端 · 启动检查")
print("=" * 70)
logger.info("=" * 60)
logger.info("web2 后端 · 启动检查")
logger.info(f"Python: {sys.version.split()[0]}")
# 1. 依赖检查
missing = check_dependencies()
if missing:
msg = f"缺少关键依赖: {', '.join(n for _, n in missing)}"
print(f"\n✗ {msg}")
print(f"\n请执行: pip install fastapi uvicorn pydantic pyyaml pymysql")
logger.error(msg)
sys.exit(1)
print("✓ 关键依赖齐全")
logger.info("关键依赖齐全")
# 2. 配置
host = os.environ.get("WEB2_HOST", "0.0.0.0")
port = int(os.environ.get("WEB2_PORT", "8766"))
print(f"\n启动配置:")
print(f" · 主机: {host}")
print(f" · 端口: {port}")
logger.info(f"启动配置: host={host}, port={port}")
# 3. 启动
import uvicorn
app_log = WEB2_DIR / "logs" / "app.log"
start_log = WEB2_DIR / "logs" / "start.log"
print("\n" + "=" * 70)
print(f" web2 后端 · FastAPI")
print(f" API: http://localhost:{port}/api")
print(f" API 文档: http://localhost:{port}/docs")
print(f" 前端 dev: http://localhost:5174 (需另开终端跑 npm run dev)")
print(f" 按 Ctrl+C 停止")
print("=" * 70)
print(f" 📋 日志位置(连接失败时来这里看):")
print(f" 应用日志: {app_log}")
print(f" 启动日志: {start_log}")
print(f" 📖 实时查看: PowerShell Get-Content '{app_log}' -Wait")
print(f" CMD type \"{app_log}\"")
print(f" Git Bash tail -f \"{app_log}\"")
print("=" * 70 + "\n")
logger.info(f"启动 uvicorn: web2.backend.app:app @ http://{host}:{port}")
logger.info(f"应用日志文件: {app_log}")
uvicorn.run(
"web2.backend.app:app",
host=host,
port=port,
reload=False,
log_level=os.environ.get("LOG_LEVEL", "info"),
)
if __name__ == "__main__":
main()
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数据治理工具</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
This diff is collapsed.
{
"name": "data-governance-web2",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"element-plus": "^2.9.1",
"@element-plus/icons-vue": "^2.3.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.0.5"
}
}
<template>
<router-view />
</template>
<script setup>
// 根组件只负责挂载路由,由各 layout 决定具体结构
</script>
<style>
html,
body,
#app {
height: 100%;
margin: 0;
padding: 0;
}
</style>
<template>
<el-popover
:width="280"
placement="bottom-end"
trigger="click"
>
<!-- 触发器:Header 右上角按钮 -->
<template #reference>
<el-button
class="db-trigger"
:type="connected ? 'primary' : 'default'"
:loading="loading"
plain
round
>
<el-icon><Connection /></el-icon>
<span class="db-trigger__label">
{{ currentLabel }} · {{ currentConn.host }}:{{ currentConn.port }}
</span>
<el-icon class="db-trigger__arrow"><ArrowDown /></el-icon>
</el-button>
</template>
<!-- 弹出内容:极简列表 -->
<div class="db-popover">
<div class="db-popover__title">
<el-icon><Connection /></el-icon>
<span>数据库连接</span>
</div>
<ul class="db-popover__list">
<li
v-for="opt in dbTypes"
:key="opt.value"
:class="['db-popover__item', { 'is-active': opt.value === state.currentType }]"
>
<div class="db-popover__brand">
<img
v-if="brandOf(opt.value).url && !logoFailed[opt.value]"
:src="brandOf(opt.value).url"
:alt="opt.label"
class="db-popover__logo"
@error="onLogoError(opt.value)"
/>
<span
v-else
class="db-popover__logo db-popover__logo--fallback"
:style="{ background: brandOf(opt.value).color }"
>
<el-icon><component :is="brandOf(opt.value).fallbackIcon" /></el-icon>
</span>
<span class="db-popover__name">
{{ presets[opt.value]?.display || presets[opt.value]?.database || opt.label }}
</span>
</div>
<div class="db-popover__actions">
<el-button
link
size="small"
:loading="testing === opt.value"
:title="`测试 ${opt.label}`"
@click="onTest(opt.value)"
>
<el-icon><VideoPlay /></el-icon>
</el-button>
<el-button
type="primary"
link
size="small"
:disabled="opt.value === state.currentType"
:title="`切换到 ${opt.label}`"
@click="onSwitch(opt.value)"
>
<el-icon><Position /></el-icon>
</el-button>
</div>
</li>
</ul>
</div>
</el-popover>
</template>
<script setup>
import { computed, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { DB_TYPES } from '@/config/dbPresets'
import { DB_LOGOS } from '@/config/dbBranding'
import { useDbConnection } from '@/stores/useDbConnection'
const {
state,
presets,
loading,
currentConnection,
setDbType,
} = useDbConnection()
const dbTypes = DB_TYPES
/** 当前正在测试的 db_type(null = 没在测试) */
const testing = ref(null)
/** 哪些 db_type 的 logo 加载失败过 → 切到 fallback icon */
const logoFailed = reactive({})
const currentConn = computed(() => currentConnection.value || {})
const currentLabel = computed(
() => dbTypes.find((d) => d.value === state.currentType)?.label || state.currentType,
)
const connected = computed(() => !!state.currentType)
function brandOf(dbType) {
return DB_LOGOS[dbType] || { url: null, color: '#909399', fallbackIcon: null }
}
function onLogoError(dbType) {
// 网络问题 / CDN 失效 → 切到 Element Plus icon 兜底,不再尝试
logoFailed[dbType] = true
}
/**
* 测试指定 db_type 的连接
*/
async function onTest(dbType) {
const conn = presets.value[dbType]
if (!conn) {
ElMessage.warning(`${dbType} 预设不存在`)
return
}
testing.value = dbType
try {
const r = await fetch('/api/connect/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
// db_type 用参数(永远有值),不从 conn 取(API YAML 没这字段)
db_type: dbType,
host: conn.host,
port: conn.port,
user: conn.user,
password: conn.password,
database: conn.database,
charset: 'utf8mb4',
connect_timeout: 10,
oracle_client_dir: conn.oracle_client_dir || null,
}),
})
if (!r.ok) {
let detail
try { detail = await r.json() } catch { detail = { detail: `HTTP ${r.status} ${r.statusText}` } }
const detailMsg = Array.isArray(detail.detail)
? detail.detail.map(d => `${d.loc?.join('.') || ''}: ${d.msg}`).join('\n')
: (detail.detail || JSON.stringify(detail))
ElMessageBox.alert(
`HTTP ${r.status}\n\n${detailMsg}`,
`❌ 测试失败(${dbType})`,
{ confirmButtonText: '好的', type: 'error' },
)
return
}
const data = await r.json()
if (data.ok) {
ElMessageBox.alert(
data.message || '连接成功',
`✅ ${dbType} 测试通过`,
{ confirmButtonText: '好的', type: 'success' },
)
} else {
ElMessageBox.alert(
data.message || '后端返回 ok=false 但无 message',
`❌ ${dbType} 测试失败`,
{ confirmButtonText: '好的', type: 'error' },
)
}
} catch (e) {
ElMessageBox.alert(
`请求失败: ${e.message}`,
`❌ ${dbType} 网络错误`,
{ confirmButtonText: '好的', type: 'error' },
)
} finally {
testing.value = null
}
}
/** 切换当前连接到指定 db_type */
function onSwitch(dbType) {
if (dbType === state.currentType) return
setDbType(dbType)
const label = dbTypes.find((d) => d.value === dbType)?.label || dbType
ElMessage.success(`已切换到 ${label}`)
document.activeElement?.blur?.()
}
</script>
<style scoped>
.db-trigger {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 320px;
font-weight: 500;
}
.db-trigger__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.db-trigger__arrow {
font-size: 12px;
opacity: 0.7;
}
/* 弹框标题 */
.db-popover__title {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 8px;
}
.db-popover__title .el-icon {
color: #409eff;
}
/* 列表 */
.db-popover__list {
list-style: none;
margin: 0;
padding: 0;
}
.db-popover__item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
border-radius: 6px;
transition: background 0.15s;
cursor: default;
}
.db-popover__item:hover {
background: #f5f7fa;
}
.db-popover__item.is-active {
background: #ecf5ff;
}
.db-popover__item.is-active .db-popover__name {
color: #409eff;
font-weight: 600;
}
/* logo + 数据库名(一行) */
.db-popover__brand {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.db-popover__logo {
width: 20px;
height: 20px;
flex-shrink: 0;
object-fit: contain;
}
.db-popover__logo--fallback {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
color: #fff;
font-size: 12px;
}
.db-popover__logo--fallback .el-icon {
font-size: 14px;
}
.db-popover__name {
font-size: 14px;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 操作按钮 */
.db-popover__actions {
display: flex;
gap: 2px;
flex-shrink: 0;
}
.db-popover__actions .el-icon {
font-size: 16px;
}
</style>
/**
* 数据库品牌资源(logo / icon / 颜色)
*
* Logo 优先级:
* 1. 远程 SVG URL(devicon CDN,稳定 + 真实 logo)
* 2. URL 加载失败 → fallback 到 Element Plus icon(不依赖网络)
*
* 来源说明:
* - MySQL / Oracle → devicons/devicon(GitHub 11w+ stars,CDN 稳定)
* - 达梦 → 无公开 SVG logo CDN(国产数据库,体量小),用 Element Plus icon
* + 官方品牌色 #C8102E 兜底
*
* 颜色取自各厂商官方 brand color。
*/
import { DataLine, Coin } from '@element-plus/icons-vue'
export const DB_LOGOS = {
mysql: {
url: 'https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/mysql/mysql-original.svg',
color: '#00758F', // MySQL 官方蓝
fallbackIcon: DataLine,
},
dameng: {
url: null, // 达梦无公开 SVG CDN
color: '#C8102E', // 达梦官方红
fallbackIcon: DataLine,
},
oracle: {
url: 'https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/oracle/oracle-original.svg',
color: '#C74634', // Oracle 官方红
fallbackIcon: Coin,
},
}
/**
* 数据库连接预设(前端本地兜底)
*
* 来源:web/configs/db_defaults.yaml(同步结构 + display 字段)
* - MySQL / 达梦 (DM) / Oracle 三套配置
* - 改这里 = 改 web/configs/db_defaults.yaml(保持两端结构一致)
*
* ⚠️ 安全:本文件已入库,password 字段必须留空字符串。
* 真实密码由运行时 GET /api/db-defaults 注入到 store.presets.password。
* 后端不通时 password 为空 → 测试连接会失败(这是预期行为,不是 bug)。
*
* 字段说明:
* host / port / user / database / display —— 可入库(无敏感信息)
* password —— 留空
* oracle_client_dir —— 可入库(路径,非凭据)
*/
export const DB_TYPES = [
{ value: 'mysql', label: 'MySQL' },
{ value: 'dameng', label: '达梦 (DM)' },
{ value: 'oracle', label: 'Oracle' },
]
export const DB_PRESETS = {
// ── MySQL ──
mysql: {
db_type: 'mysql',
host: '192.168.20.10',
port: 3306,
user: 'root',
password: '',
database: 'smart-build',
display: '智能建造',
oracle_client_dir: '',
},
// ── 达梦 (DM) ──
dameng: {
db_type: 'dameng',
host: '192.168.20.24',
port: 5236,
user: 'HGWXZJ',
password: '',
database: 'HGWXZJ',
display: '三金系统',
oracle_client_dir: '',
},
// ── Oracle ──
oracle: {
db_type: 'oracle',
host: '192.168.20.196',
port: 1521,
user: 'GJJ80',
password: '',
database: 'ZFGJJ',
display: '黄冈公积金',
oracle_client_dir:
'D:\\Oracle\\instantclient-basic-windows.x64-12.2.0.1.0\\instantclient_12_2',
},
}
<template>
<el-container class="layout-root">
<!-- 顶栏 -->
<el-header class="layout-header">
<div class="layout-header__brand">
<el-icon class="layout-header__logo"><DataAnalysis /></el-icon>
<span class="layout-header__title">数据治理工具</span>
</div>
<div class="layout-header__right">
<DbConnectionPopover />
<span class="layout-header__user">v0.1.0</span>
</div>
</el-header>
<el-container class="layout-body">
<!-- 左侧菜单 -->
<el-aside class="layout-aside" width="220px">
<el-menu
:default-active="activeMenu"
class="layout-menu"
background-color="#001529"
text-color="#bfcbd9"
active-text-color="#409eff"
router
>
<el-menu-item
v-for="item in menuItems"
:key="item.path"
:index="item.path"
:route="{ name: item.name }"
>
<el-icon><component :is="item.icon" /></el-icon>
<template #title>{{ item.title }}</template>
</el-menu-item>
</el-menu>
</el-aside>
<!-- 内容区 -->
<el-main class="layout-main">
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import DbConnectionPopover from '@/components/DbConnectionPopover.vue'
const route = useRoute()
// 目前只有一个菜单项,后续按需扩展
const menuItems = [
{
path: '/id-card',
name: 'IdCardTool',
title: '身份证治理工具',
icon: 'IdCard',
},
]
const activeMenu = computed(() => route.path)
</script>
<style scoped>
.layout-root {
height: 100vh;
}
/* 顶栏 */
.layout-header {
display: flex;
align-items: center;
justify-content: space-between;
background: #ffffff;
border-bottom: 1px solid #e6e8eb;
padding: 0 20px;
height: 56px;
}
.layout-header__brand {
display: flex;
align-items: center;
gap: 8px;
}
.layout-header__logo {
font-size: 22px;
color: #409eff;
}
.layout-header__title {
font-size: 18px;
font-weight: 600;
color: #303133;
}
.layout-header__right {
display: flex;
align-items: center;
gap: 12px;
}
.layout-header__user {
font-size: 13px;
color: #909399;
}
/* 侧边栏 */
.layout-aside {
background: #001529;
}
.layout-menu {
border-right: none;
height: 100%;
}
/* 内容区 */
.layout-main {
background: #f5f7fa;
padding: 20px;
}
</style>
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
import { useDbConnection } from '@/stores/useDbConnection'
const app = createApp(App)
// 注册所有图标(全局可用)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(ElementPlus, { locale: zhCn })
app.use(router)
app.mount('#app')
// 启动时异步拉取后端 db-defaults(失败时 store 内部自动兜底到本地常量)
useDbConnection().loadPresetsFromApi()
import { createRouter, createWebHashHistory } from 'vue-router'
import MainLayout from '@/layouts/MainLayout.vue'
import IdCardTool from '@/views/IdCardTool.vue'
const routes = [
{
path: '/',
component: MainLayout,
redirect: '/id-card',
children: [
{
path: 'id-card',
name: 'IdCardTool',
component: IdCardTool,
meta: { title: '身份证治理工具', icon: 'IdCard' },
},
],
},
]
const router = createRouter({
history: createWebHashHistory(),
routes,
})
export default router
/**
* 全局数据库连接状态
*
* 数据流:
* 启动 → fetch('/api/db-defaults') → 替换 presets
* ↓ 失败
* 用 DB_PRESETS 常量兜底
*
* 状态:
* currentType 当前选中的 db_type(写到 localStorage)
* presets 三套预设(响应式;API 拉回来后会替换)
* loading 是否正在拉 API
*
* 设计取舍:
* - 不上 Pinia:当前只一个全局 db 状态,reactive 单例够用
* - 密码不进 localStorage:只存 db_type,需要连接时从 presets 实时取
*/
import { reactive, ref, watch, computed } from 'vue'
import { DB_PRESETS as FALLBACK_PRESETS } from '@/config/dbPresets'
const STORAGE_KEY = 'web2.dbConnection.type'
const API_TIMEOUT_MS = 4000
function _loadInitialType() {
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved && FALLBACK_PRESETS[saved]) return saved
} catch (_) {
/* localStorage 不可用 */
}
return 'mysql'
}
const state = reactive({
currentType: _loadInitialType(),
/** 是否已经尝试过拉 API(true 后不再重试,避免启动时反复请求) */
apiLoaded: false,
})
/** 响应式 presets:API 拉回来后会被替换,组件 computed 自动更新 */
const presets = ref({ ...FALLBACK_PRESETS })
const loading = ref(false)
watch(
() => state.currentType,
(v) => {
try {
localStorage.setItem(STORAGE_KEY, v)
} catch (_) {
/* 静默 */
}
},
)
/** 当前选中的完整连接信息(响应式) */
const currentConnection = computed(() => presets.value[state.currentType])
/** 切换数据库类型 */
function setDbType(type) {
if (!presets.value[type]) {
console.warn(`[db] 未知数据库类型: ${type}`)
return
}
state.currentType = type
}
/**
* 从后端拉 db-defaults,覆盖本地 presets。
* 失败/超时 → 保留 FALLBACK_PRESETS 不变,apiLoaded=true 表示「不再重试」。
*/
async function loadPresetsFromApi() {
if (state.apiLoaded) return
loading.value = true
try {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), API_TIMEOUT_MS)
const r = await fetch('/api/db-defaults', { signal: ctrl.signal })
clearTimeout(timer)
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const data = await r.json()
if (data && typeof data === 'object' && Object.keys(data).length > 0) {
presets.value = data
console.log('[db] 已从后端加载 presets:', Object.keys(data))
} else {
console.warn('[db] 后端返回空,使用本地兜底预设')
}
} catch (e) {
console.warn(`[db] 拉取 /api/db-defaults 失败(${e.message}),保留本地兜底预设`)
} finally {
state.apiLoaded = true
loading.value = false
}
}
/**
* 测试当前选中的连接(不切换 currentType,只发请求)
* @returns {Promise<{ok: boolean, message: string}>}
*/
async function testCurrentConnection() {
const conn = currentConnection.value
if (!conn) {
return { ok: false, message: '当前连接不存在' }
}
try {
const r = await fetch('/api/connect/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
// db_type 必须用 state.currentType(已知 db_type 一定有值),
// 不能用 conn.db_type —— 后端 YAML 预设里没有这个字段。
db_type: state.currentType,
host: conn.host,
port: conn.port,
user: conn.user,
password: conn.password,
database: conn.database,
charset: 'utf8mb4',
connect_timeout: 10,
oracle_client_dir: conn.oracle_client_dir || null,
}),
})
const data = await r.json()
return { ok: !!data.ok, message: data.message || (data.ok ? '连接成功' : '连接失败') }
} catch (e) {
return { ok: false, message: `请求失败: ${e.message}` }
}
}
export function useDbConnection() {
return { state, presets, loading, currentConnection, setDbType, loadPresetsFromApi, testCurrentConnection }
}
<template>
<div class="idcard-page">
<el-card class="idcard-page__header" shadow="never">
<h2 class="idcard-page__title">身份证治理工具</h2>
<p class="idcard-page__desc">
占位页面。该工具将在后续迭代中实现:身份证号码识别、校验位计算、地区码归属地解析等。
</p>
</el-card>
<el-empty description="功能开发中……" />
</div>
</template>
<script setup>
// 暂时无业务逻辑
</script>
<style scoped>
.idcard-page {
max-width: 1200px;
margin: 0 auto;
}
.idcard-page__header {
margin-bottom: 20px;
}
.idcard-page__title {
margin: 0 0 8px;
font-size: 20px;
color: #303133;
}
.idcard-page__desc {
margin: 0;
color: #606266;
font-size: 14px;
line-height: 1.6;
}
</style>
@echo off
REM ============================================================
REM web2 - Windows one-click launcher (frontend + backend)
REM (REM lines are ASCII-only to avoid cmd.exe encoding issues)
REM
REM 启动后:
REM - 前端 Vite dev server http://localhost:5174
REM - 后端 FastAPI http://localhost:8766 /api/*
REM
REM 用两个独立 cmd 窗口分别跑前后端,互不干扰。
REM 关闭任一窗口 = 停止对应服务;想全停就关掉本窗口。
REM ============================================================
REM Switch to project root (parent of web2/)
cd /d "%~dp0\.."
echo.
echo ============================================================
echo web2 - Data Governance Tool (frontend + backend)
echo ============================================================
echo.
REM Check Node + npm
where npm >nul 2>&1
if errorlevel 1 (
echo [ERROR] npm not found. Please install Node.js 18+ first.
pause
exit /b 1
)
REM Check Python + fastapi
python --version >nul 2>&1
if errorlevel 1 (
echo [ERROR] Python not found. Please install Python 3.10+ first.
pause
exit /b 1
)
python -c "import fastapi" 2>nul
if errorlevel 1 (
echo [INFO] Installing minimal backend deps...
python -m pip install fastapi uvicorn pydantic pyyaml pymysql
if errorlevel 1 (
echo [ERROR] Failed to install dependencies.
pause
exit /b 1
)
)
REM Check if node_modules exists
if not exist "web2\node_modules" (
echo [INFO] Installing frontend deps...
cd web2 && npm install && cd ..
if errorlevel 1 (
echo [ERROR] npm install failed.
pause
exit /b 1
)
)
echo Starting backend in new window...
REM /k = keep window open after command ends. Title in quotes.
start "web2-backend" cmd /k "cd /d %CD%\web2\backend && python start.py"
echo Starting frontend in new window...
start "web2-frontend" cmd /k "cd /d %CD%\web2 && npm run dev"
echo.
echo ============================================================
echo Both services starting. Two new windows opened:
echo [web2-backend] http://localhost:8766 /api/*
echo [web2-frontend] http://localhost:5174
echo.
echo Open http://localhost:5174 in your browser.
echo.
echo Close this window or press any key to dismiss.
echo (Backend / frontend keep running in their own windows.)
echo ============================================================
echo.
pause
#!/usr/bin/env bash
# ============================================================
# web2 - macOS/Linux one-click launcher (frontend + backend)
# ============================================================
# 启动后:
# - 前端 Vite dev server http://localhost:5174
# - 后端 FastAPI http://localhost:8766 /api/*
# 用 trap + kill 在 Ctrl+C 时同时停掉两个。
# ============================================================
set -e
cd "$(dirname "$0")/.."
echo "============================================================"
echo " web2 - Data Governance Tool (frontend + backend)"
echo "============================================================"
# 检查 node
if ! command -v npm >/dev/null 2>&1; then
echo "[ERROR] npm not found. Please install Node.js 18+ first."
exit 1
fi
# 检查 python + fastapi
if ! command -v python >/dev/null 2>&1; then
echo "[ERROR] python not found. Please install Python 3.10+ first."
exit 1
fi
if ! python -c "import fastapi" 2>/dev/null; then
echo "[INFO] Installing minimal backend deps..."
pip install fastapi uvicorn pydantic pyyaml pymysql
fi
# 检查 node_modules
if [ ! -d "web2/node_modules" ]; then
echo "[INFO] Installing frontend deps..."
(cd web2 && npm install)
fi
echo "Starting backend..."
python web2/backend/start.py &
BACKEND_PID=$!
# 等后端起来再启动前端(避免前端启动时拉 /api 失败)
sleep 2
echo "Starting frontend..."
(cd web2 && npm run dev) &
FRONTEND_PID=$!
cleanup() {
echo ""
echo "Stopping..."
kill $BACKEND_PID 2>/dev/null || true
kill $FRONTEND_PID 2>/dev/null || true
exit 0
}
trap cleanup INT TERM
echo ""
echo "============================================================"
echo " Both services running."
echo " Backend: http://localhost:8766"
echo " Frontend: http://localhost:5174"
echo ""
echo " Press Ctrl+C to stop both."
echo "============================================================"
wait
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 5174,
open: false,
// /api/* 反向代理到 web2 后端(FastAPI @ 8766),避免浏览器跨域
// 后端启动:python web2/backend/start.py
proxy: {
'/api': {
target: 'http://localhost:8766',
changeOrigin: true,
},
},
},
})
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment