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 节结构,确立为连接信息的单一真相源 |