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