diff --git a/apps/miniprogram/src/app.config.ts b/apps/miniprogram/src/app.config.ts index f976a8f..68c465e 100644 --- a/apps/miniprogram/src/app.config.ts +++ b/apps/miniprogram/src/app.config.ts @@ -84,7 +84,7 @@ export default defineAppConfig({ preloadRule: { 'pages/index/index': { network: 'all', - packages: ['pages/pkg-health', 'pages/pkg-doctor-core', 'pages/article'], + packages: ['pages/pkg-health', 'pages/article'], }, 'pages/health/index': { network: 'all', diff --git a/apps/miniprogram/src/components/FrozenPage/index.scss b/apps/miniprogram/src/components/FrozenPage/index.scss new file mode 100644 index 0000000..b41db31 --- /dev/null +++ b/apps/miniprogram/src/components/FrozenPage/index.scss @@ -0,0 +1,48 @@ +@import '../../styles/variables.scss'; +@import '../../styles/mixins.scss'; + +.frozen-page { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 60vh; + text-align: center; + padding: 40px 20px; +} + +.frozen-page-icon { + font-size: 48px; + margin-bottom: 24px; +} + +.frozen-page-title { + font-size: var(--tk-font-h3); + font-weight: 600; + color: $tx; + margin-bottom: 12px; +} + +.frozen-page-desc { + font-size: var(--tk-font-body); + color: $tx3; + margin-bottom: 32px; +} + +.frozen-page-btn { + height: 44px; + padding: 0 32px; + border-radius: $r; + background: var(--tk-pri); + @include flex-center; + + &:active { + opacity: var(--tk-touch-feedback-opacity); + } +} + +.frozen-page-btn-text { + font-size: var(--tk-font-body); + font-weight: 500; + color: $white; +} diff --git a/apps/miniprogram/src/components/FrozenPage/index.tsx b/apps/miniprogram/src/components/FrozenPage/index.tsx new file mode 100644 index 0000000..639fa97 --- /dev/null +++ b/apps/miniprogram/src/components/FrozenPage/index.tsx @@ -0,0 +1,22 @@ +import { View, Text } from '@tarojs/components'; +import Taro from '@tarojs/taro'; +import PageShell from '@/components/ui/PageShell'; +import './index.scss'; + +export default function FrozenPage() { + return ( + + + 🚧 + 功能即将上线 + 我们正在努力准备中,敬请期待 + Taro.navigateBack({ delta: 1 }).catch(() => Taro.switchTab({ url: '/pages/index/index' }))} + > + 返回 + + + + ); +} diff --git a/apps/miniprogram/src/pages/login/index.scss b/apps/miniprogram/src/pages/login/index.scss index 102da3f..992c3d6 100644 --- a/apps/miniprogram/src/pages/login/index.scss +++ b/apps/miniprogram/src/pages/login/index.scss @@ -50,78 +50,6 @@ color: $tx3; } -/* ─── 输入框 ─── */ -.login-field { - height: 56px; - background: $card; - border: 1.5px solid $bd; - border-radius: $r; - display: flex; - align-items: center; - padding: 0 16px; - margin-bottom: 12px; -} - -.login-input { - flex: 1; - height: 100%; - font-size: var(--tk-font-body); - color: $tx; -} - -.login-placeholder { - color: $tx3; - font-size: var(--tk-font-body); -} - -.login-eye { - font-size: var(--tk-font-body-sm); - color: var(--tk-pri); - font-weight: 500; - padding: 6px 0; - flex-shrink: 0; -} - -/* ─── 登录按钮 ─── */ -.login-submit { - height: 54px; - border-radius: $r; - background: var(--tk-pri); - @include flex-center; - margin-top: 12px; - margin-bottom: 16px; - box-shadow: 0 4px 16px rgba($pri, 0.3); - - &:active { - opacity: var(--tk-touch-feedback-opacity); - } -} - -.login-submit-text { - font-size: var(--tk-font-body-lg); - font-weight: 600; - color: $white; -} - -/* ─── 分隔线 ─── */ -.login-divider { - display: flex; - align-items: center; - gap: 16px; - margin-bottom: 16px; -} - -.login-divider-line { - flex: 1; - height: 1px; - background: $bd-l; -} - -.login-divider-text { - font-size: var(--tk-font-cap); - color: $tx3; -} - /* ─── 微信登录 ─── */ .login-wechat-btn { height: 54px; diff --git a/apps/miniprogram/src/pages/login/index.tsx b/apps/miniprogram/src/pages/login/index.tsx index dbd13a9..cf220c7 100644 --- a/apps/miniprogram/src/pages/login/index.tsx +++ b/apps/miniprogram/src/pages/login/index.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { View, Text, Input, Button } from '@tarojs/components'; +import { View, Text, Button } from '@tarojs/components'; import Taro from '@tarojs/taro'; import { safeNavigateTo } from '@/utils/navigate'; import { useAuthStore } from '../../stores/auth'; @@ -11,9 +11,6 @@ const IS_DEV = process.env.NODE_ENV !== 'production'; const IS_SIMULATOR = typeof __wxConfig !== 'undefined' && (__wxConfig as Record)?.envVersion === 'develop'; export default function Login() { - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - const [showPassword, setShowPassword] = useState(false); const [agreed, setAgreed] = useState(false); const [needBind, setNeedBind] = useState(false); @@ -25,12 +22,9 @@ export default function Login() { const navigateAfterLogin = () => { if (isMedicalStaff()) { - // 使用 redirectTo 替代 reLaunch 避免分包加载超时 - // redirectTo 只替换当前页面,不销毁整个页栈,分包预加载不会被中断 Taro.redirectTo({ url: '/pages/pkg-doctor-core/index', fail: () => { - // fallback: 先跳首页再 redirectTo Taro.switchTab({ url: '/pages/index/index', success: () => { @@ -46,32 +40,6 @@ export default function Login() { } }; - const handleCredentialLogin = async () => { - if (!username.trim()) { - Taro.showToast({ title: '请输入账号', icon: 'none' }); - return; - } - if (!password.trim()) { - Taro.showToast({ title: '请输入密码', icon: 'none' }); - return; - } - if (!agreed) { - Taro.showToast({ title: '请先阅读并同意用户协议', icon: 'none' }); - return; - } - try { - const success = await credentialLogin(username.trim(), password); - if (success) { - navigateAfterLogin(); - } else { - Taro.showToast({ title: '账号或密码错误', icon: 'none' }); - } - } catch (err) { - console.warn('[login] 登录失败:', err); - Taro.showToast({ title: '登录失败,请重试', icon: 'none' }); - } - }; - const handleWechatLogin = async () => { if (!agreed) { Taro.showToast({ title: '请先阅读并同意用户协议', icon: 'none' }); @@ -144,49 +112,6 @@ export default function Login() { {!needBind ? ( <> - {/* 账号输入 */} - - setUsername(e.detail.value)} - /> - - - {/* 密码输入 */} - - setPassword(e.detail.value)} - /> - setShowPassword(!showPassword)} - > - {showPassword ? '隐藏' : '显示'} - - - - {/* 登录按钮 */} - - {loading ? '登录中...' : '登录'} - - - {/* 分隔线 */} - - - - - - {/* 微信一键登录 */} diff --git a/apps/miniprogram/src/pages/pkg-profile/consents/index.tsx b/apps/miniprogram/src/pages/pkg-profile/consents/index.tsx index e3bb2db..8a734e8 100644 --- a/apps/miniprogram/src/pages/pkg-profile/consents/index.tsx +++ b/apps/miniprogram/src/pages/pkg-profile/consents/index.tsx @@ -1,131 +1,5 @@ -import { useState, useCallback } from 'react'; -import { View, Text } from '@tarojs/components'; -import Taro, { useReachBottom } from '@tarojs/taro'; -import { usePageData } from '@/hooks/usePageData'; -import { getCachedPatientId } from '@/services/request'; -import { listConsents, revokeConsent } from '@/services/consent'; -import type { Consent } from '@/services/consent'; -import EmptyState from '@/components/EmptyState'; -import Loading from '@/components/Loading'; -import { useElderClass } from '../../../hooks/useElderClass'; -import PageShell from '@/components/ui/PageShell'; -import './index.scss'; +import FrozenPage from '@/components/FrozenPage'; -const CONSENT_TYPE_MAP: Record = { - data_processing: '数据处理同意', - health_data_collection: '健康数据采集', - research_use: '科研使用', - third_party_share: '第三方共享', - genetic_testing: '基因检测', - telemedicine: '远程医疗', -}; - -const STATUS_MAP: Record = { - granted: { label: '已签署', cls: 'granted' }, - revoked: { label: '已撤回', cls: 'revoked' }, -}; - -export default function ConsentList() { - const modeClass = useElderClass(); - const [consents, setConsents] = useState([]); - const [page, setPage] = useState(1); - const [total, setTotal] = useState(0); - const [loading, setLoading] = useState(false); - const [revoking, setRevoking] = useState(null); - const [hasPatient, setHasPatient] = useState(true); - - const fetchData = useCallback(async (p: number, append = false) => { - const patientId = getCachedPatientId(); - if (!patientId) { - setConsents([]); - setHasPatient(false); - return; - } - setHasPatient(true); - setLoading(true); - try { - const res = await listConsents(patientId, { page: p, page_size: 20 }); - const list = res.data || []; - setConsents(append ? (prev) => [...prev, ...list] : list); - setTotal(res.total); - setPage(p); - } catch (err) { - console.warn('[consent] 加载失败:', err); - Taro.showToast({ title: '加载失败', icon: 'none' }); - } finally { - setLoading(false); - } - }, []); - - usePageData(async () => { await fetchData(1); }, { throttleMs: 10000, enablePullDown: true }); - - useReachBottom(() => { - if (!loading && consents.length < total) { - fetchData(page + 1, true); - } - }); - - const handleRevoke = async (consent: Consent) => { - const { confirm } = await Taro.showModal({ - title: '确认撤回', - content: `确定要撤回「${CONSENT_TYPE_MAP[consent.consent_type] || consent.consent_type}」的同意吗?`, - }); - if (!confirm) return; - setRevoking(consent.id); - try { - const updated = await revokeConsent(consent.id, consent.version); - setConsents((prev) => prev.map((c) => c.id === updated.id ? updated : c)); - Taro.showToast({ title: '已撤回', icon: 'success' }); - } catch (err) { - console.warn('[consent] 撤回失败:', err); - Taro.showToast({ title: '撤回失败', icon: 'none' }); - } finally { - setRevoking(null); - } - }; - - return ( - - 知情同意 - - - {consents.map((c) => { - const si = STATUS_MAP[c.status] || { label: c.status, cls: '' }; - const typeName = CONSENT_TYPE_MAP[c.consent_type] || c.consent_type; - return ( - - - {typeName} - {si.label} - - 范围: {c.consent_scope} - {c.granted_at && ( - 签署时间: {c.granted_at} - )} - {c.revoked_at && ( - 撤回时间: {c.revoked_at} - )} - {c.expiry_date && ( - 有效期至: {c.expiry_date} - )} - {c.status === 'granted' && ( - handleRevoke(c)} - > - {revoking === c.id ? '处理中...' : '撤回同意'} - - )} - - ); - })} - - - {consents.length === 0 && !loading && ( - - )} - - {loading && } - - ); +export default function ConsentsPage() { + return ; } diff --git a/apps/miniprogram/src/pages/pkg-profile/diagnoses/index.tsx b/apps/miniprogram/src/pages/pkg-profile/diagnoses/index.tsx index af79b11..b0c283f 100644 --- a/apps/miniprogram/src/pages/pkg-profile/diagnoses/index.tsx +++ b/apps/miniprogram/src/pages/pkg-profile/diagnoses/index.tsx @@ -1,102 +1,5 @@ -import { useState, useCallback } from 'react'; -import { View, Text } from '@tarojs/components'; -import Taro, { useReachBottom } from '@tarojs/taro'; -import { usePageData } from '@/hooks/usePageData'; -import { getCachedPatientId } from '@/services/request'; -import { listDiagnoses, Diagnosis } from '../../../services/health-record'; -import EmptyState from '../../../components/EmptyState'; -import Loading from '../../../components/Loading'; -import { useElderClass } from '../../../hooks/useElderClass'; -import PageShell from '@/components/ui/PageShell'; -import './index.scss'; +import FrozenPage from '@/components/FrozenPage'; -const TYPE_MAP: Record = { - primary: { label: '主要', cls: 'primary' }, - secondary: { label: '次要', cls: 'secondary' }, - comorbid: { label: '合并症', cls: 'comorbid' }, -}; - -const STATUS_MAP: Record = { - active: { label: '活动', cls: 'active' }, - resolved: { label: '已解决', cls: 'resolved' }, - chronic: { label: '慢性', cls: 'chronic' }, -}; - -export default function Diagnoses() { - const modeClass = useElderClass(); - const [records, setRecords] = useState([]); - const [page, setPage] = useState(1); - const [total, setTotal] = useState(0); - const [loading, setLoading] = useState(false); - const [hasPatient, setHasPatient] = useState(true); - - const fetchData = useCallback(async (p: number, append = false) => { - const patientId = getCachedPatientId(); - if (!patientId) { - setRecords([]); - setHasPatient(false); - return; - } - setHasPatient(true); - setLoading(true); - try { - const res = await listDiagnoses(patientId, { page: p, page_size: 20 }); - const list = res.data || []; - setRecords(append ? (prev) => [...prev, ...list] : list); - setTotal(res.total); - setPage(p); - } catch (err) { - console.warn('[diagnosis] 加载失败:', err); - Taro.showToast({ title: '加载失败', icon: 'none' }); - } finally { - setLoading(false); - } - }, []); - - usePageData(async () => { await fetchData(1); }, { throttleMs: 10000, enablePullDown: true }); - - useReachBottom(() => { - if (!loading && records.length < total) { - fetchData(page + 1, true); - } - }); - - return ( - - 诊断记录 - - - {records.map((d) => { - const typeInfo = TYPE_MAP[d.diagnosis_type] || { label: d.diagnosis_type, cls: '' }; - const statusInfo = STATUS_MAP[d.status] || { label: d.status, cls: '' }; - return ( - - - {d.diagnosis_name} - - {statusInfo.label} - - - - - {typeInfo.label} - - {d.icd_code} - - 诊断日期:{d.diagnosed_date} - {d.notes && ( - {d.notes} - )} - - ); - })} - - - {records.length === 0 && !loading && ( - - )} - - {loading && } - - ); +export default function DiagnosesPage() { + return ; } diff --git a/apps/miniprogram/src/pages/pkg-profile/dialysis-prescriptions/detail/index.tsx b/apps/miniprogram/src/pages/pkg-profile/dialysis-prescriptions/detail/index.tsx index 32e0350..75d6b1c 100644 --- a/apps/miniprogram/src/pages/pkg-profile/dialysis-prescriptions/detail/index.tsx +++ b/apps/miniprogram/src/pages/pkg-profile/dialysis-prescriptions/detail/index.tsx @@ -1,123 +1,5 @@ -import { useState, useCallback } from 'react'; -import { View, Text } from '@tarojs/components'; -import Taro, { useRouter } from '@tarojs/taro'; -import { usePageData } from '@/hooks/usePageData'; -import { getDialysisPrescription } from '@/services/dialysis'; -import type { DialysisPrescription } from '@/services/dialysis'; -import Loading from '@/components/Loading'; -import PageShell from '@/components/ui/PageShell'; -import ContentCard from '@/components/ui/ContentCard'; -import { useElderClass } from '../../../../hooks/useElderClass'; -import './index.scss'; +import FrozenPage from '@/components/FrozenPage'; -const STATUS_MAP: Record = { - active: { label: '生效中', cls: 'active' }, - inactive: { label: '已停用', cls: 'inactive' }, - expired: { label: '已过期', cls: 'expired' }, -}; - -export default function DialysisPrescriptionDetail() { - const modeClass = useElderClass(); - const router = useRouter(); - const id = router.params.id || ''; - const [rx, setRx] = useState(null); - const [loading, setLoading] = useState(true); - - const fetchDetail = useCallback(async () => { - if (!id) return; - setLoading(true); - try { - const data = await getDialysisPrescription(id); - setRx(data); - } catch (err) { - console.warn('[prescription] 加载失败:', err); - Taro.showToast({ title: '加载失败', icon: 'none' }); - } finally { - setLoading(false); - } - }, [id]); - - usePageData(fetchDetail, { throttleMs: 60000 }); - - if (loading) return ; - if (!rx) return 处方不存在; - - const si = STATUS_MAP[rx.status] || { label: rx.status, cls: '' }; - - const Row = ({ label, value }: { label: string; value?: string | number | null }) => { - if (value == null) return null; - return ( - - {label} - {value} - - ); - }; - - return ( - - {/* 状态头部 */} - - - {rx.dialyzer_model || '透析处方'} - {si.label} - - {(rx.effective_from || rx.effective_to) && ( - {rx.effective_from || '...'} ~ {rx.effective_to || '...'} - )} - - - {/* 基本参数 */} - - 基本参数 - - - - - - - - - {/* 透析液配比 */} - - 透析液配比 - - - - - - {/* 抗凝方案 */} - - 抗凝方案 - - - - - {/* 血管通路 */} - {(rx.vascular_access_type || rx.vascular_access_location) && ( - - 血管通路 - - - - )} - - {/* 超滤与干体重 */} - {(rx.target_ultrafiltration_ml != null || rx.target_dry_weight != null) && ( - - 超滤目标 - - - - )} - - {/* 备注 */} - {rx.notes && ( - - 备注 - {rx.notes} - - )} - - ); +export default function DialysisPrescriptionDetailPage() { + return ; } diff --git a/apps/miniprogram/src/pages/pkg-profile/dialysis-prescriptions/index.tsx b/apps/miniprogram/src/pages/pkg-profile/dialysis-prescriptions/index.tsx index 5102c3c..7928ac7 100644 --- a/apps/miniprogram/src/pages/pkg-profile/dialysis-prescriptions/index.tsx +++ b/apps/miniprogram/src/pages/pkg-profile/dialysis-prescriptions/index.tsx @@ -1,104 +1,5 @@ -import { useState, useCallback } from 'react'; -import { View, Text } from '@tarojs/components'; -import Taro, { useReachBottom } from '@tarojs/taro'; -import { safeNavigateTo } from '@/utils/navigate'; -import { usePageData } from '@/hooks/usePageData'; -import { getCachedPatientId } from '@/services/request'; -import { listDialysisPrescriptions } from '@/services/dialysis'; -import type { DialysisPrescription } from '@/services/dialysis'; -import EmptyState from '@/components/EmptyState'; -import Loading from '@/components/Loading'; -import { useElderClass } from '../../../hooks/useElderClass'; -import PageShell from '@/components/ui/PageShell'; -import './index.scss'; +import FrozenPage from '@/components/FrozenPage'; -const STATUS_MAP: Record = { - active: { label: '生效中', cls: 'active' }, - inactive: { label: '已停用', cls: 'inactive' }, - expired: { label: '已过期', cls: 'expired' }, -}; - -export default function DialysisPrescriptionList() { - const modeClass = useElderClass(); - const [prescriptions, setPrescriptions] = useState([]); - const [page, setPage] = useState(1); - const [total, setTotal] = useState(0); - const [loading, setLoading] = useState(false); - const [hasPatient, setHasPatient] = useState(true); - - const fetchData = useCallback(async (p: number, append = false) => { - const patientId = getCachedPatientId(); - if (!patientId) { - setPrescriptions([]); - setHasPatient(false); - return; - } - setHasPatient(true); - setLoading(true); - try { - const res = await listDialysisPrescriptions({ patient_id: patientId, page: p, page_size: 20 }); - const list = res.data || []; - setPrescriptions(append ? (prev) => [...prev, ...list] : list); - setTotal(res.total); - setPage(p); - } catch (err) { - console.warn('[prescription] 加载失败:', err); - Taro.showToast({ title: '加载失败', icon: 'none' }); - } finally { - setLoading(false); - } - }, []); - - usePageData(async () => { await fetchData(1); }, { throttleMs: 10000, enablePullDown: true }); - - useReachBottom(() => { - if (!loading && prescriptions.length < total) { - fetchData(page + 1, true); - } - }); - - const statusInfo = (s: string) => STATUS_MAP[s] || { label: s, cls: '' }; - - return ( - - 透析处方 - - - {prescriptions.map((p) => { - const si = statusInfo(p.status); - return ( - safeNavigateTo(`/pages/pkg-profile/dialysis-prescriptions/detail/index?id=${p.id}`)} - > - - {p.dialyzer_model || '未指定型号'} - {si.label} - - - {p.frequency_per_week != null && ( - {p.frequency_per_week}次/周 - )} - {p.duration_minutes != null && ( - 每次{p.duration_minutes}分钟 - )} - - {(p.effective_from || p.effective_to) && ( - - {p.effective_from || '...'} ~ {p.effective_to || '...'} - - )} - - ); - })} - - - {prescriptions.length === 0 && !loading && ( - - )} - - {loading && } - - ); +export default function DialysisPrescriptionsPage() { + return ; } diff --git a/apps/miniprogram/src/pages/pkg-profile/dialysis-records/detail/index.tsx b/apps/miniprogram/src/pages/pkg-profile/dialysis-records/detail/index.tsx index 9708db5..b086964 100644 --- a/apps/miniprogram/src/pages/pkg-profile/dialysis-records/detail/index.tsx +++ b/apps/miniprogram/src/pages/pkg-profile/dialysis-records/detail/index.tsx @@ -1,125 +1,5 @@ -import { useState, useCallback } from 'react'; -import { View, Text } from '@tarojs/components'; -import Taro, { useRouter } from '@tarojs/taro'; -import { usePageData } from '@/hooks/usePageData'; -import { getDialysisRecord } from '@/services/dialysis'; -import type { DialysisRecord } from '@/services/dialysis'; -import Loading from '@/components/Loading'; -import PageShell from '@/components/ui/PageShell'; -import ContentCard from '@/components/ui/ContentCard'; -import { useElderClass } from '../../../../hooks/useElderClass'; -import './index.scss'; +import FrozenPage from '@/components/FrozenPage'; -const STATUS_MAP: Record = { - draft: { label: '草稿', cls: 'draft' }, - completed: { label: '已完成', cls: 'completed' }, - reviewed: { label: '已审核', cls: 'reviewed' }, -}; - -const TYPE_MAP: Record = { - HD: '血液透析', - HDF: '血液透析滤过', - HF: '血液滤过', -}; - -export default function DialysisRecordDetail() { - const modeClass = useElderClass(); - const router = useRouter(); - const id = router.params.id || ''; - const [record, setRecord] = useState(null); - const [loading, setLoading] = useState(true); - - const fetchDetail = useCallback(async () => { - if (!id) return; - setLoading(true); - try { - const data = await getDialysisRecord(id); - setRecord(data); - } catch (err) { - console.warn('[dialysis] 加载失败:', err); - Taro.showToast({ title: '加载失败', icon: 'none' }); - } finally { - setLoading(false); - } - }, [id]); - - usePageData(fetchDetail, { throttleMs: 60000 }); - - if (loading) return ; - if (!record) return 记录不存在; - - const si = STATUS_MAP[record.status] || { label: record.status, cls: '' }; - - return ( - - {/* 状态头部 */} - - - {record.dialysis_date} - {si.label} - - {TYPE_MAP[record.dialysis_type] || record.dialysis_type} - {record.reviewed_at && 审核时间 {record.reviewed_at}} - - - {/* 基本信息 */} - - 基本信息 - {record.start_time && ( - 开始时间{record.start_time} - )} - {record.end_time && ( - 结束时间{record.end_time} - )} - {record.dialysis_duration != null && ( - 透析时长{record.dialysis_duration} 分钟 - )} - {record.blood_flow_rate != null && ( - 血流速{record.blood_flow_rate} ml/min - )} - {record.ultrafiltration_volume != null && ( - 超滤量{record.ultrafiltration_volume} ml - )} - - - {/* 体重与血压 */} - - 体重与血压 - {record.dry_weight != null && ( - 干体重{record.dry_weight} kg - )} - {record.pre_weight != null && ( - 透前体重{record.pre_weight} kg - )} - {record.post_weight != null && ( - 透后体重{record.post_weight} kg - )} - {record.pre_bp_systolic != null && record.pre_bp_diastolic != null && ( - 透前血压{record.pre_bp_systolic}/{record.pre_bp_diastolic} mmHg - )} - {record.post_bp_systolic != null && record.post_bp_diastolic != null && ( - 透后血压{record.post_bp_systolic}/{record.post_bp_diastolic} mmHg - )} - {record.pre_heart_rate != null && ( - 透前心率{record.pre_heart_rate} bpm - )} - {record.post_heart_rate != null && ( - 透后心率{record.post_heart_rate} bpm - )} - - - {/* 症状与并发症 */} - {(record.symptoms || record.complication_notes) && ( - - 症状与并发症 - {record.symptoms && Object.keys(record.symptoms).length > 0 && ( - 症状{JSON.stringify(record.symptoms)} - )} - {record.complication_notes && ( - 并发症备注{record.complication_notes} - )} - - )} - - ); +export default function DialysisRecordDetailPage() { + return ; } diff --git a/apps/miniprogram/src/pages/pkg-profile/dialysis-records/index.tsx b/apps/miniprogram/src/pages/pkg-profile/dialysis-records/index.tsx index e8310fe..e9406e5 100644 --- a/apps/miniprogram/src/pages/pkg-profile/dialysis-records/index.tsx +++ b/apps/miniprogram/src/pages/pkg-profile/dialysis-records/index.tsx @@ -1,109 +1,5 @@ -import { useState, useCallback } from 'react'; -import { View, Text } from '@tarojs/components'; -import Taro, { useReachBottom } from '@tarojs/taro'; -import { safeNavigateTo } from '@/utils/navigate'; -import { usePageData } from '@/hooks/usePageData'; -import { getCachedPatientId } from '@/services/request'; -import { listDialysisRecords } from '@/services/dialysis'; -import type { DialysisRecord } from '@/services/dialysis'; -import EmptyState from '@/components/EmptyState'; -import Loading from '@/components/Loading'; -import { useElderClass } from '../../../hooks/useElderClass'; -import PageShell from '@/components/ui/PageShell'; -import './index.scss'; +import FrozenPage from '@/components/FrozenPage'; -const TYPE_MAP: Record = { - HD: { label: 'HD', cls: 'hd' }, - HDF: { label: 'HDF', cls: 'hdf' }, - HF: { label: 'HF', cls: 'hf' }, -}; - -const STATUS_MAP: Record = { - draft: { label: '草稿', cls: 'draft' }, - completed: { label: '已完成', cls: 'completed' }, - reviewed: { label: '已审核', cls: 'reviewed' }, -}; - -export default function DialysisRecordList() { - const modeClass = useElderClass(); - const [records, setRecords] = useState([]); - const [page, setPage] = useState(1); - const [total, setTotal] = useState(0); - const [loading, setLoading] = useState(false); - const [hasPatient, setHasPatient] = useState(true); - - const fetchData = useCallback(async (p: number, append = false) => { - const patientId = getCachedPatientId(); - if (!patientId) { - setRecords([]); - setHasPatient(false); - return; - } - setHasPatient(true); - setLoading(true); - try { - const res = await listDialysisRecords(patientId, { page: p, page_size: 20 }); - const list = res.data || []; - setRecords(append ? (prev) => [...prev, ...list] : list); - setTotal(res.total); - setPage(p); - } catch (err) { - console.warn('[dialysis] 加载失败:', err); - Taro.showToast({ title: '加载失败', icon: 'none' }); - } finally { - setLoading(false); - } - }, []); - - usePageData(async () => { await fetchData(1); }, { throttleMs: 10000, enablePullDown: true }); - - useReachBottom(() => { - if (!loading && records.length < total) { - fetchData(page + 1, true); - } - }); - - const typeInfo = (t: string) => TYPE_MAP[t] || { label: t, cls: '' }; - const statusInfo = (s: string) => STATUS_MAP[s] || { label: s, cls: '' }; - - return ( - - 透析记录 - - - {records.map((r) => { - const ti = typeInfo(r.dialysis_type); - const si = statusInfo(r.status); - return ( - safeNavigateTo(`/pages/pkg-profile/dialysis-records/detail/index?id=${r.id}`)} - > - - {ti.label} - {si.label} - - {r.dialysis_date} - {(r.pre_weight || r.post_weight) && ( - - {r.pre_weight && 透前 {r.pre_weight}kg} - {r.post_weight && 透后 {r.post_weight}kg} - - )} - {r.dialysis_duration && ( - 时长 {r.dialysis_duration}分钟 - )} - - ); - })} - - - {records.length === 0 && !loading && ( - - )} - - {loading && } - - ); +export default function DialysisRecordsPage() { + return ; } diff --git a/apps/miniprogram/src/pages/pkg-profile/events/index.tsx b/apps/miniprogram/src/pages/pkg-profile/events/index.tsx index 8c29e21..00d1502 100644 --- a/apps/miniprogram/src/pages/pkg-profile/events/index.tsx +++ b/apps/miniprogram/src/pages/pkg-profile/events/index.tsx @@ -1,118 +1,5 @@ -import { useState, useCallback } from 'react'; -import { View, Text } from '@tarojs/components'; -import Taro from '@tarojs/taro'; -import * as pointsApi from '@/services/points'; -import Loading from '@/components/Loading'; -import EmptyState from '@/components/EmptyState'; -import { useElderClass } from '@/hooks/useElderClass'; -import { usePageData } from '@/hooks/usePageData'; -import PageShell from '@/components/ui/PageShell'; -import './index.scss'; - -const STATUS_MAP: Record = { - published: { label: '报名中', className: 'event-card__status--published' }, - ongoing: { label: '进行中', className: 'event-card__status--ongoing' }, - completed: { label: '已结束', className: 'event-card__status--completed' }, - cancelled: { label: '已取消', className: 'event-card__status--cancelled' }, -}; +import FrozenPage from '@/components/FrozenPage'; export default function EventsPage() { - const modeClass = useElderClass(); - const [events, setEvents] = useState([]); - const [loading, setLoading] = useState(true); - const [registering, setRegistering] = useState(null); - - const loadEvents = useCallback(async () => { - setLoading(true); - try { - const res = await pointsApi.listOfflineEvents({ page: 1, page_size: 50, status: 'published' }); - setEvents(res.data || []); - } catch (err) { - console.warn('[event] 加载失败:', err); - Taro.showToast({ title: '加载失败', icon: 'none' }); - } finally { - setLoading(false); - } - }, []); - - usePageData(loadEvents, { throttleMs: 10000, enablePullDown: true }); - - const handleRegister = async (event: pointsApi.OfflineEvent) => { - setRegistering(event.id); - try { - await pointsApi.registerEvent(event.id); - Taro.showToast({ title: '报名成功', icon: 'success' }); - loadEvents(); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : '报名失败'; - Taro.showToast({ title: msg.substring(0, 20), icon: 'none' }); - } finally { - setRegistering(null); - } - }; - - const formatDate = (d: string) => { - return new Date(d).toLocaleDateString('zh-CN', { - year: 'numeric', - month: 'long', - day: 'numeric', - }); - }; - - if (loading) return ; - - return ( - - - 线下活动 - 参加活动赢取积分 - - - {events.length === 0 ? ( - - ) : ( - - {events.map((event) => { - const st = STATUS_MAP[event.status] || { label: event.status, className: '' }; - const isFull = event.max_participants != null && event.current_participants >= event.max_participants; - const isRegistering = registering === event.id; - - return ( - - - - {st.label} - - +{event.points_reward} 积分 - - {event.title} - {event.description && ( - {event.description} - )} - - {formatDate(event.event_date)} - {event.location && ( - {event.location} - )} - - - - {event.current_participants}{event.max_participants ? `/${event.max_participants}` : ''} 人已报名 - - !isFull && !isRegistering && handleRegister(event)} - > - - {isRegistering ? '报名中...' : isFull ? '已满' : '立即报名'} - - - - - ); - })} - - )} - - ); + return ; } diff --git a/apps/miniprogram/src/pages/pkg-profile/family-add/index.tsx b/apps/miniprogram/src/pages/pkg-profile/family-add/index.tsx index 22656e5..f749097 100644 --- a/apps/miniprogram/src/pages/pkg-profile/family-add/index.tsx +++ b/apps/miniprogram/src/pages/pkg-profile/family-add/index.tsx @@ -1,179 +1,5 @@ -import { useState, useEffect } from 'react'; -import { View, Text, Input, Picker } from '@tarojs/components'; -import Taro, { useRouter } from '@tarojs/taro'; -import { createPatient, updatePatient, Patient } from '../../../services/patient'; -import { secureGet, secureRemove } from '@/utils/secure-storage'; -import { useElderClass } from '../../../hooks/useElderClass'; -import { useSafeTimeout } from '@/hooks/useSafeTimeout'; -import PageShell from '@/components/ui/PageShell'; -import './index.scss'; +import FrozenPage from '@/components/FrozenPage'; -const RELATION_OPTIONS = ['本人', '配偶', '父母', '子女', '其他']; -const GENDER_OPTIONS = ['男', '女']; - -export default function FamilyAdd() { - const modeClass = useElderClass(); - const router = useRouter(); - const editId = router.params.id || ''; - const rawEdit = secureGet('edit_patient'); - const editData: Patient | null = rawEdit ? JSON.parse(rawEdit) : null; - - const [name, setName] = useState(editData?.name || ''); - const [relationIdx, setRelationIdx] = useState( - editData?.relation ? RELATION_OPTIONS.indexOf(editData.relation) : 0 - ); - const [genderIdx, setGenderIdx] = useState( - editData?.gender === 'female' ? 1 : 0 - ); - const [birthDate, setBirthDate] = useState(editData?.birth_date || ''); - const [submitting, setSubmitting] = useState(false); - const { safeSetTimeout } = useSafeTimeout(); - - useEffect(() => { - return () => { secureRemove('edit_patient'); }; - }, []); - - const handleSubmit = async () => { - if (!name.trim()) { - Taro.showToast({ title: '请输入姓名', icon: 'none' }); - return; - } - setSubmitting(true); - Taro.showLoading({ title: '提交中...' }); - try { - if (editId && editData) { - await updatePatient(editId, { - name: name.trim(), - gender: GENDER_OPTIONS[genderIdx] === '男' ? 'male' : 'female', - birth_date: birthDate || undefined, - relation: RELATION_OPTIONS[relationIdx], - }, editData.version); - Taro.hideLoading(); - Taro.showToast({ title: '修改成功', icon: 'success' }); - } else { - await createPatient({ - name: name.trim(), - gender: GENDER_OPTIONS[genderIdx] === '男' ? 'male' : 'female', - birth_date: birthDate || undefined, - }); - Taro.hideLoading(); - Taro.showToast({ title: '添加成功', icon: 'success' }); - } - safeSetTimeout(() => Taro.navigateBack(), 1000); - } catch (err) { - console.warn('[family] 操作失败:', err); - Taro.hideLoading(); - Taro.showToast({ title: editId ? '修改失败' : '添加失败', icon: 'none' }); - } finally { - setSubmitting(false); - } - }; - - return ( - - {editId ? '编辑就诊人' : '添加就诊人'} - - {/* 提示卡片 */} - - 完善个人信息 - - 完善信息后即可使用积分商城、签到等功能。请填写真实信息。 - - - - {/* 表单 */} - - - 姓名* - - setName(e.detail.value)} - /> - - - - - 关系* - setRelationIdx(Number(e.detail.value))} - > - - {RELATION_OPTIONS[relationIdx]} - - - - - - - 性别* - setGenderIdx(Number(e.detail.value))} - > - - {GENDER_OPTIONS[genderIdx]} - - - - - - - 出生日期* - setBirthDate(e.detail.value)} - > - - - {birthDate || '请选择'} - - - - - - - - 手机号 - - - - - - - 身份证号 - - - - - - - - {submitting ? '提交中...' : editId ? '保存修改' : '确认添加'} - - - ); +export default function FamilyAddPage() { + return ; } diff --git a/apps/miniprogram/src/pages/pkg-profile/family/index.tsx b/apps/miniprogram/src/pages/pkg-profile/family/index.tsx index 140a5f0..55d1019 100644 --- a/apps/miniprogram/src/pages/pkg-profile/family/index.tsx +++ b/apps/miniprogram/src/pages/pkg-profile/family/index.tsx @@ -1,131 +1,5 @@ -import { useState, useCallback } from 'react'; -import { View, Text } from '@tarojs/components'; -import Taro from '@tarojs/taro'; -import { safeNavigateTo } from '@/utils/navigate'; -import { secureSet } from '@/utils/secure-storage'; -import { usePageData } from '@/hooks/usePageData'; -import { listPatients, Patient } from '../../../services/patient'; -import { useAuthStore } from '../../../stores/auth'; -import EmptyState from '../../../components/EmptyState'; -import { useElderClass } from '../../../hooks/useElderClass'; -import PageShell from '@/components/ui/PageShell'; -import './index.scss'; +import FrozenPage from '@/components/FrozenPage'; -const RELATION_CLASS: Record = { - '本人': 'self', - '配偶': 'spouse', - '父母': 'parent', - '子女': 'child', - '其他': 'other', -}; - -function getRelationClass(relation: string): string { - return RELATION_CLASS[relation] || 'other'; -} - -export default function FamilyList() { - const modeClass = useElderClass(); - const [patients, setPatients] = useState([]); - const [loading, setLoading] = useState(false); - const currentPatient = useAuthStore((s) => s.currentPatient); - const setCurrentPatient = useAuthStore((s) => s.setCurrentPatient); - - const fetchPatients = useCallback(async () => { - setLoading(true); - try { - const res = await listPatients(); - setPatients(res.data || []); - } catch (err) { - console.warn('[family] 加载失败:', err); - Taro.showToast({ title: '加载失败', icon: 'none' }); - } finally { - setLoading(false); - } - }, []); - - usePageData(fetchPatients, { throttleMs: 10000 }); - - const handleSelect = (patient: Patient) => { - setCurrentPatient({ - id: patient.id, - name: patient.name, - gender: patient.gender, - birth_date: patient.birth_date, - relation: patient.relation || '本人', - }); - Taro.showToast({ title: `已切换为 ${patient.name}`, icon: 'success' }); - }; - - const goToAdd = () => { - safeNavigateTo('/pages/pkg-profile/family-add/index'); - }; - - const goToEdit = (patient: Patient) => { - secureSet('edit_patient', JSON.stringify(patient)); - safeNavigateTo(`/pages/pkg-profile/family-add/index?id=${patient.id}`); - }; - - const genderText = (g?: string) => { - if (g === 'male') return '男'; - if (g === 'female') return '女'; - return '未知'; - }; - - const birthYear = (d?: string) => { - if (!d) return ''; - return d.slice(0, 4) + '年'; - }; - - return ( - - 就诊人管理 - 完善信息后即可使用积分商城、签到等功能。可添加多位家庭成员。 - - - {patients.map((p) => { - const isActive = currentPatient?.id === p.id; - const relClass = getRelationClass(p.relation || '本人'); - return ( - handleSelect(p)} - > - - {p.name.charAt(0)} - - - - {p.name} - {isActive && 当前} - - - - {p.relation || '本人'} - - {genderText(p.gender)} - {birthYear(p.birth_date) && {birthYear(p.birth_date)}} - - - { e.stopPropagation(); goToEdit(p); }} - > - 编辑 - - - ); - })} - - - {patients.length === 0 && !loading && ( - - )} - - - + - 添加就诊人 - - - ); +export default function FamilyPage() { + return ; } diff --git a/apps/miniprogram/src/pages/pkg-profile/medication/index.tsx b/apps/miniprogram/src/pages/pkg-profile/medication/index.tsx index c1afd06..5ac8184 100644 --- a/apps/miniprogram/src/pages/pkg-profile/medication/index.tsx +++ b/apps/miniprogram/src/pages/pkg-profile/medication/index.tsx @@ -1,217 +1,5 @@ -import { useState, useCallback } from 'react'; -import { View, Text, Input, Picker } from '@tarojs/components'; -import Taro from '@tarojs/taro'; -import { usePageData } from '@/hooks/usePageData'; -import { getCachedPatientId } from '@/services/request'; -import { requestSubscribe } from '@/services/wechat-templates'; -import EmptyState from '../../../components/EmptyState'; -import { - listReminders, - createReminder, - updateReminder, - deleteReminder, - type MedicationReminder, -} from '../../../services/medication-reminder'; -import { useElderClass } from '../../../hooks/useElderClass'; -import PageShell from '@/components/ui/PageShell'; -import './index.scss'; +import FrozenPage from '@/components/FrozenPage'; -export default function MedicationReminder() { - const modeClass = useElderClass(); - const [reminders, setReminders] = useState([]); - const [loading, setLoading] = useState(true); - const [showForm, setShowForm] = useState(false); - const [formName, setFormName] = useState(''); - const [formDosage, setFormDosage] = useState(''); - const [formTime, setFormTime] = useState('08:00'); - - const fetchReminders = useCallback(async () => { - try { - const res = await listReminders(); - setReminders(res.data ?? []); - } catch (err) { - console.warn('[medication] 加载失败:', err); - Taro.showToast({ title: '加载失败', icon: 'none' }); - } finally { - setLoading(false); - } - }, []); - - usePageData( - async () => { - await fetchReminders(); - // 请求用药提醒推送订阅 - requestSubscribe('MEDICATION_REMINDER'); - }, - { throttleMs: 5000, enablePullDown: true }, - ); - - const handleToggle = async (r: MedicationReminder) => { - try { - await updateReminder(r.id, { - is_active: !r.is_active, - version: r.version, - }); - fetchReminders(); - } catch (err) { - console.warn('[medication] 操作失败:', err); - Taro.showToast({ title: '操作失败', icon: 'none' }); - } - }; - - const handleDelete = (r: MedicationReminder) => { - Taro.showModal({ - title: '确认删除', - content: '确定要删除这个提醒吗?', - }).then(async (res) => { - if (res.confirm) { - try { - await deleteReminder(r.id, r.version); - Taro.showToast({ title: '已删除', icon: 'success' }); - fetchReminders(); - } catch (err) { - console.warn('[medication] 删除失败:', err); - Taro.showToast({ title: '删除失败', icon: 'none' }); - } - } - }); - }; - - const handleAdd = async () => { - if (!formName.trim()) { - Taro.showToast({ title: '请输入药品名称', icon: 'none' }); - return; - } - const patientId = getCachedPatientId(); - if (!patientId) { - Taro.showToast({ title: '请先绑定患者档案', icon: 'none' }); - return; - } - try { - await createReminder({ - patient_id: patientId, - medication_name: formName.trim(), - dosage: formDosage.trim() || undefined, - reminder_times: [formTime], - is_active: true, - }); - setFormName(''); - setFormDosage(''); - setFormTime('08:00'); - setShowForm(false); - Taro.showToast({ title: '添加成功', icon: 'success' }); - fetchReminders(); - } catch (err) { - console.warn('[medication] 添加失败:', err); - Taro.showToast({ title: '添加失败', icon: 'none' }); - } - }; - - const nameInitial = (name: string) => { - return name ? name.charAt(0) : '药'; - }; - - if (loading) { - return ( - - 用药提醒 - - 加载中... - - - ); - } - - return ( - - 用药提醒 - - - {reminders.map((r) => ( - - - {nameInitial(r.medication_name)} - - - {r.medication_name} - - {r.dosage || '-'} | {r.reminder_times?.join(', ') || '-'} - - - - handleToggle(r)} - > - - - handleDelete(r)} - > - 删除 - - - - ))} - - - {reminders.length === 0 && ( - - )} - - {showForm && ( - - 添加提醒 - - 药品名称 - setFormName(e.detail.value)} - /> - - - 剂量 - setFormDosage(e.detail.value)} - /> - - - 提醒时间 - setFormTime(e.detail.value)} - > - - {formTime} - 修改 - - - - - setShowForm(false)}> - 取消 - - - 确认 - - - - )} - - {!showForm && ( - setShowForm(true)}> - 添加提醒 - - )} - - ); +export default function MedicationPage() { + return ; } diff --git a/apps/miniprogram/src/pages/profile/index.tsx b/apps/miniprogram/src/pages/profile/index.tsx index f183378..85e1a81 100644 --- a/apps/miniprogram/src/pages/profile/index.tsx +++ b/apps/miniprogram/src/pages/profile/index.tsx @@ -19,7 +19,6 @@ interface MenuItem { bg: string; color: string; path: string; - isSwitchTab?: boolean; } interface MenuGroup { @@ -29,43 +28,15 @@ interface MenuGroup { const LOGGED_IN_GROUPS: MenuGroup[] = [ { - title: '健康管理', + title: '健康档案', items: [ - { label: '健康记录', icon: '健', bg: 'pri-l', color: 'pri', path: '/pages/pkg-profile/health-records/index' }, + { label: '健康档案', icon: '健', bg: 'pri-l', color: 'pri', path: '/pages/pkg-profile/health-records/index' }, { label: '我的报告', icon: '报', bg: 'acc-l', color: 'acc', path: '/pages/pkg-profile/reports/index' }, - { label: 'AI 分析', icon: '智', bg: 'pri-l', color: 'pri', path: '/pages/ai-report/list/index' }, - { label: '诊断记录', icon: '诊', bg: 'acc-l', color: 'acc', path: '/pages/pkg-profile/diagnoses/index' }, - { label: '用药记录', icon: '药', bg: 'pri-l', color: 'pri', path: '/pages/pkg-profile/medication/index' }, - ], - }, - { - title: '就诊服务', - items: [ - { label: '我的预约', icon: '约', bg: 'pri-l', color: 'pri', path: '/pages/appointment/index' }, - { label: '我的随访', icon: '随', bg: 'acc-l', color: 'acc', path: '/pages/pkg-profile/followups/index' }, - { label: '在线咨询', icon: '问', bg: 'pri-l', color: 'pri', path: '/pages/consultation/index' }, - ], - }, - { - title: '透析管理', - items: [ - { label: '透析记录', icon: '透', bg: 'pri-l', color: 'pri', path: '/pages/pkg-profile/dialysis-records/index' }, - { label: '透析处方', icon: '方', bg: 'acc-l', color: 'acc', path: '/pages/pkg-profile/dialysis-prescriptions/index' }, - { label: '知情同意', icon: '知', bg: 'pri-l', color: 'pri', path: '/pages/pkg-profile/consents/index' }, - ], - }, - { - title: '生活服务', - items: [ - { label: '积分商城', icon: '礼', bg: 'pri-l', color: 'pri', path: '/pages/mall/index' }, - { label: '线下活动', icon: '活', bg: 'acc-l', color: 'acc', path: '/pages/pkg-profile/events/index' }, ], }, { title: '账号', items: [ - { label: '就诊人管理', icon: '家', bg: 'pri-l', color: 'pri', path: '/pages/pkg-profile/family/index' }, - { label: '长辈模式', icon: '老', bg: 'acc-l', color: 'acc', path: '/pages/pkg-profile/elder-mode/index' }, { label: '设备同步', icon: '设', bg: 'surface-alt', color: 'tx3', path: '/pages/pkg-health/device-sync/index' }, { label: '设置', icon: '齿', bg: 'surface-alt', color: 'tx3', path: '/pages/pkg-profile/settings/index' }, ], @@ -76,7 +47,6 @@ const GUEST_GROUPS: MenuGroup[] = [ { title: '设置', items: [ - { label: '长辈模式', icon: '老', bg: 'acc-l', color: 'acc', path: '/pages/pkg-profile/elder-mode/index' }, { label: '设置', icon: '齿', bg: 'surface-alt', color: 'tx3', path: '/pages/pkg-profile/settings/index' }, ], }, @@ -101,9 +71,9 @@ export default function Profile() { await refreshPoints(); setPointsLoading(false); try { - const res = await notificationService.list<{ total?: number; data?: { read?: boolean }[] }>({ page: 1, page_size: 50 }); - const items = (res as { data?: { read?: boolean }[] })?.data || []; - setUnreadCount(items.filter((n) => !n.read).length); + const res = await notificationService.getUnreadCount(); + const count = (res as { data?: { count?: number } })?.data?.count ?? 0; + setUnreadCount(count); } catch { /* ignore */ } } }, [isGuest, refreshPoints]); @@ -111,11 +81,7 @@ export default function Profile() { usePageData(fetchPoints, { throttleMs: 5000 }); const handleMenuClick = (item: MenuItem) => { - if (item.isSwitchTab) { - Taro.switchTab({ url: item.path }); - } else { - safeNavigateTo(item.path); - } + safeNavigateTo(item.path); }; const handleLogout = () => { diff --git a/crates/erp-auth/src/auth_state.rs b/crates/erp-auth/src/auth_state.rs index 108f266..2c70fd5 100644 --- a/crates/erp-auth/src/auth_state.rs +++ b/crates/erp-auth/src/auth_state.rs @@ -1,3 +1,4 @@ +use erp_core::crypto::PiiCrypto; use erp_core::events::EventBus; use sea_orm::DatabaseConnection; use uuid::Uuid; @@ -25,6 +26,7 @@ pub struct AuthState { pub wechat_secret: String, pub wechat_dev_mode: bool, pub redis: Option, + pub crypto: PiiCrypto, } /// Parse a human-readable TTL string (e.g. "15m", "7d", "1h", "900s") into seconds. diff --git a/crates/erp-auth/src/service/auth_service.rs b/crates/erp-auth/src/service/auth_service.rs index 6131e5e..c34912f 100644 --- a/crates/erp-auth/src/service/auth_service.rs +++ b/crates/erp-auth/src/service/auth_service.rs @@ -127,6 +127,12 @@ impl AuthService { return Err(AuthError::Forbidden("患者账号请使用小程序登录".to_string())); } + // 小程序端仅允许患者角色登录,医护角色请使用管理端 + let has_patient_role = roles.iter().any(|r| r == "patient"); + if is_miniprogram && !has_patient_role { + return Err(AuthError::Forbidden("医护账号请使用管理端登录".to_string())); + } + let permissions = TokenService::get_user_permissions(user_model.id, tenant_id, db).await?; // 6. Sign tokens diff --git a/crates/erp-auth/src/service/wechat_service.rs b/crates/erp-auth/src/service/wechat_service.rs index 6453764..ec3323d 100644 --- a/crates/erp-auth/src/service/wechat_service.rs +++ b/crates/erp-auth/src/service/wechat_service.rs @@ -151,7 +151,8 @@ impl WechatService { return Err(AuthError::Validation("该微信已绑定账号".to_string())); } - let user_id = Self::find_or_create_user_by_phone(&state.db, tenant_id, &phone).await?; + let user_id = + Self::find_or_create_user_by_phone(&state.db, tenant_id, &phone, &state.crypto).await?; let now = Utc::now(); let wu = wechat_user::ActiveModel { @@ -189,6 +190,7 @@ impl WechatService { db: &sea_orm::DatabaseConnection, tenant_id: Uuid, phone: &str, + crypto: &erp_core::crypto::PiiCrypto, ) -> AuthResult { use crate::entity::user; @@ -234,7 +236,7 @@ impl WechatService { Self::assign_patient_role(db, tenant_id, user_id).await?; // 自动创建或关联 patient 记录 - Self::ensure_patient_record(db, tenant_id, user_id, phone).await?; + Self::ensure_patient_record(db, tenant_id, user_id, phone, crypto).await?; Ok(user_id) } @@ -282,12 +284,14 @@ impl WechatService { /// 自动创建或关联 patient 记录。 /// /// 1. 如果已有 user_id 关联的 patient → 跳过 - /// 2. 否则 → 创建新的 patient 记录 + /// 2. 如果手机号盲索引匹配到未绑定的已有患者 → 合并(关联 user_id) + /// 3. 否则 → 创建新的 patient 记录 async fn ensure_patient_record( db: &sea_orm::DatabaseConnection, tenant_id: Uuid, user_id: Uuid, phone: &str, + crypto: &erp_core::crypto::PiiCrypto, ) -> AuthResult<()> { use sea_orm::{ConnectionTrait, Statement}; @@ -306,6 +310,40 @@ impl WechatService { return Ok(()); } + // 智能合并:用手机号盲索引查找未绑定的已有患者(管理员/护士建档) + let phone_hash = erp_core::crypto::hmac_hash(crypto.hmac_key(), phone); + let blind_match: Option = db + .query_one(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + r#"SELECT bi.entity_id AS patient_id + FROM blind_index bi + JOIN patient p ON p.id = bi.entity_id AND p.tenant_id = $2 AND p.deleted_at IS NULL + WHERE bi.entity_type = 'patient' + AND bi.field_name = 'emergency_contact_phone' + AND bi.blind_hash = $1 + AND bi.tenant_id = $2 + AND p.user_id IS NULL + LIMIT 1"#, + [phone_hash.as_str().into(), tenant_id.into()], + )) + .await + .map_err(|e| AuthError::DbError(e.to_string()))?; + + if let Some(row) = blind_match { + let patient_id: Uuid = row + .try_get("", "patient_id") + .map_err(|e| AuthError::DbError(format!("blind_index parse: {}", e)))?; + db.execute(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + "UPDATE patient SET user_id = $1, updated_at = NOW(), updated_by = $1 WHERE id = $2 AND user_id IS NULL", + [user_id.into(), patient_id.into()], + )) + .await + .map_err(|e| AuthError::DbError(e.to_string()))?; + tracing::info!(%user_id, %patient_id, "手机号盲索引合并 patient"); + return Ok(()); + } + let suffix = &phone[phone.len().saturating_sub(4)..]; let patient_id = Uuid::now_v7(); let now = Utc::now(); diff --git a/crates/erp-server/src/state.rs b/crates/erp-server/src/state.rs index f7e9948..553e6ca 100644 --- a/crates/erp-server/src/state.rs +++ b/crates/erp-server/src/state.rs @@ -59,6 +59,7 @@ impl FromRef for erp_auth::AuthState { wechat_secret: state.config.wechat.secret.clone(), wechat_dev_mode: state.config.wechat.dev_mode, redis: Some(state.redis.clone()), + crypto: state.pii_crypto.clone(), } } } diff --git a/docs/discussions/2026-05-23-account-registration-login-flow.md b/docs/discussions/2026-05-23-account-registration-login-flow.md new file mode 100644 index 0000000..1cce076 --- /dev/null +++ b/docs/discussions/2026-05-23-account-registration-login-flow.md @@ -0,0 +1,171 @@ +# 账号注册与登录流程讨论 + +> 日期: 2026-05-23 | 参与者: iven, Claude + +## 背景 + +系统有 5 个角色(admin/doctor/nurse/patient/operator)和 2 个终端(Web/小程序)。需要明确患者账号的创建路径、权限分配和业务流程边界。 + +## 现状梳理 + +### 患者账号创建路径 + +- **路径 A — 小程序自注册**:微信登录 → 手机号授权 → 自动在 `users` 表建账号 + 分配 patient 角色 + 在 `patient` 表建档(`wechat_service.rs`) +- **路径 B — Web 后台建档**:管理员/医护在 Web 端手动或 CSV 批量创建,`patient.user_id = None` +- **路径 C — 自助绑定**:患者小程序注册后通过 `bind-by-phone` 盲索引匹配关联已有档案 + +### 权限模型 + +- patient 角色自动分配,data_scope=`self` +- 纯 patient 角色被 Web 端拦截(auth_service 中 `is_pure_patient && !is_miniprogram → 拒绝`) +- 权限码 18 个 `.list` + 15 个 `.manage`,覆盖健康数据、预约、随访、咨询、积分等 + +### 患者相关业务流程(26 个) + +- 注册绑定(4): 微信登录、账号密码登录、自助绑定、后台建档 +- 健康互动(6): 数据查看、体征录入、设备同步、AI 分析、AI 对话、用药管理 +- 医疗服务(4): 在线预约、在线咨询、随访任务、关怀计划 +- 内容积分(5): 文章阅读、轮播图、签到、积分查询、积分兑换 +- 隐私授权(3): 知情同意、家属代理、资料编辑 +- 消息通知(2): 消息通知、告警接收 +- 透析专属(2): 透析记录、透析排班 + +## 讨论要点与决策 + +### 要点 1:患者注册路径的合并策略 + +**问题:** 同一人可能先被管理员建档,后来自注册产生两条记录。 + +**决策:需要智能合并策略。** 小程序注册时应检测已有档案并主动引导关联,避免重复。 + +### 要点 2:混合角色支持 + +**问题:** 一个用户是否可以同时拥有 patient + nurse 等多角色? + +**决策:不支持混合角色。** 保持角色单一、清晰,一个人只属于一个角色。如果一个医护也是患者,需要独立的 patient 账号。 + +### 要点 3:小程序多角色支持 + +**问题:** 小程序是否需要支持 doctor/nurse/admin/operator 登录? + +**决策:当前阶段小程序仅支持患者端。** 其他角色(doctor/nurse/admin/operator/health_manager)在小程序端冻结,后续按需开放。现有 credential login 的医疗角色入口需要屏蔽或隐藏。 + +### 要点 4:功能复杂度控制 + +**问题:** 患者登录后的功能是否一步到位? + +**决策:先出一版稳定可用的给甲方测试。** 聚焦核心流程(登录、健康数据查看、预约、消息),不追求功能全覆盖,优先保证稳定性和基本体验。 + +## T1:智能合并策略(已决议) + +### 现状缺口 + +`wechat_service.rs` 的 `ensure_patient_record` 只按 `user_id` 检查,不按手机号查重。管理员先建档 → 患者后自注册 → 产生两条 patient 记录。 + +### 决策 + +| 项 | 决策 | +|----|------| +| 匹配字段 | 使用现有 `emergency_contact_phone` 盲索引做近似匹配 | +| 多匹配处理 | 一个手机号只允许关联一条 patient 记录(严格去重) | +| 冲突处理 | 以管理员建档数据为准,信息不一致由护士在 Web 端修改 | + +### 实现思路 + +``` +微信绑定手机号 → 解密 phone + → 计算 HMAC(phone) → 查 blind_index(emergency_contact_phone) + → 找到未绑定的 patient → 关联 user_id,不新建 + → 未找到 → 创建新 patient 记录 +``` + +### 影响范围 + +- `crates/erp-auth/src/service/wechat_service.rs` — `ensure_patient_record` 增加盲索引查询 +- `crates/erp-health/src/entity/blind_index.rs` — 可能需要从 erp-auth 跨 crate 查询(或用 raw SQL) + +## T2:医生端独立分包处理(已决议) + +### 现状 + +| 分包 | 页面 | 代码量 | 类型 | +|------|------|--------|------| +| `pkg-doctor-core` | 8 页 | 104 KB | independent 分包 | +| `pkg-doctor-clinical` | 10 页 | 124 KB | independent 分包 | +| `DoctorTabBar` 组件 | — | 8 KB | 条件渲染 | +| 角色分流逻辑 | — | ~18 个文件引用 | 散布各处 | + +### 性能影响分析 + +| 维度 | 影响 | 程度 | +|------|------|------| +| **包体积(提交)** | 注册在 app.config.ts 中的页面增加总包体积 ~236 KB | 微小(微信主包限制 2MB,当前远未触及) | +| **运行时加载** | `independent: true` 分包只在用户主动跳转时下载,不跳转 = 不加载 | **零影响** | +| **预加载** | 首页 preloadRule 包含 `pkg-doctor-core`,会触发提前下载 | **需移除预加载项** | +| **首屏渲染** | auth.ts 中 `isMedicalStaff()` 是纯数组比较,DoctorTabBar 条件渲染 | **零影响** | +| **内存** | 未加载的分包代码不占用运行内存 | **零影响** | + +### 决策:保留代码,隐藏入口 + +保留全部医生端代码,仅做以下调整: + +1. **后端拦截** — `auth_service.rs` 中拒绝非 patient 角色登录小程序(仅保留 `client_type=miniprogram + patient` 组合) +2. **前端隐藏入口** — 小程序登录页移除账号密码登录入口(仅保留微信一键登录) +3. **移除预加载** — `app.config.ts` 的 preloadRule 中移除 `pkg-doctor-core` +4. **保留但不触发** — 角色分流逻辑(isMedicalStaff/DoctorTabBar)保留在代码中,但不会被触发 + +### 后续恢复路径 + +需要重新开放医生端时: +1. 恢复 credential login 入口 +2. 后端放开角色限制 +3. preloadRule 加回预加载 +4. 零代码修改,纯配置变更 + +## T3:甲方测试版功能裁剪(已决议) + +### 冻结页面清单 + +| 模块 | 页面 | 页面数 | 冻结方式 | +|------|------|--------|----------| +| **医生端** — `pkg-doctor-core` | 工作台/患者/咨询/随访/行动收件箱 | 8 | 隐藏入口 + 移除预加载 | +| **医生端** — `pkg-doctor-clinical` | 透析/处方/报告/告警 | 10 | 隐藏入口 | +| **透析相关** | pkg-profile/dialysis-records + dialysis-prescriptions (各 list+detail) | 4 | 移除导航入口 | +| **家属管理** | pkg-profile/family + family-add | 2 | 移除导航入口 | +| **用药管理** | pkg-profile/medication | 1 | 移除导航入口 | +| **知情同意** | pkg-profile/consents | 1 | 移除导航入口 | +| **诊断记录** | pkg-profile/diagnoses | 1 | 移除导航入口 | +| **事件日志** | pkg-profile/events | 1 | 移除导航入口 | +| **小计冻结** | | **28 页** | | + +### 保留页面清单(甲方测试版) + +| 模块 | 页面 | 页面数 | 说明 | +|------|------|--------|------| +| **主包** | login / index / health / messages / consultation / create / mall / profile / legal×2 | 10 | TabBar 全部保留 | +| **pkg-health** | trend / input / daily-monitoring / alerts / device-sync | 5 | 健康核心 | +| **pkg-mall** | exchange / orders / detail / product | 4 | 商城 | +| **pkg-profile** | reports / reports-detail / followups / followups-detail / settings / health-records / elder-mode / notifications | 8 | 档案+设置 | +| **ai-report** | list / detail | 2 | AI 报告 | +| **article** | index / detail | 2 | 文章 | +| **appointment** | index / create / detail | 3 | 预约 | +| **pkg-consultation** | detail | 1 | 咨询详情 | +| **小计保留** | | **35 页** | | + +### 裁剪实施方式 + +冻结页面的代码保留在仓库中,通过以下方式隐藏: +1. **移除导航入口** — profile 页面中移除冻结模块的菜单项 +2. **保留路由注册** — app.config.ts 中的页面注册保留(避免深度链接崩溃) +3. **直接访问容错** — 被冻结页面若被直接访问,显示"功能即将上线"占位 + +## 全部决策汇总 + +| # | 决策 | 影响范围 | 优先级 | +|---|------|----------|--------| +| D1 | 智能合并 — 用 emergency_contact_phone 盲索引匹配 | wechat_service.rs | 高 | +| D2 | 不支持混合角色 | auth 模块 | 已实现 | +| D3 | 小程序仅患者端,冻结其他角色 | auth_service.rs + 登录页 | 高 | +| D4 | 功能精简,先稳定再迭代 | 小程序全局 | — | +| D5 | 医生端保留代码隐藏入口,移除预加载 | app.config.ts + auth_service.rs | 中 | +| D6 | 冻结 28 页:透析×4 + 家属×2 + 用药 + 知情同意 + 诊断 + 事件 + 医生端×18 | profile 页菜单 + 页面容错 | 中 |