Commit b6692c94 authored by 375242562@qq.com's avatar 375242562@qq.com

fix/feat: Oracle thick mode 错误提示优化 + 向导步骤加载动画

- sync_adapters: thick mode 失败时抛出明确错误(含路径提示和操作指引)
  路径不存在时提前检测并打印日志,不再静默吞异常
- main.py: 应用启动时提前初始化 Oracle thick mode 并输出日志
- DataSyncPage: 新建任务向导步骤间加载动画
  Step0→1 获取表列表时显示半透明覆盖层 + 转圈 + 提示文字
  Step1→2 切换时显示过渡动画
  预览数据时右侧区域显示加载状态(转圈 + 说明文字)
  下一步/上一步按钮加载期间自动禁用防重复触发
parent 98876adf
import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
...@@ -6,10 +7,23 @@ from app.api.routes import patients, trials, matching, notifications, diagnoses ...@@ -6,10 +7,23 @@ from app.api.routes import patients, trials, matching, notifications, diagnoses
from app.api.routes import auth, users, roles, permissions, departments from app.api.routes import auth, users, roles, permissions, departments
from app.api.routes import batch_matching, sync, connection_profiles, dashboard from app.api.routes import batch_matching, sync, connection_profiles, dashboard
logger = logging.getLogger(__name__)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
await init_db() await init_db()
# 提前初始化 Oracle thick mode,启动时即可确认是否配置正确
try:
import oracledb
from app.services.sync_adapters import _init_oracle_thick_mode
ok = _init_oracle_thick_mode()
if ok:
logger.info("Oracle Instant Client 已加载(thick mode 启用)")
else:
logger.warning("Oracle Instant Client 未加载,Oracle 连接将使用 thin mode(不支持旧版 Oracle)")
except Exception:
pass
yield yield
......
...@@ -268,17 +268,28 @@ def _init_oracle_thick_mode() -> bool: ...@@ -268,17 +268,28 @@ def _init_oracle_thick_mode() -> bool:
oracledb can only be initialized once per process, so we guard with is_thin_mode(). oracledb can only be initialized once per process, so we guard with is_thin_mode().
""" """
import os import os
import logging
import oracledb import oracledb
_logger = logging.getLogger(__name__)
if not oracledb.is_thin_mode(): if not oracledb.is_thin_mode():
return True # already thick return True # already thick
client_path = os.environ.get("ORACLE_CLIENT_PATH", "").strip() client_path = os.environ.get("ORACLE_CLIENT_PATH", "").strip()
if client_path and not os.path.isdir(client_path):
_logger.error(
"Oracle Instant Client 路径不存在或不是目录: %r,请检查 ORACLE_CLIENT_PATH 环境变量", client_path
)
return False
try: try:
if client_path: if client_path:
oracledb.init_oracle_client(lib_dir=client_path) oracledb.init_oracle_client(lib_dir=client_path)
else: else:
oracledb.init_oracle_client() # search PATH / LD_LIBRARY_PATH oracledb.init_oracle_client() # search PATH / LD_LIBRARY_PATH
return True return True
except Exception: except Exception as e:
_logger.error("Oracle Instant Client 加载失败: %s", e)
return False return False
...@@ -287,17 +298,30 @@ def _create_oracle_engine(host: str, port: str, database: str, ...@@ -287,17 +298,30 @@ def _create_oracle_engine(host: str, port: str, database: str,
oracle_type: str = "sid", pool_timeout: int = 8): oracle_type: str = "sid", pool_timeout: int = 8):
"""Build a SQLAlchemy engine for Oracle, handling SID vs service name. """Build a SQLAlchemy engine for Oracle, handling SID vs service name.
Tries thick mode first (wider DB version support); falls back to thin mode. Requires thick mode (Oracle Instant Client). Set ORACLE_CLIENT_PATH env var
Thick mode requires Oracle Instant Client — set ORACLE_CLIENT_PATH env var to point to the directory containing oci.dll / libclntsh.so, then restart
to point to the directory containing oci.dll / libclntsh.so. the backend via PowerShell so the env var is inherited.
""" """
import os
import oracledb
from sqlalchemy import create_engine from sqlalchemy import create_engine
from urllib.parse import quote_plus from urllib.parse import quote_plus
_init_oracle_thick_mode() # no-op if already thick or client missing
_init_oracle_thick_mode()
if oracledb.is_thin_mode():
client_path = os.environ.get("ORACLE_CLIENT_PATH", "")
hint = f"当前 ORACLE_CLIENT_PATH={client_path!r}" if client_path else "ORACLE_CLIENT_PATH 未设置"
raise RuntimeError(
f"Oracle Instant Client 未能加载({hint})。"
"请确认:① 已安装 Oracle Instant Client;"
"② 在系统环境变量中设置 ORACLE_CLIENT_PATH 指向 instantclient 目录;"
"③ 通过 PowerShell 重启后端服务以继承该环境变量。"
)
user = quote_plus(username) if username else "" user = quote_plus(username) if username else ""
pwd = quote_plus(password) if password else "" pwd = quote_plus(password) if password else ""
if oracle_type == "sid": if oracle_type == "sid":
# SID connections require connect_args in thin mode; work in both modes
return create_engine( return create_engine(
f"oracle+oracledb://{user}:{pwd}@", f"oracle+oracledb://{user}:{pwd}@",
connect_args={"host": host, "port": int(port), "sid": database}, connect_args={"host": host, "port": int(port), "sid": database},
......
...@@ -115,6 +115,10 @@ function SyncTaskDialog({ open, onClose, onSaved, editSource, profiles, snack }: ...@@ -115,6 +115,10 @@ function SyncTaskDialog({ open, onClose, onSaved, editSource, profiles, snack }:
const [isActive, setIsActive] = useState(true) const [isActive, setIsActive] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
// 步骤切换加载状态
const [stepping, setStepping] = useState(false)
const [steppingMsg, setSteppingMsg] = useState('')
// 初始化 // 初始化
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
...@@ -208,9 +212,23 @@ function SyncTaskDialog({ open, onClose, onSaved, editSource, profiles, snack }: ...@@ -208,9 +212,23 @@ function SyncTaskDialog({ open, onClose, onSaved, editSource, profiles, snack }:
const handleNext = async () => { const handleNext = async () => {
if (step === 0) { if (step === 0) {
if (!taskName.trim()) { snack.show('error', '请填写任务名称'); return } if (!taskName.trim()) { snack.show('error', '请填写任务名称'); return }
if (profileId && tables.length === 0) await loadTables(profileId) if (profileId && tables.length === 0) {
setStepping(true)
setSteppingMsg('正在连接数据库,获取数据表列表...')
try {
await loadTables(profileId)
} finally {
setStepping(false)
setSteppingMsg('')
}
}
} else if (step === 1) { } else if (step === 1) {
if (!previewDone && !editSource) { snack.show('error', '请先点击"预览数据"确认 SQL 可以正常执行'); return } if (!previewDone && !editSource) { snack.show('error', '请先点击"预览数据"确认 SQL 可以正常执行'); return }
setStepping(true)
setSteppingMsg('正在分析字段结构,准备映射配置...')
await new Promise(r => setTimeout(r, 400))
setStepping(false)
setSteppingMsg('')
} }
setStep(s => s + 1) setStep(s => s + 1)
} }
...@@ -281,7 +299,24 @@ function SyncTaskDialog({ open, onClose, onSaved, editSource, profiles, snack }: ...@@ -281,7 +299,24 @@ function SyncTaskDialog({ open, onClose, onSaved, editSource, profiles, snack }:
</Stepper> </Stepper>
</Box> </Box>
<DialogContent dividers sx={{ minHeight: 400 }}> <DialogContent dividers sx={{ minHeight: 400, position: 'relative' }}>
{/* ── 步骤切换加载覆盖层 ── */}
{stepping && (
<Box sx={{
position: 'absolute', inset: 0, zIndex: 10,
display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
bgcolor: 'rgba(255,255,255,0.85)',
backdropFilter: 'blur(2px)',
gap: 2,
}}>
<CircularProgress size={48} thickness={4} />
<Typography variant="body1" color="text.secondary" fontWeight={500}>
{steppingMsg}
</Typography>
</Box>
)}
{/* ── Step 0: 基本设置 ── */} {/* ── Step 0: 基本设置 ── */}
{step === 0 && ( {step === 0 && (
...@@ -383,7 +418,22 @@ function SyncTaskDialog({ open, onClose, onSaved, editSource, profiles, snack }: ...@@ -383,7 +418,22 @@ function SyncTaskDialog({ open, onClose, onSaved, editSource, profiles, snack }:
{/* 预览数据表格 */} {/* 预览数据表格 */}
<Grid item xs={12} md={7}> <Grid item xs={12} md={7}>
{previewDone && columns.length > 0 ? ( {previewing ? (
<Box sx={{
minHeight: 220, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', gap: 2,
border: '1px solid', borderColor: 'primary.light',
borderRadius: 2, bgcolor: 'primary.50',
}}>
<CircularProgress size={40} thickness={4} />
<Typography variant="body2" color="primary.main" fontWeight={500}>
正在查询数据库,请稍候...
</Typography>
<Typography variant="caption" color="text.secondary">
首次连接可能需要几秒钟建立网络通道
</Typography>
</Box>
) : previewDone && columns.length > 0 ? (
<Box> <Box>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>数据预览</Typography> <Typography variant="subtitle2" color="text.secondary" gutterBottom>数据预览</Typography>
<Box sx={{ overflow: 'auto', border: '1px solid', borderColor: 'divider', borderRadius: 1 }}> <Box sx={{ overflow: 'auto', border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
...@@ -525,14 +575,21 @@ function SyncTaskDialog({ open, onClose, onSaved, editSource, profiles, snack }: ...@@ -525,14 +575,21 @@ function SyncTaskDialog({ open, onClose, onSaved, editSource, profiles, snack }:
</DialogContent> </DialogContent>
<DialogActions sx={{ px: 3, py: 2, justifyContent: 'space-between' }}> <DialogActions sx={{ px: 3, py: 2, justifyContent: 'space-between' }}>
<Button onClick={step === 0 ? onClose : () => setStep(s => s - 1)} disabled={saving}> <Button onClick={step === 0 ? onClose : () => setStep(s => s - 1)} disabled={saving || stepping}>
{step === 0 ? '取消' : '上一步'} {step === 0 ? '取消' : '上一步'}
</Button> </Button>
{step < WIZARD_STEPS.length - 1 ? ( {step < WIZARD_STEPS.length - 1 ? (
<Button variant="contained" onClick={handleNext}>下一步</Button> <Button
variant="contained"
onClick={handleNext}
disabled={stepping || previewing}
startIcon={stepping ? <CircularProgress size={16} color="inherit" /> : <ArrowForwardIcon />}
>
{stepping ? '加载中...' : '下一步'}
</Button>
) : ( ) : (
<Button variant="contained" onClick={handleSave} disabled={saving} <Button variant="contained" onClick={handleSave} disabled={saving}
startIcon={saving ? <CircularProgress size={16} /> : undefined}> startIcon={saving ? <CircularProgress size={16} color="inherit" /> : undefined}>
{saving ? '保存中...' : (editSource ? '更新任务' : '创建任务')} {saving ? '保存中...' : (editSource ? '更新任务' : '创建任务')}
</Button> </Button>
)} )}
......
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