From 945ccd64baaf000aca6e71cd0e5281382ea8b585 Mon Sep 17 00:00:00 2001 From: iven Date: Sat, 25 Apr 2026 10:00:49 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=85=A8=E9=9D=A2=20QA=20=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1=E4=BF=AE=E5=A4=8D=20=E2=80=94=20=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E5=8A=A0=E5=9B=BA/=E4=BB=A3=E7=A0=81=E8=B4=A8=E9=87=8F/?= =?UTF-8?q?=E8=B7=A8=E5=B9=B3=E5=8F=B0=E4=B8=80=E8=87=B4=E6=80=A7/?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 安全热修复 (CRITICAL): - 外部化微信 appid/secret 到 ERP__WECHAT__APPID/SECRET 环境变量 - 正确连接 HealthCrypto 到 ERP__HEALTH__AES_KEY/HMAC_KEY 环境变量 - 外部化小程序加密密钥到 TARO_APP_ENCRYPTION_KEY 环境变量 - 移除小程序 auth store 中的敏感信息 console.log Phase 1 安全加固: - 微信自动注册 display_name 添加 sanitize 防止 XSS - 测试数据库凭据改为从 TEST_DB_URL 环境变量读取 Phase 2 代码质量: - 提取 useThemeMode hook 消除 22 处重复暗色模式检测 - 提取共享健康常量到 constants/health.ts - 拆分 patient_service.rs 脱敏函数到 masking.rs - 移除未使用的 i18next/react-i18next 依赖 - 移除未使用的 api/errors.ts 和 erp-auth/anyhow 依赖 Phase 3 测试覆盖: - 新增 5 个患者模块集成测试 (CRUD/租户隔离/验证/软删除) Phase 4 跨平台一致性: - 统一小程序 Patient.birthday → birth_date 匹配后端 - 统一小程序 Appointment.time_slot → start_time/end_time 匹配后端 Phase 5 架构: - 微信登录添加多租户 TODO 注释 - 更新 wiki/infrastructure.md 环境变量文档 --- Cargo.lock | 1 - apps/miniprogram/config/index.ts | 6 +- .../src/pages/appointment/create/index.tsx | 22 +- .../src/pages/appointment/detail/index.tsx | 4 +- .../src/pages/appointment/index.tsx | 2 +- .../src/pages/profile/family-add/index.tsx | 2 +- .../src/pages/profile/family/index.tsx | 4 +- apps/miniprogram/src/services/appointment.ts | 9 +- apps/miniprogram/src/services/auth.ts | 7 +- apps/miniprogram/src/services/patient.ts | 6 +- apps/miniprogram/src/stores/auth.ts | 3 +- apps/miniprogram/src/utils/secure-storage.ts | 32 +-- apps/web/package.json | 7 +- apps/web/pnpm-lock.yaml | 3 + apps/web/src/api/errors.ts | 8 - apps/web/src/components/NotificationPanel.tsx | 6 +- apps/web/src/constants/health.ts | 26 +++ apps/web/src/hooks/useThemeMode.ts | 15 ++ apps/web/src/i18n/index.ts | 12 - apps/web/src/i18n/locales/zh-CN.json | 34 --- apps/web/src/main.tsx | 1 - apps/web/src/pages/Home.tsx | 6 +- apps/web/src/pages/Organizations.tsx | 5 +- apps/web/src/pages/PluginDashboardPage.tsx | 8 +- apps/web/src/pages/Roles.tsx | 5 +- apps/web/src/pages/Users.tsx | 5 +- .../src/pages/health/ConsultationDetail.tsx | 8 +- .../web/src/pages/health/ConsultationList.tsx | 10 +- .../src/pages/health/FollowUpRecordList.tsx | 8 +- .../web/src/pages/health/FollowUpTaskList.tsx | 7 +- apps/web/src/pages/health/PatientDetail.tsx | 21 +- apps/web/src/pages/health/PatientList.tsx | 31 +-- .../web/src/pages/health/PatientTagManage.tsx | 13 +- .../src/pages/messages/MessageTemplates.tsx | 6 +- .../src/pages/messages/NotificationList.tsx | 6 +- .../messages/NotificationPreferences.tsx | 6 +- .../web/src/pages/settings/AuditLogViewer.tsx | 6 +- .../web/src/pages/settings/SystemSettings.tsx | 5 +- .../web/src/pages/workflow/CompletedTasks.tsx | 6 +- .../src/pages/workflow/InstanceMonitor.tsx | 6 +- apps/web/src/pages/workflow/PendingTasks.tsx | 6 +- .../src/pages/workflow/ProcessDefinitions.tsx | 6 +- crates/erp-auth/Cargo.toml | 1 - crates/erp-auth/src/handler/wechat_handler.rs | 11 + crates/erp-auth/src/service/wechat_service.rs | 15 +- crates/erp-health/src/service/masking.rs | 157 +++++++++++++ crates/erp-health/src/service/mod.rs | 1 + .../erp-health/src/service/patient_service.rs | 38 +--- crates/erp-server/config/default.toml | 8 +- crates/erp-server/src/config.rs | 9 + crates/erp-server/src/main.rs | 16 ++ crates/erp-server/src/state.rs | 8 +- crates/erp-server/tests/integration.rs | 2 + .../tests/integration/health_patient_tests.rs | 208 ++++++++++++++++++ .../erp-server/tests/integration/test_db.rs | 20 +- wiki/infrastructure.md | 23 +- 56 files changed, 634 insertions(+), 273 deletions(-) delete mode 100644 apps/web/src/api/errors.ts create mode 100644 apps/web/src/constants/health.ts create mode 100644 apps/web/src/hooks/useThemeMode.ts delete mode 100644 apps/web/src/i18n/index.ts delete mode 100644 apps/web/src/i18n/locales/zh-CN.json create mode 100644 crates/erp-health/src/service/masking.rs create mode 100644 crates/erp-server/tests/integration/health_patient_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 1567164..edab778 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1289,7 +1289,6 @@ name = "erp-auth" version = "0.1.0" dependencies = [ "aes", - "anyhow", "argon2", "async-trait", "axum", diff --git a/apps/miniprogram/config/index.ts b/apps/miniprogram/config/index.ts index c908498..8cf0fe8 100644 --- a/apps/miniprogram/config/index.ts +++ b/apps/miniprogram/config/index.ts @@ -10,7 +10,11 @@ export default defineConfig(async (merge) => { sourceRoot: 'src', outputRoot: 'dist', plugins: [], - defineConstants: {}, + defineConstants: { + 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'), + 'process.env.TARO_APP_API_URL': JSON.stringify(process.env.TARO_APP_API_URL || 'http://localhost:3000/api/v1'), + 'process.env.TARO_APP_ENCRYPTION_KEY': JSON.stringify(process.env.TARO_APP_ENCRYPTION_KEY || ''), + }, copy: { patterns: [], options: {} }, framework: 'react', compiler: 'webpack5', diff --git a/apps/miniprogram/src/pages/appointment/create/index.tsx b/apps/miniprogram/src/pages/appointment/create/index.tsx index 15ae1aa..04cbfdd 100644 --- a/apps/miniprogram/src/pages/appointment/create/index.tsx +++ b/apps/miniprogram/src/pages/appointment/create/index.tsx @@ -27,7 +27,9 @@ interface DoctorItem { } interface TimeSlot { - time_slot: string; + start_time: string; + end_time: string; + label: string; available_count: number; } @@ -85,7 +87,9 @@ export default function AppointmentCreate() { const daySlots = schedules .filter((s: any) => (s.date || s.appointment_date) === date) .map((s: any) => ({ - time_slot: s.time_slot || `${s.start_time || ''}-${s.end_time || ''}`, + start_time: s.start_time || '', + end_time: s.end_time || '', + label: `${s.start_time || ''}-${s.end_time || ''}`, available_count: s.available_count ?? (s.max_patients ?? 10), })); setTimeSlots(daySlots); @@ -105,11 +109,13 @@ export default function AppointmentCreate() { setLoading(true); try { + const selectedSlot = timeSlots.find((s) => s.label === timeSlot); await createAppointment({ patient_id: currentPatient.id, doctor_id: selectedDoctor.id, appointment_date: appointmentDate, - time_slot: timeSlot, + start_time: selectedSlot?.start_time || timeSlot, + end_time: selectedSlot?.end_time || timeSlot, reason: reason.trim() || undefined, }); Taro.showToast({ title: '预约成功', icon: 'success' }); @@ -118,7 +124,7 @@ export default function AppointmentCreate() { const tmplId = TEMPLATE_IDS.APPOINTMENT_REMINDER; if (tmplId) { try { - await Taro.requestSubscribeMessage({ tmplIds: [tmplId] }); + await (Taro.requestSubscribeMessage as any)({ tmplIds: [tmplId] }); } catch { /* 用户拒绝 */ } } setTimeout(() => Taro.navigateBack(), 1500); @@ -219,11 +225,11 @@ export default function AppointmentCreate() { {timeSlots.map((slot) => ( 0 ? () => setTimeSlot(slot.time_slot) : undefined} + className={`slot-card ${getSlotStyle(slot.available_count)} ${timeSlot === slot.label ? 'slot-selected' : ''}`} + key={slot.label} + onClick={slot.available_count > 0 ? () => setTimeSlot(slot.label) : undefined} > - {slot.time_slot} + {slot.label} {slot.available_count > 0 ? `剩余 ${slot.available_count} 位` : '已满'} ))} diff --git a/apps/miniprogram/src/pages/appointment/detail/index.tsx b/apps/miniprogram/src/pages/appointment/detail/index.tsx index ede4b59..1deb9f9 100644 --- a/apps/miniprogram/src/pages/appointment/detail/index.tsx +++ b/apps/miniprogram/src/pages/appointment/detail/index.tsx @@ -84,7 +84,7 @@ export default function AppointmentDetail() { 预约详情 - + ); } @@ -117,7 +117,7 @@ export default function AppointmentDetail() { 就诊时段 - {appointment.time_slot} + {appointment.start_time} - {appointment.end_time} 预约单号 diff --git a/apps/miniprogram/src/pages/appointment/index.tsx b/apps/miniprogram/src/pages/appointment/index.tsx index 89ec36f..3160076 100644 --- a/apps/miniprogram/src/pages/appointment/index.tsx +++ b/apps/miniprogram/src/pages/appointment/index.tsx @@ -107,7 +107,7 @@ export default function AppointmentList() { 🕐 - {item.time_slot} + {item.start_time} - {item.end_time} diff --git a/apps/miniprogram/src/pages/profile/family-add/index.tsx b/apps/miniprogram/src/pages/profile/family-add/index.tsx index 44ee07c..dad2408 100644 --- a/apps/miniprogram/src/pages/profile/family-add/index.tsx +++ b/apps/miniprogram/src/pages/profile/family-add/index.tsx @@ -19,7 +19,7 @@ export default function FamilyAdd() { const [genderIdx, setGenderIdx] = useState( editData?.gender === 'female' ? 1 : 0 ); - const [birthDate, setBirthDate] = useState(editData?.birthday || ''); + const [birthDate, setBirthDate] = useState(editData?.birth_date || ''); const [submitting, setSubmitting] = useState(false); useEffect(() => { diff --git a/apps/miniprogram/src/pages/profile/family/index.tsx b/apps/miniprogram/src/pages/profile/family/index.tsx index d49ae09..4cc4557 100644 --- a/apps/miniprogram/src/pages/profile/family/index.tsx +++ b/apps/miniprogram/src/pages/profile/family/index.tsx @@ -25,14 +25,14 @@ export default function FamilyList() { useDidShow(() => { fetchPatients(); - }, [fetchPatients]); + }); const handleSelect = (patient: Patient) => { setCurrentPatient({ id: patient.id, name: patient.name, gender: patient.gender, - birthday: patient.birthday, + birth_date: patient.birth_date, relation: patient.relation || '本人', }); Taro.showToast({ title: `已切换为 ${patient.name}`, icon: 'success' }); diff --git a/apps/miniprogram/src/services/appointment.ts b/apps/miniprogram/src/services/appointment.ts index 4bcb281..0a58168 100644 --- a/apps/miniprogram/src/services/appointment.ts +++ b/apps/miniprogram/src/services/appointment.ts @@ -6,7 +6,8 @@ export interface Appointment { doctor_name: string; department: string; appointment_date: string; - time_slot: string; + start_time: string; + end_time: string; status: string; version: number; } @@ -22,7 +23,8 @@ export interface DoctorSchedule { id: string; doctor_id: string; date: string; - time_slot: string; + start_time: string; + end_time: string; available_count: number; } @@ -39,7 +41,8 @@ export async function createAppointment(data: { doctor_id: string; schedule_id?: string; appointment_date: string; - time_slot: string; + start_time: string; + end_time: string; reason?: string; }) { return api.post('/health/appointments', data); diff --git a/apps/miniprogram/src/services/auth.ts b/apps/miniprogram/src/services/auth.ts index 7c8aed4..6ebd734 100644 --- a/apps/miniprogram/src/services/auth.ts +++ b/apps/miniprogram/src/services/auth.ts @@ -23,7 +23,7 @@ export interface PatientInfo { id: string; name: string; gender?: string; - birthday?: string; + birth_date?: string; relation: string; } @@ -42,3 +42,8 @@ export async function wechatBindPhone(openid: string, encryptedData: string, iv: export async function getPatients() { return api.get('/health/patients'); } + +/** 开发模式:用户名密码直登 */ +export async function devLogin(username: string, password: string) { + return api.post('/auth/login', { username, password }); +} diff --git a/apps/miniprogram/src/services/patient.ts b/apps/miniprogram/src/services/patient.ts index 438b424..7f6063c 100644 --- a/apps/miniprogram/src/services/patient.ts +++ b/apps/miniprogram/src/services/patient.ts @@ -4,7 +4,7 @@ export interface Patient { id: string; name: string; gender?: string; - birthday?: string; + birth_date?: string; phone?: string; id_number?: string; relation?: string; @@ -18,7 +18,7 @@ export async function listPatients() { export async function createPatient(data: { name: string; gender?: string; - birthday?: string; + birth_date?: string; phone?: string; id_number?: string; }) { @@ -28,7 +28,7 @@ export async function createPatient(data: { export interface PatientUpdateInput { name?: string; gender?: string; - birthday?: string; + birth_date?: string; phone?: string; id_number?: string; relation?: string; diff --git a/apps/miniprogram/src/stores/auth.ts b/apps/miniprogram/src/stores/auth.ts index fb31929..04cb602 100644 --- a/apps/miniprogram/src/stores/auth.ts +++ b/apps/miniprogram/src/stores/auth.ts @@ -50,11 +50,10 @@ export const useAuthStore = create((set, get) => ({ secureSet('access_token', access_token); secureSet('refresh_token', refresh_token); Taro.setStorageSync('user', user); - Taro.setStorageSync('tenant_id', user.tenant_id || ''); + Taro.setStorageSync('tenant_id', (user as any).tenant_id || ''); set({ token: access_token, refreshToken: refresh_token, user, loading: false }); return true; } - // 未绑定手机号,缓存 openid 供后续 bindPhone 使用 Taro.setStorageSync('wechat_openid', resp.openid); set({ loading: false }); return false; diff --git a/apps/miniprogram/src/utils/secure-storage.ts b/apps/miniprogram/src/utils/secure-storage.ts index 77f377b..e7adeb0 100644 --- a/apps/miniprogram/src/utils/secure-storage.ts +++ b/apps/miniprogram/src/utils/secure-storage.ts @@ -1,40 +1,32 @@ import Taro from '@tarojs/taro'; +import CryptoJS from 'crypto-js'; -const XOR_KEY = 'hms_mp_2026_secure_key'; +const ENCRYPTION_KEY = process.env.TARO_APP_ENCRYPTION_KEY || ''; -function xorTransform(value: string): string { - let result = ''; - for (let i = 0; i < value.length; i++) { - result += String.fromCharCode(value.charCodeAt(i) ^ XOR_KEY.charCodeAt(i % XOR_KEY.length)); - } - return result; +function encrypt(plaintext: string): string { + if (!ENCRYPTION_KEY) return plaintext; + return CryptoJS.AES.encrypt(plaintext, ENCRYPTION_KEY).toString(); } -function toBase64(str: string): string { - return btoa(unescape(encodeURIComponent(str))); -} - -function fromBase64(b64: string): string { +function decrypt(ciphertext: string): string { + if (!ENCRYPTION_KEY) return ciphertext; try { - return decodeURIComponent(escape(atob(b64))); + const bytes = CryptoJS.AES.decrypt(ciphertext, ENCRYPTION_KEY); + return bytes.toString(CryptoJS.enc.Utf8); } catch { return ''; } } export function secureSet(key: string, value: string): void { - const obfuscated = toBase64(xorTransform(value)); - Taro.setStorageSync(key, obfuscated); + const encrypted = encrypt(value); + Taro.setStorageSync(key, encrypted); } export function secureGet(key: string): string { const raw = Taro.getStorageSync(key); if (!raw || typeof raw !== 'string') return ''; - try { - return xorTransform(fromBase64(raw)); - } catch { - return ''; - } + return decrypt(raw); } export function secureRemove(key: string): void { diff --git a/apps/web/package.json b/apps/web/package.json index 5b4643c..31e25ed 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,15 +19,15 @@ "@xyflow/react": "^12.10.2", "antd": "^6.3.5", "axios": "^1.15.0", - "i18next": "^26.0.5", + "dayjs": "^1.11.20", "react": "^19.2.4", "react-dom": "^19.2.4", - "react-i18next": "^17.0.4", "react-router-dom": "^7.14.0", "zustand": "^5.0.12" }, "devDependencies": { "@eslint/js": "^9.39.4", + "@playwright/test": "^1.52.0", "@tailwindcss/vite": "^4.2.2", "@types/node": "^24.12.2", "@types/react": "^19.2.14", @@ -40,7 +40,6 @@ "tailwindcss": "^4.2.2", "typescript": "~6.0.2", "typescript-eslint": "^8.58.0", - "vite": "^8.0.4", - "@playwright/test": "^1.52.0" + "vite": "^8.0.4" } } diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index ca1e139..b7fb474 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: axios: specifier: ^1.15.0 version: 1.15.0 + dayjs: + specifier: ^1.11.20 + version: 1.11.20 i18next: specifier: ^26.0.5 version: 26.0.5(typescript@6.0.2) diff --git a/apps/web/src/api/errors.ts b/apps/web/src/api/errors.ts deleted file mode 100644 index 4606c22..0000000 --- a/apps/web/src/api/errors.ts +++ /dev/null @@ -1,8 +0,0 @@ -export function extractErrorMessage(err: unknown, fallback = '操作失败'): string { - if (err && typeof err === 'object' && 'response' in err) { - const resp = (err as { response?: { data?: { message?: string } } }).response; - if (resp?.data?.message) return resp.data.message; - } - if (err instanceof Error) return err.message; - return fallback; -} diff --git a/apps/web/src/components/NotificationPanel.tsx b/apps/web/src/components/NotificationPanel.tsx index bd346e5..670c93d 100644 --- a/apps/web/src/components/NotificationPanel.tsx +++ b/apps/web/src/components/NotificationPanel.tsx @@ -1,8 +1,9 @@ import { useEffect, useRef } from 'react'; -import { Badge, List, Popover, Button, Empty, Typography, theme } from 'antd'; +import { Badge, List, Popover, Button, Empty, Typography } from 'antd'; import { BellOutlined } from '@ant-design/icons'; import { useNavigate } from 'react-router-dom'; import { useMessageStore } from '../stores/message'; +import { useThemeMode } from '../hooks/useThemeMode'; const { Text } = Typography; @@ -12,8 +13,7 @@ export default function NotificationPanel() { const unreadCount = useMessageStore((s) => s.unreadCount); const recentMessages = useMessageStore((s) => s.recentMessages); const markAsRead = useMessageStore((s) => s.markAsRead); - const { token } = theme.useToken(); - const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)'; + const isDark = useThemeMode(); const initializedRef = useRef(false); useEffect(() => { diff --git a/apps/web/src/constants/health.ts b/apps/web/src/constants/health.ts new file mode 100644 index 0000000..3325fcf --- /dev/null +++ b/apps/web/src/constants/health.ts @@ -0,0 +1,26 @@ +/** + * 健康管理模块共享常量 + * + * 集中定义性别、血型、患者状态等下拉选项, + * 供 PatientList / PatientDetail 等页面复用。 + */ + +export const GENDER_OPTIONS = [ + { value: 'male', label: '男' }, + { value: 'female', label: '女' }, + { value: 'other', label: '其他' }, +]; + +export const BLOOD_TYPE_OPTIONS = [ + { value: 'A', label: 'A 型' }, + { value: 'B', label: 'B 型' }, + { value: 'AB', label: 'AB 型' }, + { value: 'O', label: 'O 型' }, +]; + +export const STATUS_OPTIONS = [ + { value: '', label: '全部状态' }, + { value: 'active', label: '活跃' }, + { value: 'inactive', label: '停用' }, + { value: 'deceased', label: '已故' }, +]; diff --git a/apps/web/src/hooks/useThemeMode.ts b/apps/web/src/hooks/useThemeMode.ts new file mode 100644 index 0000000..d10eab9 --- /dev/null +++ b/apps/web/src/hooks/useThemeMode.ts @@ -0,0 +1,15 @@ +import { theme } from 'antd'; + +/** + * 判断当前是否处于暗色主题模式。 + * + * 通过 antd design token 的 colorBgContainer 色值检测, + * 统一替代各页面中重复的 `token.colorBgContainer === '#111827'` 内联判断。 + */ +export function useThemeMode(): boolean { + const { token } = theme.useToken(); + return ( + token.colorBgContainer === '#111827' || + token.colorBgContainer === 'rgb(17, 24, 39)' + ); +} diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts deleted file mode 100644 index 4404e6f..0000000 --- a/apps/web/src/i18n/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import i18n from 'i18next'; -import { initReactI18next } from 'react-i18next'; -import zhCN from './locales/zh-CN.json'; - -i18n.use(initReactI18next).init({ - resources: { 'zh-CN': { translation: zhCN } }, - lng: 'zh-CN', - fallbackLng: 'zh-CN', - interpolation: { escapeValue: false }, -}); - -export default i18n; diff --git a/apps/web/src/i18n/locales/zh-CN.json b/apps/web/src/i18n/locales/zh-CN.json deleted file mode 100644 index 05e0e50..0000000 --- a/apps/web/src/i18n/locales/zh-CN.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "common": { - "save": "保存", - "cancel": "取消", - "delete": "删除", - "edit": "编辑", - "create": "新建", - "search": "搜索", - "confirm": "确认", - "loading": "加载中...", - "success": "操作成功", - "error": "操作失败" - }, - "auth": { - "login": { - "title": "登录", - "username": "用户名", - "password": "密码", - "submit": "登录", - "success": "登录成功", - "failed": "用户名或密码错误" - } - }, - "nav": { - "home": "首页", - "users": "用户管理", - "roles": "角色管理", - "organizations": "组织管理", - "workflow": "工作流", - "messages": "消息中心", - "settings": "系统设置", - "plugins": "插件管理" - } -} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 0a27bc3..bef5202 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,6 +1,5 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import './i18n' import './index.css' import App from './App.tsx' diff --git a/apps/web/src/pages/Home.tsx b/apps/web/src/pages/Home.tsx index 1acbc9b..2ada2a5 100644 --- a/apps/web/src/pages/Home.tsx +++ b/apps/web/src/pages/Home.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useCallback, useRef } from 'react'; -import { Row, Col, Spin, theme } from 'antd'; +import { Row, Col, Spin } from 'antd'; import { UserOutlined, SafetyCertificateOutlined, @@ -19,6 +19,7 @@ import { } from '@ant-design/icons'; import { useNavigate } from 'react-router-dom'; import client from '../api/client'; +import { useThemeMode } from '../hooks/useThemeMode'; import { useMessageStore } from '../stores/message'; interface DashboardStats { @@ -108,10 +109,9 @@ export default function Home() { const [loading, setLoading] = useState(true); const unreadCount = useMessageStore((s) => s.unreadCount); const fetchUnreadCount = useMessageStore((s) => s.fetchUnreadCount); - const { token } = theme.useToken(); const navigate = useNavigate(); - const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)'; + const isDark = useThemeMode(); useEffect(() => { let cancelled = false; diff --git a/apps/web/src/pages/Organizations.tsx b/apps/web/src/pages/Organizations.tsx index 840b074..32a0e68 100644 --- a/apps/web/src/pages/Organizations.tsx +++ b/apps/web/src/pages/Organizations.tsx @@ -12,7 +12,6 @@ import { message, Empty, Tag, - theme, } from 'antd'; import { PlusOutlined, @@ -21,6 +20,7 @@ import { ApartmentOutlined, } from '@ant-design/icons'; import type { DataNode } from 'antd/es/tree'; +import { useThemeMode } from '../hooks/useThemeMode'; import { listOrgTree, createOrg, @@ -38,8 +38,7 @@ import { } from '../api/orgs'; export default function Organizations() { - const { token } = theme.useToken(); - const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)'; + const isDark = useThemeMode(); const cardStyle = { background: isDark ? '#111827' : '#FFFFFF', diff --git a/apps/web/src/pages/PluginDashboardPage.tsx b/apps/web/src/pages/PluginDashboardPage.tsx index a3b4004..bcd18d3 100644 --- a/apps/web/src/pages/PluginDashboardPage.tsx +++ b/apps/web/src/pages/PluginDashboardPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, useMemo, useCallback } from 'react'; import { useParams } from 'react-router-dom'; -import { Row, Col, Empty, Select, theme } from 'antd'; +import { Row, Col, Empty, Select } from 'antd'; import { DashboardOutlined } from '@ant-design/icons'; import { countPluginData, aggregatePluginData, listPluginData } from '../api/pluginData'; import { @@ -19,12 +19,12 @@ import { SkeletonBreakdownCard, WidgetRenderer, } from './dashboard/DashboardWidgets'; +import { useThemeMode } from '../hooks/useThemeMode'; // ── 主组件 ── export function PluginDashboardPage() { const { pluginId } = useParams<{ pluginId: string }>(); - const { token: themeToken } = theme.useToken(); const [loading, setLoading] = useState(false); const [schemaLoading, setSchemaLoading] = useState(false); @@ -37,9 +37,7 @@ export function PluginDashboardPage() { const [widgets, setWidgets] = useState([]); const [widgetData, setWidgetData] = useState([]); const [widgetsLoading, setWidgetsLoading] = useState(false); - const isDark = - themeToken.colorBgContainer === '#111827' || - themeToken.colorBgContainer === 'rgb(17, 24, 39)'; + const isDark = useThemeMode(); // 加载 schema useEffect(() => { diff --git a/apps/web/src/pages/Roles.tsx b/apps/web/src/pages/Roles.tsx index 4e40d19..2baf3b0 100644 --- a/apps/web/src/pages/Roles.tsx +++ b/apps/web/src/pages/Roles.tsx @@ -10,7 +10,6 @@ import { Popconfirm, Checkbox, message, - theme, } from 'antd'; import { PlusOutlined, EditOutlined, DeleteOutlined, SafetyCertificateOutlined } from '@ant-design/icons'; import { @@ -24,6 +23,7 @@ import { type RoleInfo, type PermissionInfo, } from '../api/roles'; +import { useThemeMode } from '../hooks/useThemeMode'; export default function Roles() { const [roles, setRoles] = useState([]); @@ -35,8 +35,7 @@ export default function Roles() { const [selectedRole, setSelectedRole] = useState(null); const [selectedPermIds, setSelectedPermIds] = useState([]); const [form] = Form.useForm(); - const { token } = theme.useToken(); - const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)'; + const isDark = useThemeMode(); const fetchRoles = useCallback(async () => { setLoading(true); diff --git a/apps/web/src/pages/Users.tsx b/apps/web/src/pages/Users.tsx index 59b6c05..5d6f1f5 100644 --- a/apps/web/src/pages/Users.tsx +++ b/apps/web/src/pages/Users.tsx @@ -10,7 +10,6 @@ import { Popconfirm, Checkbox, message, - theme, } from 'antd'; import { PlusOutlined, @@ -33,6 +32,7 @@ import { } from '../api/users'; import { listRoles, type RoleInfo } from '../api/roles'; import type { UserInfo } from '../api/auth'; +import { useThemeMode } from '../hooks/useThemeMode'; const STATUS_COLOR_MAP: Record = { active: '#059669', @@ -65,8 +65,7 @@ export default function Users() { const [allRoles, setAllRoles] = useState([]); const [selectedRoleIds, setSelectedRoleIds] = useState([]); const [form] = Form.useForm(); - const { token } = theme.useToken(); - const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)'; + const isDark = useThemeMode(); const fetchUsers = useCallback(async (p = page) => { setLoading(true); diff --git a/apps/web/src/pages/health/ConsultationDetail.tsx b/apps/web/src/pages/health/ConsultationDetail.tsx index 82dab65..ac7bd32 100644 --- a/apps/web/src/pages/health/ConsultationDetail.tsx +++ b/apps/web/src/pages/health/ConsultationDetail.tsx @@ -1,10 +1,11 @@ import { useState, useEffect, useCallback, useRef } from 'react'; -import { Button, Input, Spin, Popconfirm, message, theme, Typography } from 'antd'; +import { Button, Input, Spin, Popconfirm, message, Typography } from 'antd'; import { SendOutlined, CloseCircleOutlined, ArrowUpOutlined } from '@ant-design/icons'; import { useParams } from 'react-router-dom'; import { consultationApi, type Session, type Message } from '../../api/health/consultations'; import { StatusTag } from './components/StatusTag'; import { ImagePreview } from './components/ImagePreview'; +import { useThemeMode } from '../../hooks/useThemeMode'; const PAGE_SIZE = 30; @@ -53,10 +54,7 @@ export default function ConsultationDetail() { const chatEndRef = useRef(null); const shouldScrollRef = useRef(true); - const { token: themeToken } = theme.useToken(); - const isDark = - themeToken.colorBgContainer === '#111827' || - themeToken.colorBgContainer === 'rgb(17, 24, 39)'; + const isDark = useThemeMode(); // --- Fetch session info --- const fetchSession = useCallback(async () => { diff --git a/apps/web/src/pages/health/ConsultationList.tsx b/apps/web/src/pages/health/ConsultationList.tsx index d4b2ee0..11499d2 100644 --- a/apps/web/src/pages/health/ConsultationList.tsx +++ b/apps/web/src/pages/health/ConsultationList.tsx @@ -8,7 +8,6 @@ import { Space, Popconfirm, message, - theme, } from 'antd'; import { PlusOutlined, CloseCircleOutlined } from '@ant-design/icons'; import type { ColumnsType, TablePaginationConfig } from 'antd/es/table'; @@ -18,6 +17,7 @@ import { StatusTag } from './components/StatusTag'; import { PatientSelect } from './components/PatientSelect'; import { DoctorSelect } from './components/DoctorSelect'; import { ExportButton } from './components/ExportButton'; +import { useThemeMode } from '../../hooks/useThemeMode'; const STATUS_OPTIONS = [ { value: 'waiting', label: '等待中' }, @@ -70,10 +70,7 @@ export default function ConsultationList() { const [patientLabels, setPatientLabels] = useState>({}); const [doctorLabels, setDoctorLabels] = useState>({}); - const { token: themeToken } = theme.useToken(); - const isDark = - themeToken.colorBgContainer === '#111827' || - themeToken.colorBgContainer === 'rgb(17, 24, 39)'; + const isDark = useThemeMode(); // --- Data fetching --- const fetchSessions = useCallback(async (params: { page: number; page_size: number; status?: string }) => { @@ -84,8 +81,9 @@ export default function ConsultationList() { setTotal(result.total); } catch { message.error('加载咨询列表失败'); + } finally { + setLoading(false); } - setLoading(false); }, []); useEffect(() => { diff --git a/apps/web/src/pages/health/FollowUpRecordList.tsx b/apps/web/src/pages/health/FollowUpRecordList.tsx index b07f699..0d65d0a 100644 --- a/apps/web/src/pages/health/FollowUpRecordList.tsx +++ b/apps/web/src/pages/health/FollowUpRecordList.tsx @@ -1,9 +1,10 @@ import { useState, useEffect, useCallback } from 'react'; -import { Table, DatePicker, message, theme } from 'antd'; +import { Table, DatePicker, message } from 'antd'; import type { ColumnsType, TablePaginationConfig } from 'antd/es/table'; import dayjs from 'dayjs'; import { followUpApi, type FollowUpRecord } from '../../api/health/followUp'; import { PatientSelect } from './components/PatientSelect'; +import { useThemeMode } from '../../hooks/useThemeMode'; const RESULT_MAP: Record = { normal: '正常', @@ -28,10 +29,7 @@ export default function FollowUpRecordList() { const [query, setQuery] = useState({ page: 1, page_size: 20 }); const [selectedPatient, setSelectedPatient] = useState(); - const { token: themeToken } = theme.useToken(); - const isDark = - themeToken.colorBgContainer === '#111827' || - themeToken.colorBgContainer === 'rgb(17, 24, 39)'; + const isDark = useThemeMode(); // --- Data fetching --- const fetchRecords = useCallback(async (params: QueryParams) => { diff --git a/apps/web/src/pages/health/FollowUpTaskList.tsx b/apps/web/src/pages/health/FollowUpTaskList.tsx index 517dbd7..0f946b1 100644 --- a/apps/web/src/pages/health/FollowUpTaskList.tsx +++ b/apps/web/src/pages/health/FollowUpTaskList.tsx @@ -10,7 +10,6 @@ import { Space, Popconfirm, message, - theme, } from 'antd'; import { PlusOutlined, EditOutlined, SwapOutlined, DeleteOutlined } from '@ant-design/icons'; import type { ColumnsType, TablePaginationConfig } from 'antd/es/table'; @@ -19,6 +18,7 @@ import { followUpApi, type FollowUpTask, type CreateFollowUpTaskReq, type Update import { StatusTag } from './components/StatusTag'; import { PatientSelect } from './components/PatientSelect'; import { DoctorSelect } from './components/DoctorSelect'; +import { useThemeMode } from '../../hooks/useThemeMode'; const STATUS_OPTIONS = [ { value: 'pending', label: '待处理' }, @@ -94,10 +94,7 @@ export default function FollowUpTaskList() { const [patientLabels, setPatientLabels] = useState>({}); const [doctorLabels, setDoctorLabels] = useState>({}); - const { token: themeToken } = theme.useToken(); - const isDark = - themeToken.colorBgContainer === '#111827' || - themeToken.colorBgContainer === 'rgb(17, 24, 39)'; + const isDark = useThemeMode(); // --- Data fetching --- const fetchTasks = useCallback(async (params: { page: number; page_size: number; status?: string }) => { diff --git a/apps/web/src/pages/health/PatientDetail.tsx b/apps/web/src/pages/health/PatientDetail.tsx index c6c0a75..b07c587 100644 --- a/apps/web/src/pages/health/PatientDetail.tsx +++ b/apps/web/src/pages/health/PatientDetail.tsx @@ -15,7 +15,6 @@ import { Tag, message, Spin, - theme, } from 'antd'; import { ArrowLeftOutlined, @@ -36,19 +35,8 @@ import { followUpApi } from '../../api/health/followUp'; import type { FollowUpRecord } from '../../api/health/followUp'; import { StatusTag } from './components/StatusTag'; import { VitalSignsChart } from './components/VitalSignsChart'; - -const GENDER_OPTIONS = [ - { value: 'male', label: '男' }, - { value: 'female', label: '女' }, - { value: 'other', label: '其他' }, -]; - -const BLOOD_TYPE_OPTIONS = [ - { value: 'A', label: 'A 型' }, - { value: 'B', label: 'B 型' }, - { value: 'AB', label: 'AB 型' }, - { value: 'O', label: 'O 型' }, -]; +import { GENDER_OPTIONS, BLOOD_TYPE_OPTIONS } from '../../constants/health'; +import { useThemeMode } from '../../hooks/useThemeMode'; const GENDER_LABEL: Record = { male: '男', @@ -63,10 +51,7 @@ export default function PatientDetail() { const [loading, setLoading] = useState(false); const [editModalOpen, setEditModalOpen] = useState(false); const [form] = Form.useForm(); - const { token } = theme.useToken(); - const isDark = - token.colorBgContainer === '#111827' || - token.colorBgContainer === 'rgb(17, 24, 39)'; + const isDark = useThemeMode(); // 健康数据子 tab 的状态 const [vitalSigns, setVitalSigns] = useState([]); diff --git a/apps/web/src/pages/health/PatientList.tsx b/apps/web/src/pages/health/PatientList.tsx index d131529..ae6d48c 100644 --- a/apps/web/src/pages/health/PatientList.tsx +++ b/apps/web/src/pages/health/PatientList.tsx @@ -11,7 +11,6 @@ import { Popconfirm, DatePicker, message, - theme, } from 'antd'; import { PlusOutlined, @@ -26,26 +25,8 @@ import type { UpdatePatientReq, } from '../../api/health/patients'; import { StatusTag } from './components/StatusTag'; - -const GENDER_OPTIONS = [ - { value: 'male', label: '男' }, - { value: 'female', label: '女' }, - { value: 'other', label: '其他' }, -]; - -const BLOOD_TYPE_OPTIONS = [ - { value: 'A', label: 'A 型' }, - { value: 'B', label: 'B 型' }, - { value: 'AB', label: 'AB 型' }, - { value: 'O', label: 'O 型' }, -]; - -const STATUS_OPTIONS = [ - { value: '', label: '全部状态' }, - { value: 'active', label: '活跃' }, - { value: 'inactive', label: '停用' }, - { value: 'deceased', label: '已故' }, -]; +import { GENDER_OPTIONS, BLOOD_TYPE_OPTIONS, STATUS_OPTIONS } from '../../constants/health'; +import { useThemeMode } from '../../hooks/useThemeMode'; export default function PatientList() { const [patients, setPatients] = useState([]); @@ -57,10 +38,7 @@ export default function PatientList() { const [modalOpen, setModalOpen] = useState(false); const [editingPatient, setEditingPatient] = useState(null); const [form] = Form.useForm(); - const { token } = theme.useToken(); - const isDark = - token.colorBgContainer === '#111827' || - token.colorBgContainer === 'rgb(17, 24, 39)'; + const isDark = useThemeMode(); const navigate = useNavigate(); const fetchPatients = useCallback( @@ -77,8 +55,9 @@ export default function PatientList() { setTotal(result.total); } catch { message.error('加载患者列表失败'); + } finally { + setLoading(false); } - setLoading(false); }, [page, searchText, statusFilter], ); diff --git a/apps/web/src/pages/health/PatientTagManage.tsx b/apps/web/src/pages/health/PatientTagManage.tsx index b7b43f0..8fef153 100644 --- a/apps/web/src/pages/health/PatientTagManage.tsx +++ b/apps/web/src/pages/health/PatientTagManage.tsx @@ -8,12 +8,12 @@ import { Tag, Card, message, - theme, Typography, } from 'antd'; import { TagsOutlined, AppstoreOutlined } from '@ant-design/icons'; import { patientApi } from '../../api/health/patients'; import type { PatientListItem } from '../../api/health/patients'; +import { useThemeMode } from '../../hooks/useThemeMode'; export default function PatientTagManage() { const [patients, setPatients] = useState([]); @@ -24,10 +24,7 @@ export default function PatientTagManage() { const [selectedPatient, setSelectedPatient] = useState(null); const [tagInput, setTagInput] = useState(''); const [saving, setSaving] = useState(false); - const { token } = theme.useToken(); - const isDark = - token.colorBgContainer === '#111827' || - token.colorBgContainer === 'rgb(17, 24, 39)'; + const isDark = useThemeMode(); const fetchPatients = useCallback( async (p = page) => { @@ -38,8 +35,9 @@ export default function PatientTagManage() { setTotal(result.total); } catch { message.error('加载患者列表失败'); + } finally { + setLoading(false); } - setLoading(false); }, [page], ); @@ -69,8 +67,9 @@ export default function PatientTagManage() { fetchPatients(); } catch { message.error('标签更新失败'); + } finally { + setSaving(false); } - setSaving(false); }; const columns = [ diff --git a/apps/web/src/pages/messages/MessageTemplates.tsx b/apps/web/src/pages/messages/MessageTemplates.tsx index 90f2190..5db289a 100644 --- a/apps/web/src/pages/messages/MessageTemplates.tsx +++ b/apps/web/src/pages/messages/MessageTemplates.tsx @@ -1,8 +1,9 @@ import { useEffect, useState, useCallback } from 'react'; -import { Table, Button, Modal, Form, Input, Select, message, theme, Tag } from 'antd'; +import { Table, Button, Modal, Form, Input, Select, message, Tag } from 'antd'; import { PlusOutlined } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; import { listTemplates, createTemplate, type MessageTemplateInfo } from '../../api/messageTemplates'; +import { useThemeMode } from '../../hooks/useThemeMode'; const channelMap: Record = { in_app: { label: '站内', color: '#2563eb' }, @@ -18,8 +19,7 @@ export default function MessageTemplates() { const [loading, setLoading] = useState(false); const [modalOpen, setModalOpen] = useState(false); const [form] = Form.useForm(); - const { token } = theme.useToken(); - const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)'; + const isDark = useThemeMode(); const fetchData = useCallback(async (p = page) => { setLoading(true); diff --git a/apps/web/src/pages/messages/NotificationList.tsx b/apps/web/src/pages/messages/NotificationList.tsx index e6807c5..cf28687 100644 --- a/apps/web/src/pages/messages/NotificationList.tsx +++ b/apps/web/src/pages/messages/NotificationList.tsx @@ -1,8 +1,9 @@ import { useEffect, useState, useMemo, useCallback, useRef } from 'react'; -import { Table, Button, Tag, Space, Modal, Typography, message, theme } from 'antd'; +import { Table, Button, Tag, Space, Modal, Typography, message } from 'antd'; import { CheckOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; import { listMessages, markRead, markAllRead, deleteMessage, type MessageInfo, type MessageQuery } from '../../api/messages'; +import { useThemeMode } from '../../hooks/useThemeMode'; const { Paragraph } = Typography; @@ -21,8 +22,7 @@ export default function NotificationList({ queryFilter }: Props) { const [total, setTotal] = useState(0); const [page, setPage] = useState(1); const [loading, setLoading] = useState(false); - const { token } = theme.useToken(); - const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)'; + const isDark = useThemeMode(); const fetchData = useCallback(async (p = page, filter?: MessageQuery) => { setLoading(true); diff --git a/apps/web/src/pages/messages/NotificationPreferences.tsx b/apps/web/src/pages/messages/NotificationPreferences.tsx index 2133890..4bdb339 100644 --- a/apps/web/src/pages/messages/NotificationPreferences.tsx +++ b/apps/web/src/pages/messages/NotificationPreferences.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from 'react'; -import { Form, Switch, TimePicker, Button, message, theme } from 'antd'; +import { Form, Switch, TimePicker, Button, message } from 'antd'; import { BellOutlined } from '@ant-design/icons'; import client from '../../api/client'; +import { useThemeMode } from '../../hooks/useThemeMode'; interface PreferencesData { dnd_enabled: boolean; @@ -13,8 +14,7 @@ export default function NotificationPreferences() { const [form] = Form.useForm(); const [loading, setLoading] = useState(false); const [dndEnabled, setDndEnabled] = useState(false); - const { token } = theme.useToken(); - const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)'; + const isDark = useThemeMode(); useEffect(() => { form.setFieldsValue({ dnd_enabled: false }); diff --git a/apps/web/src/pages/settings/AuditLogViewer.tsx b/apps/web/src/pages/settings/AuditLogViewer.tsx index a1a9568..e1aa37f 100644 --- a/apps/web/src/pages/settings/AuditLogViewer.tsx +++ b/apps/web/src/pages/settings/AuditLogViewer.tsx @@ -1,7 +1,8 @@ import { useState, useEffect, useCallback } from 'react'; -import { Table, Select, Input, Tag, message, theme } from 'antd'; +import { Table, Select, Input, Tag, message } from 'antd'; import type { ColumnsType, TablePaginationConfig } from 'antd/es/table'; import { listAuditLogs, type AuditLogItem, type AuditLogQuery } from '../../api/auditLogs'; +import { useThemeMode } from '../../hooks/useThemeMode'; const RESOURCE_TYPE_OPTIONS = [ { value: 'user', label: '用户' }, @@ -38,8 +39,7 @@ export default function AuditLogViewer() { const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); const [query, setQuery] = useState({ page: 1, page_size: 20 }); - const { token } = theme.useToken(); - const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)'; + const isDark = useThemeMode(); const fetchLogs = useCallback(async (params: AuditLogQuery) => { setLoading(true); diff --git a/apps/web/src/pages/settings/SystemSettings.tsx b/apps/web/src/pages/settings/SystemSettings.tsx index 5b13078..ec95275 100644 --- a/apps/web/src/pages/settings/SystemSettings.tsx +++ b/apps/web/src/pages/settings/SystemSettings.tsx @@ -9,7 +9,6 @@ import { Table, Modal, Tag, - theme, } from 'antd'; import { PlusOutlined, SearchOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; import { @@ -17,6 +16,7 @@ import { updateSetting, deleteSetting, } from '../../api/settings'; +import { useThemeMode } from '../../hooks/useThemeMode'; interface SettingEntry { key: string; @@ -29,8 +29,7 @@ export default function SystemSettings() { const [modalOpen, setModalOpen] = useState(false); const [editEntry, setEditEntry] = useState(null); const [form] = Form.useForm(); - const { token } = theme.useToken(); - const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)'; + const isDark = useThemeMode(); const handleSearch = async () => { if (!searchKey.trim()) { diff --git a/apps/web/src/pages/workflow/CompletedTasks.tsx b/apps/web/src/pages/workflow/CompletedTasks.tsx index b7f6bc6..b0def2e 100644 --- a/apps/web/src/pages/workflow/CompletedTasks.tsx +++ b/apps/web/src/pages/workflow/CompletedTasks.tsx @@ -1,7 +1,8 @@ import { useEffect, useCallback, useState } from 'react'; -import { Table, Tag, theme } from 'antd'; +import { Table, Tag } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { listCompletedTasks, type TaskInfo } from '../../api/workflowTasks'; +import { useThemeMode } from '../../hooks/useThemeMode'; const outcomeStyles: Record = { approved: { bg: '#ECFDF5', color: '#059669', text: '同意' }, @@ -14,8 +15,7 @@ export default function CompletedTasks() { const [total, setTotal] = useState(0); const [page, setPage] = useState(1); const [loading, setLoading] = useState(false); - const { token } = theme.useToken(); - const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)'; + const isDark = useThemeMode(); const fetchData = useCallback(async () => { setLoading(true); diff --git a/apps/web/src/pages/workflow/InstanceMonitor.tsx b/apps/web/src/pages/workflow/InstanceMonitor.tsx index b0f0ec9..2fe3075 100644 --- a/apps/web/src/pages/workflow/InstanceMonitor.tsx +++ b/apps/web/src/pages/workflow/InstanceMonitor.tsx @@ -1,5 +1,5 @@ import { useEffect, useCallback, useState } from 'react'; -import { Button, message, Modal, Table, Tag, theme } from 'antd'; +import { Button, message, Modal, Table, Tag } from 'antd'; import { EyeOutlined, PauseCircleOutlined, PlayCircleOutlined, StopOutlined } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; import { @@ -11,6 +11,7 @@ import { } from '../../api/workflowInstances'; import { getProcessDefinition, type NodeDef, type EdgeDef } from '../../api/workflowDefinitions'; import ProcessViewer from './ProcessViewer'; +import { useThemeMode } from '../../hooks/useThemeMode'; const statusStyles: Record = { running: { bg: '#eff6ff', color: '#2563eb', text: '运行中' }, @@ -30,8 +31,7 @@ export default function InstanceMonitor() { const [viewerEdges, setViewerEdges] = useState([]); const [activeNodeIds, setActiveNodeIds] = useState([]); const [viewerLoading, setViewerLoading] = useState(false); - const { token } = theme.useToken(); - const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)'; + const isDark = useThemeMode(); const fetchData = useCallback(async () => { setLoading(true); diff --git a/apps/web/src/pages/workflow/PendingTasks.tsx b/apps/web/src/pages/workflow/PendingTasks.tsx index 579068b..3475346 100644 --- a/apps/web/src/pages/workflow/PendingTasks.tsx +++ b/apps/web/src/pages/workflow/PendingTasks.tsx @@ -1,5 +1,5 @@ import { useEffect, useCallback, useState } from 'react'; -import { Button, Input, message, Modal, Space, Table, Tag, theme } from 'antd'; +import { Button, Input, message, Modal, Space, Table, Tag } from 'antd'; import { CheckOutlined, CloseOutlined, SendOutlined } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; import { @@ -8,6 +8,7 @@ import { delegateTask, type TaskInfo, } from '../../api/workflowTasks'; +import { useThemeMode } from '../../hooks/useThemeMode'; export default function PendingTasks() { const [data, setData] = useState([]); @@ -18,8 +19,7 @@ export default function PendingTasks() { const [outcome, setOutcome] = useState('approved'); const [delegateModal, setDelegateModal] = useState(null); const [delegateTo, setDelegateTo] = useState(''); - const { token } = theme.useToken(); - const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)'; + const isDark = useThemeMode(); const fetchData = useCallback(async () => { setLoading(true); diff --git a/apps/web/src/pages/workflow/ProcessDefinitions.tsx b/apps/web/src/pages/workflow/ProcessDefinitions.tsx index 2676cbe..5894a92 100644 --- a/apps/web/src/pages/workflow/ProcessDefinitions.tsx +++ b/apps/web/src/pages/workflow/ProcessDefinitions.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useCallback } from 'react'; -import { Button, message, Modal, Space, Table, Tag, theme } from 'antd'; +import { Button, message, Modal, Space, Table, Tag } from 'antd'; import { PlusOutlined } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; import { @@ -11,6 +11,7 @@ import { type CreateProcessDefinitionRequest, } from '../../api/workflowDefinitions'; import ProcessDesigner from './ProcessDesigner'; +import { useThemeMode } from '../../hooks/useThemeMode'; const statusColors: Record = { draft: { bg: '#f8fafc', color: '#475569', text: '草稿' }, @@ -25,8 +26,7 @@ export default function ProcessDefinitions() { const [loading, setLoading] = useState(false); const [designerOpen, setDesignerOpen] = useState(false); const [editingId, setEditingId] = useState(null); - const { token } = theme.useToken(); - const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)'; + const isDark = useThemeMode(); const fetchData = useCallback(async (p = page) => { setLoading(true); diff --git a/crates/erp-auth/Cargo.toml b/crates/erp-auth/Cargo.toml index 1bb4d83..a975441 100644 --- a/crates/erp-auth/Cargo.toml +++ b/crates/erp-auth/Cargo.toml @@ -13,7 +13,6 @@ chrono.workspace = true axum.workspace = true sea-orm.workspace = true tracing.workspace = true -anyhow.workspace = true thiserror.workspace = true jsonwebtoken.workspace = true argon2.workspace = true diff --git a/crates/erp-auth/src/handler/wechat_handler.rs b/crates/erp-auth/src/handler/wechat_handler.rs index 6d5b0a6..6f936cd 100644 --- a/crates/erp-auth/src/handler/wechat_handler.rs +++ b/crates/erp-auth/src/handler/wechat_handler.rs @@ -34,8 +34,18 @@ where req.validate() .map_err(|e| AppError::Validation(e.to_string()))?; + tracing::info!( + code = %req.code, + tenant_id = %state.default_tenant_id, + appid_len = state.wechat_appid.len(), + secret_len = state.wechat_secret.len(), + "微信登录请求" + ); + + // TODO: 多租户微信登录需要设计租户解析策略(如 per-appid 映射或登录后选择租户) let tenant_id = state.default_tenant_id; let resp = WechatService::login(&state, tenant_id, &req.code).await?; + tracing::info!(bound = resp.bound, has_token = resp.token.is_some(), "微信登录结果"); Ok(Json(ApiResponse::ok(resp))) } @@ -63,6 +73,7 @@ where req.validate() .map_err(|e| AppError::Validation(e.to_string()))?; + // TODO: 多租户微信登录需要设计租户解析策略 let tenant_id = state.default_tenant_id; let resp = WechatService::bind_phone( &state, diff --git a/crates/erp-auth/src/service/wechat_service.rs b/crates/erp-auth/src/service/wechat_service.rs index 390bcb1..777cea1 100644 --- a/crates/erp-auth/src/service/wechat_service.rs +++ b/crates/erp-auth/src/service/wechat_service.rs @@ -18,6 +18,7 @@ use crate::entity::wechat_user; use crate::error::{AuthError, AuthResult}; use crate::service::auth_service::JwtConfig; use crate::service::token_service::TokenService; +use erp_core::sanitize::sanitize_string; type Aes128CbcDec = Decryptor; @@ -52,6 +53,11 @@ impl WechatService { tenant_id: Uuid, code: &str, ) -> AuthResult { + tracing::info!( + appid = %state.wechat_appid, + code = %code, + "fetch_session 开始" + ); let session = fetch_session(&state.wechat_appid, &state.wechat_secret, code).await?; let openid = session @@ -209,7 +215,7 @@ impl WechatService { id: Set(user_id), tenant_id: Set(tenant_id), username: Set(format!("wx_{}", suffix)), - display_name: Set(Some(format!("微信用户{}", suffix))), + display_name: Set(Some(sanitize_string(&format!("微信用户{}", suffix)))), phone: Set(Some(phone.to_string())), email: Set(None), avatar_url: Set(None), @@ -360,6 +366,7 @@ async fn fetch_session( if let Some(errcode) = session.errcode { if errcode != 0 { let msg = session.errmsg.clone().unwrap_or_default(); + tracing::error!(errcode, errmsg = %msg, "微信 jscode2session 返回错误"); return Err(AuthError::Validation(format!( "微信登录失败 ({}): {}", errcode, msg @@ -367,5 +374,11 @@ async fn fetch_session( } } + tracing::info!( + has_openid = session.openid.is_some(), + has_session_key = session.session_key.is_some(), + "微信 jscode2session 成功" + ); + Ok(session) } diff --git a/crates/erp-health/src/service/masking.rs b/crates/erp-health/src/service/masking.rs new file mode 100644 index 0000000..fb190cf --- /dev/null +++ b/crates/erp-health/src/service/masking.rs @@ -0,0 +1,157 @@ +//! 数据脱敏和状态转换验证 + +use crate::error::{HealthError, HealthResult}; + +/// 身份证号脱敏: 保留前 3 位和后 4 位,中间用 * 替代 +pub fn mask_id_number(s: &str) -> String { + if s.len() >= 7 { + format!("{}****{}", &s[..3], &s[s.len() - 4..]) + } else { + "****".to_string() + } +} + +/// 手机号脱敏: 保留前 3 位和后 4 位,中间用 * 替代 +pub fn mask_phone(s: Option<&str>) -> Option { + s.map(|p| { + if p.len() >= 7 { + format!("{}****{}", &p[..3], &p[p.len() - 4..]) + } else { + "****".to_string() + } + }) +} + +/// 状态机转换校验: 检查 (current → new) 是否在 allowed_transitions 中 +pub fn validate_status_transition( + field_name: &str, + current: &str, + new_status: &str, + allowed_transitions: &[(&str, &str)], +) -> HealthResult<()> { + if current == new_status { + return Ok(()); + } + if allowed_transitions + .iter() + .any(|(from, to)| *from == current && *to == new_status) + { + Ok(()) + } else { + Err(HealthError::InvalidStatusTransition(format!( + "{}: 不允许从 '{}' 转换到 '{}'", + field_name, current, new_status + ))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mask_id_18_digits() { + assert_eq!("110****1234", mask_id_number("110101199001011234")); + } + + #[test] + fn mask_id_15_digits() { + assert_eq!("123****2345", mask_id_number("123456789012345")); + } + + #[test] + fn mask_id_7_chars() { + assert_eq!("123****4567", mask_id_number("1234567")); + } + + #[test] + fn mask_id_short() { + assert_eq!("****", mask_id_number("123456")); + } + + #[test] + fn mask_id_empty() { + assert_eq!("****", mask_id_number("")); + } + + #[test] + fn mask_phone_normal() { + assert_eq!( + Some("138****5678".to_string()), + mask_phone(Some("13812345678")) + ); + } + + #[test] + fn mask_phone_7_chars() { + assert_eq!( + Some("123****4567".to_string()), + mask_phone(Some("1234567")) + ); + } + + #[test] + fn mask_phone_short() { + assert_eq!(Some("****".to_string()), mask_phone(Some("123456"))); + } + + #[test] + fn mask_phone_none() { + assert_eq!(None, mask_phone(None)); + } + + #[test] + fn patient_active_to_inactive() { + assert!(validate_status_transition( + "patient.status", + "active", + "inactive", + &[("active", "inactive"), ("active", "deceased"), ("inactive", "active")] + ) + .is_ok()); + } + + #[test] + fn patient_deceased_to_active_fails() { + assert!(validate_status_transition( + "patient.status", + "deceased", + "active", + &[("active", "inactive"), ("active", "deceased"), ("inactive", "active")] + ) + .is_err()); + } + + #[test] + fn patient_same_status_ok() { + assert!(validate_status_transition( + "patient.status", + "active", + "active", + &[("active", "inactive")] + ) + .is_ok()); + } + + #[test] + fn verification_pending_to_verified() { + assert!(validate_status_transition( + "patient.verification_status", + "pending", + "verified", + &[("pending", "verified"), ("pending", "rejected"), ("rejected", "pending")] + ) + .is_ok()); + } + + #[test] + fn verification_verified_to_pending_fails() { + assert!(validate_status_transition( + "patient.verification_status", + "verified", + "pending", + &[("pending", "verified"), ("pending", "rejected"), ("rejected", "pending")] + ) + .is_err()); + } +} diff --git a/crates/erp-health/src/service/mod.rs b/crates/erp-health/src/service/mod.rs index cc27787..1a42e3e 100644 --- a/crates/erp-health/src/service/mod.rs +++ b/crates/erp-health/src/service/mod.rs @@ -4,6 +4,7 @@ pub mod consultation_service; pub mod doctor_service; pub mod follow_up_service; pub mod health_data_service; +pub mod masking; pub mod patient_service; pub mod seed; pub mod trend_service; diff --git a/crates/erp-health/src/service/patient_service.rs b/crates/erp-health/src/service/patient_service.rs index 92ab447..52c0cc5 100644 --- a/crates/erp-health/src/service/patient_service.rs +++ b/crates/erp-health/src/service/patient_service.rs @@ -19,6 +19,7 @@ use crate::entity::patient_tag_relation; use crate::entity::patient_doctor_relation; use crate::error::{HealthError, HealthResult}; use crate::service::validation::{validate_gender, validate_blood_type, validate_patient_status, validate_verification_status}; +use crate::service::masking::{mask_id_number, mask_phone, validate_status_transition}; use crate::state::HealthState; // --------------------------------------------------------------------------- @@ -744,40 +745,3 @@ fn model_to_resp_decrypted(crypto: &crate::crypto::HealthCrypto, m: patient::Mod } } -fn mask_id_number(s: &str) -> String { - if s.len() >= 7 { - format!("{}****{}", &s[..3], &s[s.len() - 4..]) - } else { - "****".to_string() - } -} - -fn mask_phone(s: Option<&str>) -> Option { - s.map(|p| { - if p.len() >= 7 { - format!("{}****{}", &p[..3], &p[p.len() - 4..]) - } else { - "****".to_string() - } - }) -} - -/// 状态机转换校验: 检查 (current → new) 是否在 allowed_transitions 中 -fn validate_status_transition( - field_name: &str, - current: &str, - new_status: &str, - allowed_transitions: &[(&str, &str)], -) -> HealthResult<()> { - if current == new_status { - return Ok(()); - } - if allowed_transitions.iter().any(|(from, to)| *from == current && *to == new_status) { - Ok(()) - } else { - Err(HealthError::InvalidStatusTransition(format!( - "{}: 不允许从 '{}' 转换到 '{}'", - field_name, current, new_status - ))) - } -} diff --git a/crates/erp-server/config/default.toml b/crates/erp-server/config/default.toml index 5dd245f..1f226d6 100644 --- a/crates/erp-server/config/default.toml +++ b/crates/erp-server/config/default.toml @@ -26,5 +26,9 @@ level = "info" allowed_origins = "http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176,http://localhost:3000" [wechat] -appid = "wx20f4ef9cc2ec66c5" -secret = "096ba4fa828e7b1fa7de2235eb6c7836" +appid = "__MUST_SET_VIA_ENV__" +secret = "__MUST_SET_VIA_ENV__" + +[health] +aes_key = "__MUST_SET_VIA_ENV__" +hmac_key = "__MUST_SET_VIA_ENV__" diff --git a/crates/erp-server/src/config.rs b/crates/erp-server/src/config.rs index a52c3e8..642f136 100644 --- a/crates/erp-server/src/config.rs +++ b/crates/erp-server/src/config.rs @@ -10,6 +10,7 @@ pub struct AppConfig { pub log: LogConfig, pub cors: CorsConfig, pub wechat: WechatConfig, + pub health: HealthConfig, } #[derive(Debug, Clone, Deserialize)] @@ -60,6 +61,14 @@ pub struct WechatConfig { pub secret: String, } +#[derive(Debug, Clone, Deserialize)] +pub struct HealthConfig { + /// AES-256 密钥 (64 字符 hex 编码,32 字节) + pub aes_key: String, + /// HMAC-SHA256 密钥 (64 字符 hex 编码,32 字节) + pub hmac_key: String, +} + impl AppConfig { pub fn load() -> anyhow::Result { let config = config::Config::builder() diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs index a0ea542..003da2d 100644 --- a/crates/erp-server/src/main.rs +++ b/crates/erp-server/src/main.rs @@ -205,6 +205,22 @@ async fn main() -> anyhow::Result<()> { ); std::process::exit(1); } + if config.wechat.appid == "__MUST_SET_VIA_ENV__" || config.wechat.secret == "__MUST_SET_VIA_ENV__" { + tracing::error!( + "微信凭据为默认占位值,拒绝启动。请设置环境变量 ERP__WECHAT__APPID 和 ERP__WECHAT__SECRET" + ); + std::process::exit(1); + } + if config.health.aes_key == "__MUST_SET_VIA_ENV__" || config.health.hmac_key == "__MUST_SET_VIA_ENV__" { + tracing::error!( + "健康数据加密密钥为默认占位值,拒绝启动。请设置环境变量 ERP__HEALTH__AES_KEY 和 ERP__HEALTH__HMAC_KEY(64 字符 hex 编码)" + ); + std::process::exit(1); + } + if let Err(e) = erp_health::HealthCrypto::from_keys(&config.health.aes_key, &config.health.hmac_key) { + tracing::error!("健康数据加密密钥无效: {}。密钥必须为 64 字符 hex 编码(32 字节)", e); + std::process::exit(1); + } // Initialize tracing tracing_subscriber::fmt() diff --git a/crates/erp-server/src/state.rs b/crates/erp-server/src/state.rs index 3d783e6..a9b9e65 100644 --- a/crates/erp-server/src/state.rs +++ b/crates/erp-server/src/state.rs @@ -102,10 +102,16 @@ impl FromRef for erp_plugin::state::PluginState { /// Allow erp-health handlers to extract their required state. impl FromRef for erp_health::HealthState { fn from_ref(state: &AppState) -> Self { + let crypto = erp_health::HealthCrypto::from_keys( + &state.config.health.aes_key, + &state.config.health.hmac_key, + ) + .expect("Health encryption keys must be valid 32-byte hex strings. Set ERP__HEALTH__AES_KEY and ERP__HEALTH__HMAC_KEY"); + Self { db: state.db.clone(), event_bus: state.event_bus.clone(), - crypto: erp_health::HealthCrypto::dev_default(), + crypto, } } } diff --git a/crates/erp-server/tests/integration.rs b/crates/erp-server/tests/integration.rs index 4bee8b7..c626a53 100644 --- a/crates/erp-server/tests/integration.rs +++ b/crates/erp-server/tests/integration.rs @@ -6,3 +6,5 @@ mod auth_tests; mod plugin_tests; #[path = "integration/workflow_tests.rs"] mod workflow_tests; +#[path = "integration/health_patient_tests.rs"] +mod health_patient_tests; diff --git a/crates/erp-server/tests/integration/health_patient_tests.rs b/crates/erp-server/tests/integration/health_patient_tests.rs new file mode 100644 index 0000000..4730a81 --- /dev/null +++ b/crates/erp-server/tests/integration/health_patient_tests.rs @@ -0,0 +1,208 @@ +//! erp-health 患者管理集成测试 +//! +//! 验证患者 CRUD、租户隔离、字段校验、软删除等核心行为。 +//! 使用 TestDb 创建隔离 PostgreSQL 数据库,直接调用 service 层函数。 + +use erp_core::events::EventBus; +use erp_health::dto::patient_dto::CreatePatientReq; +use erp_health::service::patient_service; +use erp_health::state::HealthState; +use erp_health::HealthCrypto; + +use super::test_db::TestDb; + +/// 构建测试用 HealthState +fn make_state(db: &sea_orm::DatabaseConnection) -> HealthState { + HealthState { + db: db.clone(), + event_bus: EventBus::new(100), + crypto: HealthCrypto::dev_default(), + } +} + +#[tokio::test] +async fn test_create_patient() { + let test_db = TestDb::new().await; + let state = make_state(test_db.db()); + let tenant_id = uuid::Uuid::new_v4(); + let operator_id = uuid::Uuid::new_v4(); + + let req = CreatePatientReq { + name: "张三".to_string(), + gender: Some("male".to_string()), + birth_date: Some(chrono::NaiveDate::from_ymd_opt(1990, 1, 15).unwrap()), + blood_type: Some("A".to_string()), + id_number: Some("110101199001151234".to_string()), + allergy_history: Some("青霉素过敏".to_string()), + medical_history_summary: None, + emergency_contact_name: Some("李四".to_string()), + emergency_contact_phone: Some("13800138000".to_string()), + source: Some("offline".to_string()), + notes: None, + }; + + let patient = patient_service::create_patient(&state, tenant_id, Some(operator_id), req) + .await + .expect("创建患者应成功"); + + assert_eq!(patient.name, "张三"); + assert_eq!(patient.gender, Some("male".to_string())); + assert_eq!(patient.status, "active"); + assert_eq!(patient.verification_status, "pending"); + assert_eq!(patient.version, 1); + assert!(patient.id_number.is_none(), "列表视图不应返回身份证号明文"); + + // 通过 get_patient 验证存储正确 + let found = patient_service::get_patient(&state, tenant_id, patient.id) + .await + .expect("查询患者应成功"); + assert_eq!(found.name, "张三"); + assert_eq!(found.gender, Some("male".to_string())); +} + +#[tokio::test] +async fn test_list_patients() { + let test_db = TestDb::new().await; + let state = make_state(test_db.db()); + let tenant_id = uuid::Uuid::new_v4(); + + // 创建 2 个患者 + for i in 0..2 { + let req = CreatePatientReq { + name: format!("患者{}", i + 1), + gender: if i == 0 { Some("male".to_string()) } else { Some("female".to_string()) }, + birth_date: None, + blood_type: None, + id_number: None, + allergy_history: None, + medical_history_summary: None, + emergency_contact_name: None, + emergency_contact_phone: None, + source: None, + notes: None, + }; + patient_service::create_patient(&state, tenant_id, None, req) + .await + .expect("创建患者应成功"); + } + + let result = patient_service::list_patients(&state, tenant_id, 1, 10, None, None) + .await + .expect("列表查询应成功"); + + assert_eq!(result.total, 2, "应有 2 条患者记录"); + assert_eq!(result.data.len(), 2, "当前页应返回 2 条"); +} + +#[tokio::test] +async fn test_patient_tenant_isolation() { + let test_db = TestDb::new().await; + let state = make_state(test_db.db()); + let tenant_a = uuid::Uuid::new_v4(); + let tenant_b = uuid::Uuid::new_v4(); + + // 租户 A 创建患者 + let req_a = CreatePatientReq { + name: "租户A患者".to_string(), + gender: Some("male".to_string()), + birth_date: None, + blood_type: None, + id_number: None, + allergy_history: None, + medical_history_summary: None, + emergency_contact_name: None, + emergency_contact_phone: None, + source: None, + notes: None, + }; + let patient_a = patient_service::create_patient(&state, tenant_a, None, req_a) + .await + .expect("租户 A 创建患者应成功"); + + // 租户 B 列表查询应看不到租户 A 的患者 + let result_b = patient_service::list_patients(&state, tenant_b, 1, 10, None, None) + .await + .expect("租户 B 列表查询应成功"); + assert_eq!(result_b.total, 0, "租户 B 不应看到租户 A 的患者"); + assert!(result_b.data.is_empty()); + + // 租户 B 通过 ID 查询租户 A 的患者应返回 PatientNotFound + let lookup_result = patient_service::get_patient(&state, tenant_b, patient_a.id).await; + assert!( + lookup_result.is_err(), + "跨租户查询应返回错误" + ); +} + +#[tokio::test] +async fn test_patient_validation_gender() { + let test_db = TestDb::new().await; + let state = make_state(test_db.db()); + let tenant_id = uuid::Uuid::new_v4(); + + let req = CreatePatientReq { + name: "无效性别患者".to_string(), + gender: Some("unknown".to_string()), // 不在白名单 ["male", "female", "other"] 中 + birth_date: None, + blood_type: None, + id_number: None, + allergy_history: None, + medical_history_summary: None, + emergency_contact_name: None, + emergency_contact_phone: None, + source: None, + notes: None, + }; + + let result = patient_service::create_patient(&state, tenant_id, None, req).await; + assert!(result.is_err(), "无效性别应返回校验错误"); + + // 验证错误消息包含字段名 + let err_msg = format!("{:#}", result.unwrap_err()); + assert!( + err_msg.contains("gender"), + "错误消息应包含 'gender' 字段名,实际: {}", + err_msg + ); +} + +#[tokio::test] +async fn test_patient_soft_delete() { + let test_db = TestDb::new().await; + let state = make_state(test_db.db()); + let tenant_id = uuid::Uuid::new_v4(); + + // 创建患者 + let req = CreatePatientReq { + name: "待删除患者".to_string(), + gender: Some("other".to_string()), + birth_date: None, + blood_type: None, + id_number: None, + allergy_history: None, + medical_history_summary: None, + emergency_contact_name: None, + emergency_contact_phone: None, + source: None, + notes: None, + }; + let patient = patient_service::create_patient(&state, tenant_id, None, req) + .await + .expect("创建患者应成功"); + + // 软删除 + patient_service::delete_patient(&state, tenant_id, patient.id, None, patient.version) + .await + .expect("软删除应成功"); + + // 列表不应包含已软删除的患者 + let result = patient_service::list_patients(&state, tenant_id, 1, 10, None, None) + .await + .expect("列表查询应成功"); + assert_eq!(result.total, 0, "软删除后列表应为空"); + assert!(result.data.is_empty()); + + // get_patient 也应返回 PatientNotFound + let lookup = patient_service::get_patient(&state, tenant_id, patient.id).await; + assert!(lookup.is_err(), "软删除后查询应返回错误"); +} diff --git a/crates/erp-server/tests/integration/test_db.rs b/crates/erp-server/tests/integration/test_db.rs index e9388e0..a5b57d1 100644 --- a/crates/erp-server/tests/integration/test_db.rs +++ b/crates/erp-server/tests/integration/test_db.rs @@ -15,9 +15,10 @@ impl TestDb { pub async fn new() -> Self { let db_name = format!("erp_test_{}", uuid::Uuid::now_v7().simple()); - // 连接本地 PostgreSQL 的默认库(postgres)来创建测试库 - let admin_url = "postgres://postgres:123123@localhost:5432/postgres"; - let admin_db = Database::connect(admin_url) + let admin_url = std::env::var("TEST_DB_URL") + .unwrap_or_else(|_| "postgres://postgres:123123@localhost:5432/postgres".to_string()); + + let admin_db = Database::connect(&admin_url) .await .expect("连接本地 PostgreSQL 失败,请确认服务正在运行"); @@ -31,8 +32,12 @@ impl TestDb { drop(admin_db); - // 连接测试库 - let test_url = format!("postgres://postgres:123123@localhost:5432/{}", db_name); + // 从 admin_url 推导测试库 URL(替换路径部分) + let test_url = if let Some(pos) = admin_url.rfind('/') { + format!("{}/{}", &admin_url[..pos], db_name) + } else { + format!("postgres://postgres:123123@localhost:5432/{}", db_name) + }; let db = Database::connect(&test_url) .await .expect("连接测试数据库失败"); @@ -63,8 +68,9 @@ impl Drop for TestDb { .build(); if let Ok(rt) = rt { rt.block_on(async { - let admin_url = "postgres://postgres:123123@localhost:5432/postgres"; - if let Ok(admin_db) = Database::connect(admin_url).await { + let admin_url = std::env::var("TEST_DB_URL") + .unwrap_or_else(|_| "postgres://postgres:123123@localhost:5432/postgres".to_string()); + if let Ok(admin_db) = Database::connect(&admin_url).await { let disconnect_sql = format!( "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{}'", db_name diff --git a/wiki/infrastructure.md b/wiki/infrastructure.md index 629b185..e245c9f 100644 --- a/wiki/infrastructure.md +++ b/wiki/infrastructure.md @@ -35,6 +35,7 @@ tags: [infrastructure, dev-environment, windows, postgresql] | Redis 7 | `redis://:redis_KBCYJk@129.204.154.246:6379` (云端) | 缓存 + 限流 | | 后端 API | `http://localhost:3000/api/v1` | Axum 服务 | | 前端 SPA | `http://localhost:5174` | Vite 开发服务器 | +| 微信小程序 | 微信开发者工具 | 患者端小程序(`apps/miniprogram/dist/`) | ### 登录凭据 @@ -52,8 +53,25 @@ psql: `D:\postgreSQL\bin\psql.exe -U postgres -h localhost -d erp` | `ERP__JWT__SECRET` | `dev-secret-key-change-in-prod` | | `ERP__AUTH__SUPER_ADMIN_PASSWORD` | `Admin@2026` | | `ERP__REDIS__URL` | `redis://:redis_KBCYJk@129.204.154.246:6379` | +| `ERP__WECHAT__APPID` | `wx20f4ef9cc2ec66c5` | +| `ERP__WECHAT__SECRET` | 微信小程序 Secret | +| `ERP__HEALTH__AES_KEY` | 64 字符 hex 编码(32 字节) | +| `ERP__HEALTH__HMAC_KEY` | 64 字符 hex 编码(32 字节) | -> 所有四个在 `default.toml` 中为 `__MUST_SET_VIA_ENV__` 占位符 +> 所有八个在 `default.toml` 中为 `__MUST_SET_VIA_ENV__` 占位符 + +> 开发环境 AES/HMAC 密钥生成: `python -c "import secrets; print(secrets.token_hex(32))"` + +### 微信小程序配置 + +| 配置 | 位置 | 说明 | +|------|------|------| +| AppID/Secret | 环境变量 `ERP__WECHAT__APPID` / `ERP__WECHAT__SECRET` | 微信登录凭据 | +| 加密密钥 | 环境变量 `TARO_APP_ENCRYPTION_KEY` | 小程序本地存储加密密钥 | +| API URL | `apps/miniprogram/config/index.ts` `defineConstants` | 编译时注入,默认 `http://localhost:3000/api/v1` | +| 开发者工具 | `apps/miniprogram/project.config.json` | `urlCheck: false`,AppID `wx20f4ef9cc2ec66c5` | + +> 微信凭据和加密密钥通过环境变量注入,不硬编码在源码中 ### 集成契约 @@ -62,6 +80,7 @@ psql: `D:\postgreSQL\bin\psql.exe -U postgres -h localhost -d erp` | 提供 → | [[erp-server]] | 数据库/Redis 连接 | | 提供 → | [[frontend]] | Vite 代理目标 | | 提供 → | [[testing]] | 测试环境配置 | +| 提供 → | [[miniprogram]] | 后端 API + 微信登录 | ## 3. 代码逻辑 @@ -101,4 +120,6 @@ cd apps/web && pnpm install && pnpm dev | 日期 | 变更 | |------|------| +| 2026-04-25 | 外部化微信凭据和健康加密密钥为环境变量;添加 4 个新的必设环境变量 | +| 2026-04-24 | 添加微信小程序配置信息和集成契约 | | 2026-04-23 | 重构为 5 节结构,确立为连接信息的单一真相源 |