feat(auth,mp): 患者登录流程优化 — 智能合并 + 角色冻结 + 页面冻结

- 智能合并:微信注册时用手机号盲索引匹配已有患者档案,避免重复建
  档(AuthState 添加 PiiCrypto + ensure_patient_record 增加盲索引查询)
- 角色冻结:小程序仅允许患者角色登录,医护角色被拦截
  (auth_service.rs 添加反向拦截 + 登录页移除 credential login 表单)
- 页面冻结:10 个非核心页面替换为 FrozenPage 占位组件(用药/知情同意
  /透析/家属/诊断/事件),移除 profile 导航入口,移除医生端预加载
- 医生端代码保留,仅隐藏入口,后续可零成本恢复

讨论记录:docs/discussions/2026-05-23-account-registration-login-flow.md
This commit is contained in:
iven
2026-05-23 12:27:14 +08:00
parent f7d98a59f0
commit f11dd59382
21 changed files with 328 additions and 1510 deletions

View File

@@ -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',

View File

@@ -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;
}

View File

@@ -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 (
<PageShell scroll={false}>
<View className="frozen-page">
<Text className="frozen-page-icon">🚧</Text>
<Text className="frozen-page-title">线</Text>
<Text className="frozen-page-desc"></Text>
<View
className="frozen-page-btn"
onClick={() => Taro.navigateBack({ delta: 1 }).catch(() => Taro.switchTab({ url: '/pages/index/index' }))}
>
<Text className="frozen-page-btn-text"></Text>
</View>
</View>
</PageShell>
);
}

View File

@@ -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;

View File

@@ -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<string, unknown>)?.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 ? (
<>
{/* 账号输入 */}
<View className="login-field">
<Input
className="login-input"
type="text"
placeholder="请输入账号"
placeholderClass="login-placeholder"
value={username}
onInput={(e) => setUsername(e.detail.value)}
/>
</View>
{/* 密码输入 */}
<View className="login-field">
<Input
className="login-input"
type="safe-password"
password={!showPassword}
placeholder="请输入密码"
placeholderClass="login-placeholder"
value={password}
onInput={(e) => setPassword(e.detail.value)}
/>
<Text
className="login-eye"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? '隐藏' : '显示'}
</Text>
</View>
{/* 登录按钮 */}
<View className="login-submit" onClick={handleCredentialLogin}>
<Text className="login-submit-text">{loading ? '登录中...' : '登录'}</Text>
</View>
{/* 分隔线 */}
<View className="login-divider">
<View className="login-divider-line" />
<Text className="login-divider-text"></Text>
<View className="login-divider-line" />
</View>
{/* 微信一键登录 */}
<View className="login-wechat-btn" onClick={handleWechatLogin}>
<Text className="login-wechat-icon"></Text>

View File

