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 页菜单 + 页面容错 | 中 |