@@ -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<string, string> = {
data_processing: '数据处理同意',
health_data_collection: '健康数据采集',
research_use: '科研使用',
third_party_share: '第三方共享',
genetic_testing: '基因检测',
telemedicine: '远程医疗',
};
const STATUS_MAP: Record<string, { label: string; cls: string }> = {
granted: { label: '已签署', cls: 'granted' },
revoked: { label: '已撤回', cls: 'revoked' },
};
export default function ConsentList() {
const modeClass = useElderClass();
const [consents, setConsents] = useState<Consent[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [revoking, setRevoking] = useState<string | null>(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 (
<PageShell className={modeClass}>
<Text className='page-title'></Text>
<View className='consent-list'>
{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 (
<View className='consent-card' key={c.id}>
<View className='consent-card__header'>
<Text className='consent-card__type'>{typeName}</Text>
<Text className={`status-tag ${si.cls}`}>{si.label}</Text>
</View>
<Text className='consent-card__scope'>: {c.consent_scope}</Text>
{c.granted_at && (
<Text className='consent-card__date'>: {c.granted_at}</Text>
)}
{c.revoked_at && (
<Text className='consent-card__date'>: {c.revoked_at}</Text>
)}
{c.expiry_date && (
<Text className='consent-card__expiry'>: {c.expiry_date}</Text>
)}
{c.status === 'granted' && (
<View
className={`revoke-btn ${revoking === c.id ? 'revoke-btn--disabled' : ''}`}
onClick={() => handleRevoke(c)}
>
<Text className='revoke-btn__text'>{revoking === c.id ? '处理中...' : '撤回同意'}</Text>
</View>
)}
</View>
);
})}
</View>
{consents.length === 0 && !loading && (
<EmptyState text={hasPatient ? '暂无知情同意记录' : '请先在就诊人管理中选择就诊人'} />
)}
{loading && <Loading />}
</PageShell>
);
export default function ConsentsPage() {
return <FrozenPage />;
}

View File

@@ -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<string, { label: string; cls: string }> = {
primary: { label: '主要', cls: 'primary' },
secondary: { label: '次要', cls: 'secondary' },
comorbid: { label: '合并症', cls: 'comorbid' },
};
const STATUS_MAP: Record<string, { label: string; cls: string }> = {
active: { label: '活动', cls: 'active' },
resolved: { label: '已解决', cls: 'resolved' },
chronic: { label: '慢性', cls: 'chronic' },
};
export default function Diagnoses() {
const modeClass = useElderClass();
const [records, setRecords] = useState<Diagnosis[]>([]);
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 (
<PageShell className={modeClass}>
<Text className='page-title'></Text>
<View className='diagnosis-list'>
{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 (
<View className='diagnosis-card' key={d.id}>
<View className='diagnosis-card__header'>
<Text className='diagnosis-card__name'>{d.diagnosis_name}</Text>
<Text className={`diagnosis-card__status ${statusInfo.cls}`}>
{statusInfo.label}
</Text>
</View>
<View className='diagnosis-card__meta'>
<Text className={`diagnosis-card__type ${typeInfo.cls}`}>
{typeInfo.label}
</Text>
<Text className='diagnosis-card__code'>{d.icd_code}</Text>
</View>
<Text className='diagnosis-card__date'>{d.diagnosed_date}</Text>
{d.notes && (
<Text className='diagnosis-card__notes'>{d.notes}</Text>
)}
</View>
);
})}
</View>
{records.length === 0 && !loading && (
<EmptyState text={hasPatient ? '暂无诊断记录' : '请先在就诊人管理中选择就诊人'} />
)}
{loading && <Loading />}
</PageShell>
);
export default function DiagnosesPage() {
return <FrozenPage />;
}

View File

@@ -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<string, { label: string; cls: string }> = {
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<DialysisPrescription | null>(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 <PageShell className={modeClass}><Loading /></PageShell>;
if (!rx) return <PageShell className={modeClass}><View className='empty-state'><Text className='empty-text'></Text></View></PageShell>;
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 (
<View className='detail-row'>
<Text className='detail-label'>{label}</Text>
<Text className='detail-value'>{value}</Text>
</View>
);
};
return (
<PageShell className={modeClass}>
{/* 状态头部 */}
<ContentCard>
<View className='header-row'>
<Text className='detail-title'>{rx.dialyzer_model || '透析处方'}</Text>
<Text className={`status-tag ${si.cls}`}>{si.label}</Text>
</View>
{(rx.effective_from || rx.effective_to) && (
<Text className='header-sub'>{rx.effective_from || '...'} ~ {rx.effective_to || '...'}</Text>
)}
</ContentCard>
{/* 基本参数 */}
<ContentCard>
<Text className='section-title'></Text>
<Row label='透析器型号' value={rx.dialyzer_model} />
<Row label='膜面积' value={rx.membrane_area != null ? `${rx.membrane_area}` : null} />
<Row label='血流速' value={rx.blood_flow_rate != null ? `${rx.blood_flow_rate} ml/min` : null} />
<Row label='透析液流量' value={rx.dialysate_flow_rate != null ? `${rx.dialysate_flow_rate} ml/min` : null} />
<Row label='频率' value={rx.frequency_per_week != null ? `${rx.frequency_per_week} 次/周` : null} />
<Row label='每次时长' value={rx.duration_minutes != null ? `${rx.duration_minutes} 分钟` : null} />
</ContentCard>
{/* 透析液配比 */}
<ContentCard>
<Text className='section-title'></Text>
<Row label='钾浓度' value={rx.dialysate_potassium != null ? `${rx.dialysate_potassium} mmol/L` : null} />
<Row label='钙浓度' value={rx.dialysate_calcium != null ? `${rx.dialysate_calcium} mmol/L` : null} />
<Row label='碳酸氢盐浓度' value={rx.dialysate_bicarbonate != null ? `${rx.dialysate_bicarbonate} mmol/L` : null} />
</ContentCard>
{/* 抗凝方案 */}
<ContentCard>
<Text className='section-title'></Text>
<Row label='抗凝类型' value={rx.anticoagulation_type} />
<Row label='抗凝剂量' value={rx.anticoagulation_dose} />
</ContentCard>
{/* 血管通路 */}
{(rx.vascular_access_type || rx.vascular_access_location) && (
<ContentCard>
<Text className='section-title'></Text>
<Row label='通路类型' value={rx.vascular_access_type} />
<Row label='通路位置' value={rx.vascular_access_location} />
</ContentCard>
)}
{/* 超滤与干体重 */}
{(rx.target_ultrafiltration_ml != null || rx.target_dry_weight != null) && (
<ContentCard>
<Text className='section-title'></Text>
<Row label='目标超滤量' value={rx.target_ultrafiltration_ml != null ? `${rx.target_ultrafiltration_ml} ml` : null} />
<Row label='目标干体重' value={rx.target_dry_weight != null ? `${rx.target_dry_weight} kg` : null} />
</ContentCard>
)}
{/* 备注 */}
{rx.notes && (
<ContentCard>
<Text className='section-title'></Text>
<Text className='notes-text'>{rx.notes}</Text>
</ContentCard>
)}
</PageShell>
);
export default function DialysisPrescriptionDetailPage() {
return <FrozenPage />;
}

View File

@@ -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<string, { label: string; cls: string }> = {
active: { label: '生效中', cls: 'active' },
inactive: { label: '已停用', cls: 'inactive' },
expired: { label: '已过期', cls: 'expired' },
};
export default function DialysisPrescriptionList() {
const modeClass = useElderClass();
const [prescriptions, setPrescriptions] = useState<DialysisPrescription[]>([]);
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 (
<PageShell className={modeClass}>
<Text className='page-title'></Text>
<View className='prescription-list'>
{prescriptions.map((p) => {
const si = statusInfo(p.status);
return (
<View
className='prescription-card'
key={p.id}
onClick={() => safeNavigateTo(`/pages/pkg-profile/dialysis-prescriptions/detail/index?id=${p.id}`)}
>
<View className='prescription-card-top'>
<Text className='prescription-model'>{p.dialyzer_model || '未指定型号'}</Text>
<Text className={`status-tag ${si.cls}`}>{si.label}</Text>
</View>
<View className='prescription-meta'>
{p.frequency_per_week != null && (
<Text className='meta-item'>{p.frequency_per_week}/</Text>
)}
{p.duration_minutes != null && (
<Text className='meta-item'>{p.duration_minutes}</Text>
)}
</View>
{(p.effective_from || p.effective_to) && (
<Text className='prescription-date'>
{p.effective_from || '...'} ~ {p.effective_to || '...'}
</Text>
)}
</View>
);
})}
</View>
{prescriptions.length === 0 && !loading && (
<EmptyState text={hasPatient ? '暂无透析处方' : '请先在就诊人管理中选择就诊人'} />
)}
{loading && <Loading />}
</PageShell>
);
export default function DialysisPrescriptionsPage() {
return <FrozenPage />;
}

View File

@@ -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<string, { label: string; cls: string }> = {
draft: { label: '草稿', cls: 'draft' },
completed: { label: '已完成', cls: 'completed' },
reviewed: { label: '已审核', cls: 'reviewed' },
};
const TYPE_MAP: Record<string, string> = {
HD: '血液透析',
HDF: '血液透析滤过',
HF: '血液滤过',
};
export default function DialysisRecordDetail() {
const modeClass = useElderClass();
const router = useRouter();
const id = router.params.id || '';
const [record, setRecord] = useState<DialysisRecord | null>(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 <PageShell className={modeClass}><Loading /></PageShell>;
if (!record) return <PageShell className={modeClass}><View className='empty-state'><Text className='empty-text'></Text></View></PageShell>;
const si = STATUS_MAP[record.status] || { label: record.status, cls: '' };
return (
<PageShell className={modeClass}>
{/* 状态头部 */}
<ContentCard>
<View className='header-row'>
<Text className='detail-title'>{record.dialysis_date}</Text>
<Text className={`status-tag ${si.cls}`}>{si.label}</Text>
</View>
<Text className='header-sub'>{TYPE_MAP[record.dialysis_type] || record.dialysis_type}</Text>
{record.reviewed_at && <Text className='review-info'> {record.reviewed_at}</Text>}
</ContentCard>
{/* 基本信息 */}
<ContentCard>
<Text className='section-title'></Text>
{record.start_time && (
<View className='detail-row'><Text className='detail-label'></Text><Text className='detail-value'>{record.start_time}</Text></View>
)}
{record.end_time && (
<View className='detail-row'><Text className='detail-label'></Text><Text className='detail-value'>{record.end_time}</Text></View>
)}
{record.dialysis_duration != null && (
<View className='detail-row'><Text className='detail-label'></Text><Text className='detail-value'>{record.dialysis_duration} </Text></View>
)}
{record.blood_flow_rate != null && (
<View className='detail-row'><Text className='detail-label'></Text><Text className='detail-value'>{record.blood_flow_rate} ml/min</Text></View>
)}
{record.ultrafiltration_volume != null && (
<View className='detail-row'><Text className='detail-label'></Text><Text className='detail-value'>{record.ultrafiltration_volume} ml</Text></View>
)}
</ContentCard>
{/* 体重与血压 */}
<ContentCard>
<Text className='section-title'></Text>
{record.dry_weight != null && (
<View className='detail-row'><Text className='detail-label'></Text><Text className='detail-value'>{record.dry_weight} kg</Text></View>
)}
{record.pre_weight != null && (
<View className='detail-row'><Text className='detail-label'></Text><Text className='detail-value'>{record.pre_weight} kg</Text></View>
)}
{record.post_weight != null && (
<View className='detail-row'><Text className='detail-label'></Text><Text className='detail-value'>{record.post_weight} kg</Text></View>
)}
{record.pre_bp_systolic != null && record.pre_bp_diastolic != null && (
<View className='detail-row'><Text className='detail-label'></Text><Text className='detail-value'>{record.pre_bp_systolic}/{record.pre_bp_diastolic} mmHg</Text></View>
)}
{record.post_bp_systolic != null && record.post_bp_diastolic != null && (
<View className='detail-row'><Text className='detail-label'></Text><Text className='detail-value'>{record.post_bp_systolic}/{record.post_bp_diastolic} mmHg</Text></View>
)}
{record.pre_heart_rate != null && (
<View className='detail-row'><Text className='detail-label'></Text><Text className='detail-value'>{record.pre_heart_rate} bpm</Text></View>
)}
{record.post_heart_rate != null && (
<View className='detail-row'><Text className='detail-label'></Text><Text className='detail-value'>{record.post_heart_rate} bpm</Text></View>
)}
</ContentCard>
{/* 症状与并发症 */}
{(record.symptoms || record.complication_notes) && (
<ContentCard>
<Text className='section-title'></Text>
{record.symptoms && Object.keys(record.symptoms).length > 0 && (
<View className='detail-row'><Text className='detail-label'></Text><Text className='detail-value'>{JSON.stringify(record.symptoms)}</Text></View>
)}
{record.complication_notes && (
<View className='detail-row'><Text className='detail-label'></Text><Text className='detail-value'>{record.complication_notes}</Text></View>
)}
</ContentCard>
)}
</PageShell>
);
export default function DialysisRecordDetailPage() {
return <FrozenPage />;
}

View File

@@ -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<string, { label: string; cls: string }> = {
HD: { label: 'HD', cls: 'hd' },
HDF: { label: 'HDF', cls: 'hdf' },
HF: { label: 'HF', cls: 'hf' },
};
const STATUS_MAP: Record<string, { label: string; cls: string }> = {
draft: { label: '草稿', cls: 'draft' },
completed: { label: '已完成', cls: 'completed' },
reviewed: { label: '已审核', cls: 'reviewed' },
};
export default function DialysisRecordList() {
const modeClass = useElderClass();
const [records, setRecords] = useState<DialysisRecord[]>([]);
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 (
<PageShell className={modeClass}>
<Text className='page-title'></Text>
<View className='record-list'>
{records.map((r) => {
const ti = typeInfo(r.dialysis_type);
const si = statusInfo(r.status);
return (
<View
className='record-card'
key={r.id}
onClick={() => safeNavigateTo(`/pages/pkg-profile/dialysis-records/detail/index?id=${r.id}`)}
>
<View className='record-card-top'>
<Text className={`type-tag ${ti.cls}`}>{ti.label}</Text>
<Text className={`status-tag ${si.cls}`}>{si.label}</Text>
</View>
<Text className='record-date'>{r.dialysis_date}</Text>
{(r.pre_weight || r.post_weight) && (
<View className='weight-row'>
{r.pre_weight && <Text className='weight-item'> {r.pre_weight}kg</Text>}
{r.post_weight && <Text className='weight-item'> {r.post_weight}kg</Text>}
</View>
)}
{r.dialysis_duration && (
<Text className='record-meta'> {r.dialysis_duration}</Text>
)}
</View>
);
})}
</View>
{records.length === 0 && !loading && (
<EmptyState text={hasPatient ? '暂无透析记录' : '请先在就诊人管理中选择就诊人'} />
)}
{loading && <Loading />}
</PageShell>
);
export default function DialysisRecordsPage() {
return <FrozenPage />;
}

View File

@@ -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<string, { label: string; className: string }> = {
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<pointsApi.OfflineEvent[]>([]);
const [loading, setLoading] = useState(true);
const [registering, setRegistering] = useState<string | null>(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 <Loading />;
return (
<PageShell className={modeClass}>
<View className='events-header'>
<Text className='events-header__title'>线</Text>
<Text className='events-header__subtitle'></Text>
</View>
{events.length === 0 ? (
<EmptyState text='暂无可报名的活动' />
) : (
<View className='event-list'>
{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 (
<View key={event.id} className='event-card'>
<View className='event-card__header'>
<View className={`event-card__status ${st.className}`}>
<Text>{st.label}</Text>
</View>
<Text className='event-card__points'>+{event.points_reward} </Text>
</View>
<Text className='event-card__title'>{event.title}</Text>
{event.description && (
<Text className='event-card__desc'>{event.description}</Text>
)}
<View className='event-card__info'>
<Text className='event-card__date'>{formatDate(event.event_date)}</Text>
{event.location && (
<Text className='event-card__location'>{event.location}</Text>
)}
</View>
<View className='event-card__footer'>
<Text className='event-card__participants'>
{event.current_participants}{event.max_participants ? `/${event.max_participants}` : ''}
</Text>
<View
className={`event-card__btn ${isFull ? 'event-card__btn--disabled' : ''}`}
onClick={() => !isFull && !isRegistering && handleRegister(event)}
>
<Text className='event-card__btn-text'>
{isRegistering ? '报名中...' : isFull ? '已满' : '立即报名'}
</Text>
</View>
</View>
</View>
);
})}
</View>
)}
</PageShell>
);
return <FrozenPage />;
}

View File

@@ -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 (
<PageShell padding="md" safeBottom={false} scroll={false} className={`family-add-page ${modeClass}`}>
<Text className='family-add-title'>{editId ? '编辑就诊人' : '添加就诊人'}</Text>
{/* 提示卡片 */}
<View className='family-add-tip'>
<Text className='family-add-tip-title'></Text>
<Text className='family-add-tip-desc'>
使
</Text>
</View>
{/* 表单 */}
<View className='form-card'>
<View className='form-item'>
<Text className='form-label'><Text className='form-required'>*</Text></Text>
<View className='form-input-wrap'>
<Input
className='form-input'
placeholder='请输入真实姓名'
placeholderClass='form-placeholder'
value={name}
onInput={(e) => setName(e.detail.value)}
/>
</View>
</View>
<View className='form-item'>
<Text className='form-label'><Text className='form-required'>*</Text></Text>
<Picker
mode='selector'
range={RELATION_OPTIONS}
value={relationIdx}
onChange={(e) => setRelationIdx(Number(e.detail.value))}
>
<View className='form-picker-wrap'>
<Text className='form-picker-text'>{RELATION_OPTIONS[relationIdx]}</Text>
<Text className='form-picker-arrow'></Text>
</View>
</Picker>
</View>
<View className='form-item'>
<Text className='form-label'><Text className='form-required'>*</Text></Text>
<Picker
mode='selector'
range={GENDER_OPTIONS}
value={genderIdx}
onChange={(e) => setGenderIdx(Number(e.detail.value))}
>
<View className='form-picker-wrap'>
<Text className='form-picker-text'>{GENDER_OPTIONS[genderIdx]}</Text>
<Text className='form-picker-arrow'></Text>
</View>
</Picker>
</View>
<View className='form-item'>
<Text className='form-label'><Text className='form-required'>*</Text></Text>
<Picker
mode='date'
value={birthDate || '2000-01-01'}
onChange={(e) => setBirthDate(e.detail.value)}
>
<View className='form-picker-wrap'>
<Text className={`form-picker-text ${!birthDate ? 'placeholder' : ''}`}>
{birthDate || '请选择'}
</Text>
<Text className='form-picker-arrow'></Text>
</View>
</Picker>
</View>
<View className='form-item'>
<Text className='form-label'></Text>
<View className='form-input-wrap'>
<Input
className='form-input'
placeholder='选填,用于接收通知'
placeholderClass='form-placeholder'
type='number'
maxlength={11}
/>
</View>
</View>
<View className='form-item'>
<Text className='form-label'></Text>
<View className='form-input-wrap'>
<Input
className='form-input'
placeholder='选填,用于医保对接'
placeholderClass='form-placeholder'
maxlength={18}
/>
</View>
</View>
</View>
<View
className={`submit-btn ${submitting ? 'disabled' : ''}`}
onClick={submitting ? undefined : handleSubmit}
>
<Text className='submit-text'>{submitting ? '提交中...' : editId ? '保存修改' : '确认添加'}</Text>
</View>
</PageShell>
);
export default function FamilyAddPage() {
return <FrozenPage />;
}

View File

@@ -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<string, string> = {
'本人': '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<Patient[]>([]);
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 (
<PageShell padding="md" safeBottom={false} scroll={false} className={`family-page ${modeClass}`}>
<Text className='family-page-title'></Text>
<Text className='family-hint'>使</Text>
<View className='family-list'>
{patients.map((p) => {
const isActive = currentPatient?.id === p.id;
const relClass = getRelationClass(p.relation || '本人');
return (
<View
className={`family-item ${isActive ? 'active' : ''}`}
key={p.id}
onClick={() => handleSelect(p)}
>
<View className={`family-avatar family-avatar--${relClass}`}>
<Text className='family-avatar-text'>{p.name.charAt(0)}</Text>
</View>
<View className='family-info'>
<View className='family-name-row'>
<Text className='family-name'>{p.name}</Text>
{isActive && <Text className='family-current-tag'></Text>}
</View>
<View className='family-meta'>
<Text className={`family-relation-tag family-relation-tag--${relClass}`}>
{p.relation || '本人'}
</Text>
<Text>{genderText(p.gender)}</Text>
{birthYear(p.birth_date) && <Text>{birthYear(p.birth_date)}</Text>}
</View>
</View>
<View
className='family-edit'
onClick={(e) => { e.stopPropagation(); goToEdit(p); }}
>
<Text className='family-edit-text'></Text>
</View>
</View>
);
})}
</View>
{patients.length === 0 && !loading && (
<EmptyState text='暂无就诊人' />
)}
<View className='family-add-btn' onClick={goToAdd}>
<Text className='family-add-icon'>+</Text>
<Text className='family-add-text'></Text>
</View>
</PageShell>
);
export default function FamilyPage() {
return <FrozenPage />;
}

View File

@@ -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<MedicationReminder[]>([]);
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 (
<PageShell className={modeClass}>
<Text className='page-title'></Text>
<View className='medication-loading'>
<Text className='medication-loading-text'>...</Text>
</View>
</PageShell>
);
}
return (
<PageShell className={modeClass}>
<Text className='page-title'></Text>
<View className='reminder-list'>
{reminders.map((r) => (
<View className={`reminder-card ${!r.is_active ? 'disabled' : ''}`} key={r.id}>
<View className='reminder-avatar'>
<Text className='reminder-avatar-text'>{nameInitial(r.medication_name)}</Text>
</View>
<View className='reminder-info'>
<Text className='reminder-name'>{r.medication_name}</Text>
<Text className='reminder-dosage'>
{r.dosage || '-'} | {r.reminder_times?.join(', ') || '-'}
</Text>
</View>
<View className='reminder-actions'>
<View
className={`toggle ${r.is_active ? 'on' : 'off'}`}
onClick={() => handleToggle(r)}
>
<View className='toggle-dot' />
</View>
<Text
className='delete-btn'
onClick={() => handleDelete(r)}
>
</Text>
</View>
</View>
))}
</View>
{reminders.length === 0 && (
<EmptyState text='暂无用药提醒' />
)}
{showForm && (
<View className='form-card'>
<Text className='form-card-title'></Text>
<View className='form-item'>
<Text className='form-label'></Text>
<Input
className='form-input'
placeholder='请输入药品名称'
placeholderClass='form-placeholder'
value={formName}
onInput={(e) => setFormName(e.detail.value)}
/>
</View>
<View className='form-item'>
<Text className='form-label'></Text>
<Input
className='form-input'
placeholder='如: 1片、10ml'
placeholderClass='form-placeholder'
value={formDosage}
onInput={(e) => setFormDosage(e.detail.value)}
/>
</View>
<View className='form-item'>
<Text className='form-label'></Text>
<Picker
mode='time'
value={formTime}
onChange={(e) => setFormTime(e.detail.value)}
>
<View className='time-picker-wrap'>
<Text className='time-value'>{formTime}</Text>
<Text className='time-modify'></Text>
</View>
</Picker>
</View>
<View className='form-actions'>
<View className='form-cancel' onClick={() => setShowForm(false)}>
<Text className='form-cancel-text'></Text>
</View>
<View className='form-confirm' onClick={handleAdd}>
<Text className='form-confirm-text'></Text>
</View>
</View>
</View>
)}
{!showForm && (
<View className='add-btn' onClick={() => setShowForm(true)}>
<Text className='add-text'></Text>
</View>
)}
</PageShell>
);
export default function MedicationPage() {
return <FrozenPage />;
}

View File

@@ -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 = () => {

View File

@@ -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<redis::Client>,
pub crypto: PiiCrypto,
}
/// Parse a human-readable TTL string (e.g. "15m", "7d", "1h", "900s") into seconds.

View File

@@ -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

View File

@@ -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<Uuid> {
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<sea_orm::QueryResult> = 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();

View File

@@ -59,6 +59,7 @@ impl FromRef<AppState> 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(),
}
}
}

View File

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