refactor(mp): 分包策略优化 — 合并单页分包 + doctor 拆包 + consultation 移出主包
- 合并 4 个单页分包:report→pkg-profile/reports, followup→pkg-profile/followups, events→pkg-profile/events, device-sync→pkg-health - consultation/detail 移出主包到 pkg-consultation 分包(减少主包体积) - doctor 18 页拆分为 pkg-doctor-core(8页) + pkg-doctor-clinical(10页) - 全部导航路径和 import 路径同步更新 - 分包 10→8 个,主包页面 13→12
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
@import '../../../../styles/variables.scss';
|
||||
@import '../../../../styles/mixins.scss';
|
||||
|
||||
.alert-detail-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding: 24px;
|
||||
padding-bottom: 160px;
|
||||
}
|
||||
|
||||
.alert-detail-header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
&__tags {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__time {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-severity {
|
||||
font-size: var(--tk-font-h2);
|
||||
font-weight: 600;
|
||||
padding: 6px 16px;
|
||||
border-radius: $r-sm;
|
||||
|
||||
&--info {
|
||||
background: $bd-l;
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
&--warning {
|
||||
background: $wrn-l;
|
||||
color: $wrn;
|
||||
}
|
||||
|
||||
&--critical {
|
||||
background: $dan-l;
|
||||
color: $dan;
|
||||
}
|
||||
|
||||
&--urgent {
|
||||
background: $dan-l;
|
||||
color: $dan;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-status {
|
||||
font-size: var(--tk-font-h2);
|
||||
padding: 6px 16px;
|
||||
border-radius: $r-sm;
|
||||
|
||||
&--pending {
|
||||
background: $wrn-l;
|
||||
color: $wrn;
|
||||
}
|
||||
|
||||
&--acknowledged {
|
||||
background: $pri-l;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
&--resolved {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
&--dismissed {
|
||||
background: $bd-l;
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-detail-card {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&__label {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
word-break: break-all;
|
||||
|
||||
&--id {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
&--detail {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
font-family: monospace;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
background: $bg;
|
||||
padding: 16px;
|
||||
border-radius: $r;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.alert-detail-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 32px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.alert-action-btn {
|
||||
flex: 1;
|
||||
height: 88px;
|
||||
line-height: 88px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
border-radius: $r-lg;
|
||||
text-align: center;
|
||||
|
||||
&--primary {
|
||||
background: $pri;
|
||||
color: $card;
|
||||
border: none;
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
&--default {
|
||||
background: $bd-l;
|
||||
color: $tx2;
|
||||
border: none;
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
&--resolve {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
border: none;
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { View, Text, ScrollView, Button } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import {
|
||||
getAlert, acknowledgeAlert, dismissAlert, resolveAlert,
|
||||
type Alert,
|
||||
} from '@/services/doctor/alerts';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '../../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const SEVERITY_MAP: Record<string, { label: string; className: string }> = {
|
||||
info: { label: '提示', className: 'detail-severity--info' },
|
||||
warning: { label: '警告', className: 'detail-severity--warning' },
|
||||
critical: { label: '严重', className: 'detail-severity--critical' },
|
||||
urgent: { label: '紧急', className: 'detail-severity--urgent' },
|
||||
};
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; className: string }> = {
|
||||
pending: { label: '待处理', className: 'detail-status--pending' },
|
||||
acknowledged: { label: '已确认', className: 'detail-status--acknowledged' },
|
||||
resolved: { label: '已恢复', className: 'detail-status--resolved' },
|
||||
dismissed: { label: '已忽略', className: 'detail-status--dismissed' },
|
||||
};
|
||||
|
||||
export default function AlertDetail() {
|
||||
const modeClass = useElderClass();
|
||||
const [alert, setAlert] = useState<Alert | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
const alertId = Taro.getCurrentInstance().router?.params?.id || '';
|
||||
|
||||
const loadAlert = useCallback(async () => {
|
||||
if (!alertId) return;
|
||||
try {
|
||||
const data = await getAlert(alertId);
|
||||
setAlert(data);
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [alertId]);
|
||||
|
||||
usePageData(loadAlert, { throttleMs: 60000, enablePullDown: false, enabled: !!alertId });
|
||||
|
||||
const handleAcknowledge = async () => {
|
||||
if (!alert) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const updated = await acknowledgeAlert(alert.id, alert.version);
|
||||
setAlert(updated);
|
||||
Taro.showToast({ title: '已确认', icon: 'success' });
|
||||
} catch {
|
||||
Taro.showToast({ title: '操作失败', icon: 'none' });
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = async () => {
|
||||
if (!alert) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const updated = await dismissAlert(alert.id, alert.version);
|
||||
setAlert(updated);
|
||||
Taro.showToast({ title: '已忽略', icon: 'success' });
|
||||
} catch {
|
||||
Taro.showToast({ title: '操作失败', icon: 'none' });
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResolve = async () => {
|
||||
if (!alert) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const updated = await resolveAlert(alert.id, alert.version);
|
||||
setAlert(updated);
|
||||
Taro.showToast({ title: '已恢复', icon: 'success' });
|
||||
} catch {
|
||||
Taro.showToast({ title: '操作失败', icon: 'none' });
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Loading />;
|
||||
if (!alert) {
|
||||
return (
|
||||
<View className={`alert-detail-page ${modeClass}`}>
|
||||
<Text>告警不存在</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const severity = SEVERITY_MAP[alert.severity] ?? SEVERITY_MAP.info;
|
||||
const status = STATUS_MAP[alert.status] ?? STATUS_MAP.pending;
|
||||
const isPending = alert.status === 'pending';
|
||||
const isAcknowledged = alert.status === 'acknowledged';
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className={`alert-detail-page ${modeClass}`}>
|
||||
{/* 顶部状态 */}
|
||||
<View className='alert-detail-header'>
|
||||
<View className='alert-detail-header__tags'>
|
||||
<Text className={`detail-severity ${severity.className}`}>
|
||||
{severity.label}
|
||||
</Text>
|
||||
<Text className={`detail-status ${status.className}`}>
|
||||
{status.label}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className='alert-detail-header__time'>
|
||||
{new Date(alert.created_at).toLocaleString('zh-CN')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 告警信息 */}
|
||||
<View className='alert-detail-card'>
|
||||
<Text className='alert-detail-card__label'>告警标题</Text>
|
||||
<Text className='alert-detail-card__value'>{alert.title}</Text>
|
||||
</View>
|
||||
|
||||
<View className='alert-detail-card'>
|
||||
<Text className='alert-detail-card__label'>患者 ID</Text>
|
||||
<Text className='alert-detail-card__value alert-detail-card__value--id'>
|
||||
{alert.patient_id ? `${alert.patient_id.slice(0, 8)}...` : '-'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className='alert-detail-card'>
|
||||
<Text className='alert-detail-card__label'>严重程度</Text>
|
||||
<Text className='alert-detail-card__value'>{severity.label}</Text>
|
||||
</View>
|
||||
|
||||
{alert.detail && (
|
||||
<View className='alert-detail-card'>
|
||||
<Text className='alert-detail-card__label'>告警详情</Text>
|
||||
<Text className='alert-detail-card__value alert-detail-card__value--detail'>
|
||||
{JSON.stringify(alert.detail, null, 2)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{alert.acknowledged_by && (
|
||||
<View className='alert-detail-card'>
|
||||
<Text className='alert-detail-card__label'>处理人</Text>
|
||||
<Text className='alert-detail-card__value'>{alert.acknowledged_by}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{alert.acknowledged_at && (
|
||||
<View className='alert-detail-card'>
|
||||
<Text className='alert-detail-card__label'>确认时间</Text>
|
||||
<Text className='alert-detail-card__value'>
|
||||
{new Date(alert.acknowledged_at).toLocaleString('zh-CN')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{alert.resolved_at && (
|
||||
<View className='alert-detail-card'>
|
||||
<Text className='alert-detail-card__label'>恢复时间</Text>
|
||||
<Text className='alert-detail-card__value'>
|
||||
{new Date(alert.resolved_at).toLocaleString('zh-CN')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
{(isPending || isAcknowledged) && (
|
||||
<View className='alert-detail-actions'>
|
||||
{isPending && (
|
||||
<>
|
||||
<Button
|
||||
className='alert-action-btn alert-action-btn--primary'
|
||||
onClick={handleAcknowledge}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
确认
|
||||
</Button>
|
||||
<Button
|
||||
className='alert-action-btn alert-action-btn--default'
|
||||
onClick={handleDismiss}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
忽略
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{(isPending || isAcknowledged) && (
|
||||
<Button
|
||||
className='alert-action-btn alert-action-btn--resolve'
|
||||
onClick={handleResolve}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
恢复
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
174
apps/miniprogram/src/pages/pkg-doctor-clinical/alerts/index.scss
Normal file
174
apps/miniprogram/src/pages/pkg-doctor-clinical/alerts/index.scss
Normal file
@@ -0,0 +1,174 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
.alert-list-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding: 24px;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
.alert-list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.alert-list-title {
|
||||
font-size: var(--tk-font-num-lg);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.alert-list-count {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.alert-tabs {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.alert-tab {
|
||||
padding: 10px 24px;
|
||||
border-radius: $r-pill;
|
||||
background: $bd-l;
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
transition: all 0.2s;
|
||||
|
||||
&--active {
|
||||
background: $pri;
|
||||
color: $card;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.alert-card {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: 24px;
|
||||
box-shadow: $shadow-sm;
|
||||
border-left: 4px solid $wrn;
|
||||
|
||||
&:active {
|
||||
background: $bd-l;
|
||||
}
|
||||
|
||||
&--critical {
|
||||
border-left-color: $dan;
|
||||
}
|
||||
|
||||
&--info {
|
||||
border-left-color: $tx3;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 500;
|
||||
color: $tx;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__time {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-severity {
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
padding: 4px 12px;
|
||||
border-radius: $r-sm;
|
||||
|
||||
&--info {
|
||||
background: $bd-l;
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
&--warning {
|
||||
background: $wrn-l;
|
||||
color: $wrn;
|
||||
}
|
||||
|
||||
&--critical {
|
||||
background: $dan-l;
|
||||
color: $dan;
|
||||
}
|
||||
|
||||
&--urgent {
|
||||
background: $dan-l;
|
||||
color: $dan;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-status {
|
||||
font-size: var(--tk-font-body);
|
||||
padding: 4px 12px;
|
||||
border-radius: $r-sm;
|
||||
|
||||
&--pending {
|
||||
background: $wrn-l;
|
||||
color: $wrn;
|
||||
}
|
||||
|
||||
&--acknowledged {
|
||||
background: $pri-l;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
&--resolved {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
&--dismissed {
|
||||
background: $bd-l;
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-top: 32px;
|
||||
|
||||
&__btn {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $pri;
|
||||
padding: 12px 24px;
|
||||
|
||||
&.disabled {
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
&__info {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
}
|
||||
165
apps/miniprogram/src/pages/pkg-doctor-clinical/alerts/index.tsx
Normal file
165
apps/miniprogram/src/pages/pkg-doctor-clinical/alerts/index.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { listAlerts, type Alert } from '@/services/doctor/alerts';
|
||||
import Loading from '@/components/Loading';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import { safeNavigateTo } from '@/utils/navigate';
|
||||
import './index.scss';
|
||||
|
||||
const SEVERITY_MAP: Record<string, { label: string; className: string }> = {
|
||||
info: { label: '提示', className: 'alert-severity--info' },
|
||||
warning: { label: '警告', className: 'alert-severity--warning' },
|
||||
critical: { label: '严重', className: 'alert-severity--critical' },
|
||||
urgent: { label: '紧急', className: 'alert-severity--urgent' },
|
||||
};
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; className: string }> = {
|
||||
pending: { label: '待处理', className: 'alert-status--pending' },
|
||||
acknowledged: { label: '已确认', className: 'alert-status--acknowledged' },
|
||||
resolved: { label: '已恢复', className: 'alert-status--resolved' },
|
||||
dismissed: { label: '已忽略', className: 'alert-status--dismissed' },
|
||||
};
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'pending', label: '待处理' },
|
||||
{ value: 'acknowledged', label: '已确认' },
|
||||
{ value: 'resolved', label: '已恢复' },
|
||||
];
|
||||
|
||||
export default function AlertList() {
|
||||
const modeClass = useElderClass();
|
||||
const [alerts, setAlerts] = useState<Alert[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState('');
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
const totalPages = useMemo(() => Math.ceil(total / 20), [total]);
|
||||
|
||||
const loadAlerts = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listAlerts({
|
||||
status: activeTab || undefined,
|
||||
page,
|
||||
page_size: 20,
|
||||
});
|
||||
setAlerts(res.data || []);
|
||||
setTotal(res.total || 0);
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [activeTab, page]);
|
||||
|
||||
const { trigger } = usePageData(loadAlerts);
|
||||
|
||||
// tab/page 变化时重新加载(跳过首次 mount,由 usePageData 的 useDidShow 处理)
|
||||
useEffect(() => {
|
||||
if (mountedRef.current) {
|
||||
trigger();
|
||||
}
|
||||
mountedRef.current = true;
|
||||
}, [page, activeTab, trigger]);
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleAlertClick = (alert: Alert) => {
|
||||
safeNavigateTo(`/pages/pkg-doctor-clinical/alerts/detail/index?id=${alert.id}`);
|
||||
};
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return '刚刚';
|
||||
if (minutes < 60) return `${minutes}分钟前`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}小时前`;
|
||||
return d.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
if (loading && alerts.length === 0) return <Loading />;
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className={`alert-list-page ${modeClass}`}>
|
||||
<View className='alert-list-header'>
|
||||
<Text className='alert-list-title'>告警列表</Text>
|
||||
<Text className='alert-list-count'>共 {total} 条</Text>
|
||||
</View>
|
||||
|
||||
<View className='alert-tabs'>
|
||||
{STATUS_TABS.map((tab) => (
|
||||
<Text
|
||||
key={tab.value}
|
||||
className={`alert-tab ${activeTab === tab.value ? 'alert-tab--active' : ''}`}
|
||||
onClick={() => handleTabChange(tab.value)}
|
||||
>
|
||||
{tab.label}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{alerts.length === 0 ? (
|
||||
<EmptyState description='暂无告警' />
|
||||
) : (
|
||||
<View className='alert-cards'>
|
||||
{alerts.map((alert) => {
|
||||
const severity = SEVERITY_MAP[alert.severity] ?? SEVERITY_MAP.info;
|
||||
const status = STATUS_MAP[alert.status] ?? STATUS_MAP.pending;
|
||||
return (
|
||||
<View
|
||||
key={alert.id}
|
||||
className='alert-card'
|
||||
onClick={() => handleAlertClick(alert)}
|
||||
>
|
||||
<View className='alert-card__header'>
|
||||
<Text className={`alert-severity ${severity.className}`}>
|
||||
{severity.label}
|
||||
</Text>
|
||||
<Text className={`alert-status ${status.className}`}>
|
||||
{status.label}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className='alert-card__title'>{alert.title}</Text>
|
||||
<View className='alert-card__footer'>
|
||||
<Text className='alert-card__time'>{formatTime(alert.created_at)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{total > 20 && (
|
||||
<View className='alert-pagination'>
|
||||
<Text
|
||||
className={`alert-pagination__btn ${page <= 1 ? 'disabled' : ''}`}
|
||||
onClick={() => page > 1 && setPage(page - 1)}
|
||||
>
|
||||
上一页
|
||||
</Text>
|
||||
<Text className='alert-pagination__info'>
|
||||
{page} / {totalPages}
|
||||
</Text>
|
||||
<Text
|
||||
className={`alert-pagination__btn ${page >= totalPages ? 'disabled' : ''}`}
|
||||
onClick={() => page < totalPages && setPage(page + 1)}
|
||||
>
|
||||
下一页
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
@import '../../../../styles/variables.scss';
|
||||
@import '../../../../styles/mixins.scss';
|
||||
|
||||
.create-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding: 24px;
|
||||
padding-bottom: 200px;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&--textarea {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
flex-shrink: 0;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.form-value {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
|
||||
&.placeholder {
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
min-height: 120px;
|
||||
background: $bg;
|
||||
border-radius: $r-sm;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background: $pri;
|
||||
border-radius: $r-sm;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
|
||||
&:active {
|
||||
background: $pri-d;
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.submit-btn__text {
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $white;
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { View, Text, Input, Textarea, Picker, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import {
|
||||
getDialysisRecord, updateDialysisRecord, createDialysisRecord,
|
||||
} from '@/services/doctor/dialysis';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '../../../../hooks/useElderClass';
|
||||
import { useSafeTimeout } from '@/hooks/useSafeTimeout';
|
||||
import './index.scss';
|
||||
|
||||
const DIALYSIS_TYPES = ['HD', 'HDF', 'HF'];
|
||||
|
||||
interface FormState {
|
||||
patient_id: string;
|
||||
dialysis_date: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
dialysis_type: string;
|
||||
dialysis_duration: string;
|
||||
blood_flow_rate: string;
|
||||
dry_weight: string;
|
||||
pre_weight: string;
|
||||
post_weight: string;
|
||||
pre_bp_systolic: string;
|
||||
pre_bp_diastolic: string;
|
||||
post_bp_systolic: string;
|
||||
post_bp_diastolic: string;
|
||||
pre_heart_rate: string;
|
||||
post_heart_rate: string;
|
||||
ultrafiltration_volume: string;
|
||||
complication_notes: string;
|
||||
}
|
||||
|
||||
const initialForm: FormState = {
|
||||
patient_id: '',
|
||||
dialysis_date: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
dialysis_type: 'HD',
|
||||
dialysis_duration: '',
|
||||
blood_flow_rate: '',
|
||||
dry_weight: '',
|
||||
pre_weight: '',
|
||||
post_weight: '',
|
||||
pre_bp_systolic: '',
|
||||
pre_bp_diastolic: '',
|
||||
post_bp_systolic: '',
|
||||
post_bp_diastolic: '',
|
||||
pre_heart_rate: '',
|
||||
post_heart_rate: '',
|
||||
ultrafiltration_volume: '',
|
||||
complication_notes: '',
|
||||
};
|
||||
|
||||
export default function DialysisCreate() {
|
||||
const router = useRouter();
|
||||
const id = router.params.id || '';
|
||||
const version = router.params.version ? Number(router.params.version) : 0;
|
||||
const patientIdFromRoute = router.params.patientId || '';
|
||||
const isEdit = !!id;
|
||||
const modeClass = useElderClass();
|
||||
|
||||
const [form, setForm] = useState<FormState>({ ...initialForm, patient_id: patientIdFromRoute });
|
||||
const [loading, setLoading] = useState(isEdit);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { safeSetTimeout } = useSafeTimeout();
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && id) loadRecord();
|
||||
}, [id]);
|
||||
|
||||
const loadRecord = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await getDialysisRecord(id);
|
||||
setForm({
|
||||
patient_id: r.patient_id,
|
||||
dialysis_date: r.dialysis_date || '',
|
||||
start_time: r.start_time || '',
|
||||
end_time: r.end_time || '',
|
||||
dialysis_type: r.dialysis_type || 'HD',
|
||||
dialysis_duration: r.dialysis_duration != null ? String(r.dialysis_duration) : '',
|
||||
blood_flow_rate: r.blood_flow_rate != null ? String(r.blood_flow_rate) : '',
|
||||
dry_weight: r.dry_weight != null ? String(r.dry_weight) : '',
|
||||
pre_weight: r.pre_weight != null ? String(r.pre_weight) : '',
|
||||
post_weight: r.post_weight != null ? String(r.post_weight) : '',
|
||||
pre_bp_systolic: r.pre_bp_systolic != null ? String(r.pre_bp_systolic) : '',
|
||||
pre_bp_diastolic: r.pre_bp_diastolic != null ? String(r.pre_bp_diastolic) : '',
|
||||
post_bp_systolic: r.post_bp_systolic != null ? String(r.post_bp_systolic) : '',
|
||||
post_bp_diastolic: r.post_bp_diastolic != null ? String(r.post_bp_diastolic) : '',
|
||||
pre_heart_rate: r.pre_heart_rate != null ? String(r.pre_heart_rate) : '',
|
||||
post_heart_rate: r.post_heart_rate != null ? String(r.post_heart_rate) : '',
|
||||
ultrafiltration_volume: r.ultrafiltration_volume != null ? String(r.ultrafiltration_volume) : '',
|
||||
complication_notes: r.complication_notes || '',
|
||||
});
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateField = (key: keyof FormState, value: string) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.dialysis_date) {
|
||||
Taro.showToast({ title: '请选择透析日期', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (!form.patient_id) {
|
||||
Taro.showToast({ title: '缺少患者信息', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
const num = (v: string) => v ? Number(v) : undefined;
|
||||
const payload = {
|
||||
patient_id: form.patient_id,
|
||||
dialysis_date: form.dialysis_date,
|
||||
start_time: form.start_time || undefined,
|
||||
end_time: form.end_time || undefined,
|
||||
dialysis_type: form.dialysis_type,
|
||||
dialysis_duration: num(form.dialysis_duration),
|
||||
blood_flow_rate: num(form.blood_flow_rate),
|
||||
dry_weight: num(form.dry_weight),
|
||||
pre_weight: num(form.pre_weight),
|
||||
post_weight: num(form.post_weight),
|
||||
pre_bp_systolic: num(form.pre_bp_systolic),
|
||||
pre_bp_diastolic: num(form.pre_bp_diastolic),
|
||||
post_bp_systolic: num(form.post_bp_systolic),
|
||||
post_bp_diastolic: num(form.post_bp_diastolic),
|
||||
pre_heart_rate: num(form.pre_heart_rate),
|
||||
post_heart_rate: num(form.post_heart_rate),
|
||||
ultrafiltration_volume: num(form.ultrafiltration_volume),
|
||||
complication_notes: form.complication_notes || undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
const { patient_id, ...updateData } = payload;
|
||||
await updateDialysisRecord(id, updateData, version);
|
||||
Taro.showToast({ title: '更新成功', icon: 'success' });
|
||||
} else {
|
||||
await createDialysisRecord(payload);
|
||||
Taro.showToast({ title: '创建成功', icon: 'success' });
|
||||
}
|
||||
safeSetTimeout(() => Taro.navigateBack(), 1000);
|
||||
} catch {
|
||||
Taro.showToast({ title: isEdit ? '更新失败' : '创建失败', icon: 'none' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Loading />;
|
||||
|
||||
const InputField = ({ label, field, placeholder, type = 'digit' }: {
|
||||
label: string; field: keyof FormState; placeholder: string; type?: string;
|
||||
}) => (
|
||||
<View className='form-row'>
|
||||
<Text className='form-label'>{label}</Text>
|
||||
<Input
|
||||
className='form-input'
|
||||
type={type as any}
|
||||
placeholder={placeholder}
|
||||
value={form[field]}
|
||||
onInput={(e) => updateField(field, e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className={`create-page ${modeClass}`}>
|
||||
<View className='section'>
|
||||
<Text className='section-title'>基本信息</Text>
|
||||
<View className='form-row'>
|
||||
<Text className='form-label'>透析日期</Text>
|
||||
<Picker mode='date' value={form.dialysis_date} onChange={(e) => updateField('dialysis_date', e.detail.value)}>
|
||||
<Text className={`form-value ${!form.dialysis_date ? 'placeholder' : ''}`}>
|
||||
{form.dialysis_date || '请选择日期'}
|
||||
</Text>
|
||||
</Picker>
|
||||
</View>
|
||||
<View className='form-row'>
|
||||
<Text className='form-label'>开始时间</Text>
|
||||
<Picker mode='time' value={form.start_time} onChange={(e) => updateField('start_time', e.detail.value)}>
|
||||
<Text className={`form-value ${!form.start_time ? 'placeholder' : ''}`}>
|
||||
{form.start_time || '请选择时间'}
|
||||
</Text>
|
||||
</Picker>
|
||||
</View>
|
||||
<View className='form-row'>
|
||||
<Text className='form-label'>结束时间</Text>
|
||||
<Picker mode='time' value={form.end_time} onChange={(e) => updateField('end_time', e.detail.value)}>
|
||||
<Text className={`form-value ${!form.end_time ? 'placeholder' : ''}`}>
|
||||
{form.end_time || '请选择时间'}
|
||||
</Text>
|
||||
</Picker>
|
||||
</View>
|
||||
<View className='form-row'>
|
||||
<Text className='form-label'>透析类型</Text>
|
||||
<Picker mode='selector' range={DIALYSIS_TYPES} value={DIALYSIS_TYPES.indexOf(form.dialysis_type)} onChange={(e) => updateField('dialysis_type', DIALYSIS_TYPES[Number(e.detail.value)])}>
|
||||
<Text className='form-value'>{form.dialysis_type}</Text>
|
||||
</Picker>
|
||||
</View>
|
||||
<InputField label='透析时长' field='dialysis_duration' placeholder='分钟' type='number' />
|
||||
<InputField label='血流速' field='blood_flow_rate' placeholder='ml/min' type='number' />
|
||||
</View>
|
||||
|
||||
<View className='section'>
|
||||
<Text className='section-title'>体重</Text>
|
||||
<InputField label='干体重' field='dry_weight' placeholder='kg' />
|
||||
<InputField label='透前体重' field='pre_weight' placeholder='kg' />
|
||||
<InputField label='透后体重' field='post_weight' placeholder='kg' />
|
||||
</View>
|
||||
|
||||
<View className='section'>
|
||||
<Text className='section-title'>血压与心率</Text>
|
||||
<InputField label='透前收缩压' field='pre_bp_systolic' placeholder='mmHg' type='number' />
|
||||
<InputField label='透前舒张压' field='pre_bp_diastolic' placeholder='mmHg' type='number' />
|
||||
<InputField label='透后收缩压' field='post_bp_systolic' placeholder='mmHg' type='number' />
|
||||
<InputField label='透后舒张压' field='post_bp_diastolic' placeholder='mmHg' type='number' />
|
||||
<InputField label='透前心率' field='pre_heart_rate' placeholder='bpm' type='number' />
|
||||
<InputField label='透后心率' field='post_heart_rate' placeholder='bpm' type='number' />
|
||||
</View>
|
||||
|
||||
<View className='section'>
|
||||
<Text className='section-title'>超滤与备注</Text>
|
||||
<InputField label='超滤量' field='ultrafiltration_volume' placeholder='ml' type='number' />
|
||||
<View className='form-row form-row--textarea'>
|
||||
<Text className='form-label'>并发症备注</Text>
|
||||
<Textarea
|
||||
className='form-textarea'
|
||||
placeholder='请输入...'
|
||||
value={form.complication_notes}
|
||||
onInput={(e) => updateField('complication_notes', e.detail.value)}
|
||||
maxlength={500}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className={`submit-btn ${submitting ? 'submit-btn--disabled' : ''}`} onClick={handleSubmit}>
|
||||
<Text className='submit-btn__text'>{submitting ? '提交中...' : isEdit ? '更新记录' : '创建记录'}</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
@import '../../../../styles/variables.scss';
|
||||
@import '../../../../styles/mixins.scss';
|
||||
|
||||
.dialysis-detail {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding: 24px;
|
||||
padding-bottom: 200px;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.record-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.record-header__title {
|
||||
font-size: var(--tk-font-num-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.record-header__status {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: $r-xs;
|
||||
font-size: var(--tk-font-body);
|
||||
background: $bd-l;
|
||||
color: $tx3;
|
||||
|
||||
&--completed {
|
||||
background: $pri-l;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
&--reviewed {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
}
|
||||
}
|
||||
|
||||
.record-sub {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.review-info {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
text-align: right;
|
||||
flex: 1;
|
||||
margin-left: 24px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
text-align: center;
|
||||
padding: 120px 0;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
border-radius: $r-sm;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
|
||||
&--primary {
|
||||
background: $pri;
|
||||
|
||||
.action-btn__text {
|
||||
color: $white;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: $pri-d;
|
||||
}
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background: $card;
|
||||
border: 1px solid $bd;
|
||||
|
||||
.action-btn__text {
|
||||
color: $pri;
|
||||
}
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background: $card;
|
||||
border: 1px solid $dan;
|
||||
|
||||
.action-btn__text {
|
||||
color: $dan;
|
||||
}
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn__text {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import {
|
||||
getDialysisRecord, reviewDialysisRecord,
|
||||
updateDialysisRecord, deleteDialysisRecord,
|
||||
type DialysisRecord,
|
||||
} from '@/services/doctor/dialysis';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '../../../../hooks/useElderClass';
|
||||
import { useSafeTimeout } from '@/hooks/useSafeTimeout';
|
||||
import './index.scss';
|
||||
|
||||
export default function DialysisDetail() {
|
||||
const router = useRouter();
|
||||
const id = router.params.id || '';
|
||||
const modeClass = useElderClass();
|
||||
const [record, setRecord] = useState<DialysisRecord | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { safeSetTimeout } = useSafeTimeout();
|
||||
|
||||
const loadRecord = useCallback(async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await getDialysisRecord(id);
|
||||
setRecord(r);
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
usePageData(loadRecord, { throttleMs: 60000, enablePullDown: false, enabled: !!id });
|
||||
|
||||
const handleReview = async () => {
|
||||
if (!record) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const updated = await reviewDialysisRecord(id, record.version);
|
||||
setRecord(updated);
|
||||
Taro.showToast({ title: '审核完成', icon: 'success' });
|
||||
} catch {
|
||||
Taro.showToast({ title: '审核失败', icon: 'none' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = async () => {
|
||||
if (!record) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const updated = await updateDialysisRecord(id, { status: 'completed' }, record.version);
|
||||
setRecord(updated);
|
||||
Taro.showToast({ title: '已标记完成', icon: 'success' });
|
||||
} catch {
|
||||
Taro.showToast({ title: '操作失败', icon: 'none' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!record) return;
|
||||
const { confirm } = await Taro.showModal({
|
||||
title: '确认删除',
|
||||
content: '删除后不可恢复,确定要删除这条记录吗?',
|
||||
});
|
||||
if (!confirm) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await deleteDialysisRecord(id, record.version);
|
||||
Taro.showToast({ title: '已删除', icon: 'success' });
|
||||
safeSetTimeout(() => Taro.navigateBack(), 1000);
|
||||
} catch {
|
||||
Taro.showToast({ title: '删除失败', icon: 'none' });
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const Row = ({ label, value, unit }: { label: string; value?: string | number | null; unit?: string }) => {
|
||||
if (value == null) return null;
|
||||
return (
|
||||
<View className='detail-row'>
|
||||
<Text className='detail-label'>{label}</Text>
|
||||
<Text className='detail-value'>{value}{unit || ''}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) return <Loading />;
|
||||
if (!record) return <View className={`error-text ${modeClass}`}><Text>记录加载失败</Text></View>;
|
||||
|
||||
const canComplete = record.status === 'draft';
|
||||
const canReview = record.status === 'completed';
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className={`dialysis-detail ${modeClass}`}>
|
||||
{/* 状态头部 */}
|
||||
<View className='section'>
|
||||
<View className='record-header'>
|
||||
<Text className='record-header__title'>{record.dialysis_date}</Text>
|
||||
<Text className={`record-header__status record-header__status--${record.status}`}>
|
||||
{record.status === 'draft' ? '草稿' : record.status === 'completed' ? '已完成' : '已审核'}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className='record-sub'>
|
||||
{(record.dialysis_type === 'HD' ? '血液透析' : record.dialysis_type === 'HDF' ? '血液透析滤过' : record.dialysis_type === 'HF' ? '血液滤过' : record.dialysis_type)}
|
||||
</Text>
|
||||
{record.reviewed_at && <Text className='review-info'>审核于 {record.reviewed_at}</Text>}
|
||||
</View>
|
||||
|
||||
{/* 基本信息 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>基本信息</Text>
|
||||
<Row label='透析日期' value={record.dialysis_date} />
|
||||
<Row label='开始时间' value={record.start_time} />
|
||||
<Row label='结束时间' value={record.end_time} />
|
||||
<Row label='透析时长' value={record.dialysis_duration} unit=' 分钟' />
|
||||
<Row label='血流速' value={record.blood_flow_rate} unit=' ml/min' />
|
||||
<Row label='超滤量' value={record.ultrafiltration_volume} unit=' ml' />
|
||||
</View>
|
||||
|
||||
{/* 体重与血压 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>体重与血压</Text>
|
||||
<Row label='干体重' value={record.dry_weight} unit=' kg' />
|
||||
<Row label='透前体重' value={record.pre_weight} unit=' kg' />
|
||||
<Row label='透后体重' value={record.post_weight} unit=' kg' />
|
||||
{record.pre_bp_systolic != null && record.pre_bp_diastolic != null && (
|
||||
<Row label='透前血压' value={`${record.pre_bp_systolic}/${record.pre_bp_diastolic}`} unit=' mmHg' />
|
||||
)}
|
||||
{record.post_bp_systolic != null && record.post_bp_diastolic != null && (
|
||||
<Row label='透后血压' value={`${record.post_bp_systolic}/${record.post_bp_diastolic}`} unit=' mmHg' />
|
||||
)}
|
||||
<Row label='透前心率' value={record.pre_heart_rate} unit=' bpm' />
|
||||
<Row label='透后心率' value={record.post_heart_rate} unit=' bpm' />
|
||||
</View>
|
||||
|
||||
{/* 症状与并发症 */}
|
||||
{(record.symptoms || record.complication_notes) && (
|
||||
<View className='section'>
|
||||
<Text className='section-title'>症状与并发症</Text>
|
||||
{record.symptoms && (
|
||||
<Row label='症状' value={JSON.stringify(record.symptoms)} />
|
||||
)}
|
||||
<Row label='并发症备注' value={record.complication_notes} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<View className='actions'>
|
||||
{canComplete && (
|
||||
<View className={`action-btn action-btn--primary ${submitting ? 'action-btn--disabled' : ''}`} onClick={handleComplete}>
|
||||
<Text className='action-btn__text'>{submitting ? '处理中...' : '标记完成'}</Text>
|
||||
</View>
|
||||
)}
|
||||
{canReview && (
|
||||
<View className={`action-btn action-btn--primary ${submitting ? 'action-btn--disabled' : ''}`} onClick={handleReview}>
|
||||
<Text className='action-btn__text'>{submitting ? '审核中...' : '确认审核'}</Text>
|
||||
</View>
|
||||
)}
|
||||
{record.status === 'draft' && (
|
||||
<View className='action-btn action-btn--secondary' onClick={() => Taro.navigateTo({
|
||||
url: `/pages/pkg-doctor-clinical/dialysis/create/index?id=${id}&version=${record.version}`,
|
||||
})}>
|
||||
<Text className='action-btn__text'>编辑</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className='action-btn action-btn--danger' onClick={handleDelete}>
|
||||
<Text className='action-btn__text'>删除</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
.dialysis-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
padding: 16px 24px;
|
||||
background: $card;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
background: $bg;
|
||||
border-radius: $r-sm;
|
||||
padding: 16px 20px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
padding: 0 24px;
|
||||
background: $card;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
position: relative;
|
||||
|
||||
&--active {
|
||||
.tab-text {
|
||||
color: $pri;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 25%;
|
||||
right: 25%;
|
||||
height: 4px;
|
||||
background: $pri;
|
||||
border-radius: $r-xs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-text {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.record-list {
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.record-count {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
padding: 8px 0 16px;
|
||||
}
|
||||
|
||||
.record-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&:active {
|
||||
box-shadow: $shadow-md;
|
||||
}
|
||||
}
|
||||
|
||||
.record-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.type-tag {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: $r-xs;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 600;
|
||||
background: $pri-l;
|
||||
color: $pri-d;
|
||||
|
||||
&--hdf {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
&--hf {
|
||||
background: $wrn-l;
|
||||
color: $wrn;
|
||||
}
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: $r-xs;
|
||||
font-size: var(--tk-font-body);
|
||||
background: $bd-l;
|
||||
color: $tx3;
|
||||
|
||||
&--completed {
|
||||
background: $pri-l;
|
||||
color: $pri;
|
||||
}
|
||||
|
||||
&--reviewed {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
}
|
||||
}
|
||||
|
||||
.record-card__body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.record-card__date {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.record-card__meta {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
padding: 12px 24px;
|
||||
background: $card;
|
||||
border-radius: $r-sm;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $pri;
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 32px;
|
||||
bottom: 120px;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: $r-pill;
|
||||
background: $pri;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: $shadow-md;
|
||||
z-index: 10;
|
||||
|
||||
&:active {
|
||||
background: $pri-d;
|
||||
}
|
||||
}
|
||||
|
||||
.fab-text {
|
||||
font-size: var(--tk-font-hero);
|
||||
color: $white;
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { View, Text, Input, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { listDialysisRecords, type DialysisRecord } from '@/services/doctor/dialysis';
|
||||
import { listPatients } from '@/services/doctor/patient';
|
||||
import Loading from '@/components/Loading';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import { safeNavigateTo } from '@/utils/navigate';
|
||||
import './index.scss';
|
||||
|
||||
const TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'draft', label: '草稿' },
|
||||
{ key: 'completed', label: '已完成' },
|
||||
{ key: 'reviewed', label: '已审核' },
|
||||
];
|
||||
|
||||
const TYPE_MAP: Record<string, string> = { HD: 'HD', HDF: 'HDF', HF: 'HF' };
|
||||
|
||||
export default function DialysisList() {
|
||||
const router = useRouter();
|
||||
const patientId = router.params.patientId || '';
|
||||
const modeClass = useElderClass();
|
||||
const [searchPatient, setSearchPatient] = useState('');
|
||||
const [currentPatientId, setCurrentPatientId] = useState(patientId);
|
||||
const [activeTab, setActiveTab] = useState('');
|
||||
const [records, setRecords] = useState<DialysisRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
const loadRecords = useCallback(async (p: number) => {
|
||||
if (!currentPatientId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: { page: number; page_size: number; status?: string } = { page: p, page_size: 20 };
|
||||
if (activeTab) params.status = activeTab;
|
||||
const res = await listDialysisRecords(currentPatientId, params);
|
||||
setRecords(res.data || []);
|
||||
setTotal(res.total || 0);
|
||||
setPage(p);
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentPatientId, activeTab]);
|
||||
|
||||
usePageData(
|
||||
useCallback(() => loadRecords(1), [loadRecords]),
|
||||
{ enabled: !!currentPatientId },
|
||||
);
|
||||
|
||||
// tab/patientId 变化时重新加载(跳过首次 mount,由 usePageData 的 useDidShow 处理)
|
||||
useEffect(() => {
|
||||
if (mountedRef.current && currentPatientId) {
|
||||
loadRecords(1);
|
||||
}
|
||||
mountedRef.current = true;
|
||||
}, [currentPatientId, activeTab, loadRecords]);
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchPatient.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listPatients({ search: searchPatient.trim(), page: 1, page_size: 1 });
|
||||
if (res.data && res.data.length > 0) {
|
||||
setCurrentPatientId(res.data[0].id);
|
||||
Taro.setNavigationBarTitle({ title: res.data[0].name + '的透析记录' });
|
||||
} else {
|
||||
Taro.showToast({ title: '未找到患者', icon: 'none' });
|
||||
}
|
||||
} catch {
|
||||
Taro.showToast({ title: '搜索失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTab = (key: string) => {
|
||||
setActiveTab(key);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
// 服务端已按 activeTab 过滤,无需客户端二次筛选
|
||||
|
||||
if (loading && records.length === 0) return <Loading />;
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className={`dialysis-page ${modeClass}`}>
|
||||
{!patientId && (
|
||||
<View className='search-bar'>
|
||||
<Input
|
||||
className='search-input'
|
||||
placeholder='搜索患者姓名'
|
||||
value={searchPatient}
|
||||
onInput={(e) => setSearchPatient(e.detail.value)}
|
||||
confirmType='search'
|
||||
onConfirm={handleSearch}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className='tabs'>
|
||||
{TABS.map((t) => (
|
||||
<View
|
||||
key={t.key}
|
||||
className={`tab ${activeTab === t.key ? 'tab--active' : ''}`}
|
||||
onClick={() => handleTab(t.key)}
|
||||
>
|
||||
<Text className='tab-text'>{t.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{!currentPatientId ? (
|
||||
<EmptyState text='请搜索并选择患者' />
|
||||
) : records.length === 0 ? (
|
||||
<EmptyState text='暂无透析记录' />
|
||||
) : (
|
||||
<View className='record-list'>
|
||||
<View className='record-count'><Text>共 {total} 条记录</Text></View>
|
||||
{records.map((r) => (
|
||||
<View
|
||||
key={r.id}
|
||||
className='record-card'
|
||||
onClick={() => safeNavigateTo(`/pages/pkg-doctor-clinical/dialysis/detail/index?id=${r.id}`)}
|
||||
>
|
||||
<View className='record-card__header'>
|
||||
<Text className={`type-tag type-tag--${(r.dialysis_type || 'hd').toLowerCase()}`}>
|
||||
{TYPE_MAP[r.dialysis_type] || r.dialysis_type}
|
||||
</Text>
|
||||
<Text className={`status-tag status-tag--${r.status}`}>
|
||||
{r.status === 'draft' ? '草稿' : r.status === 'completed' ? '已完成' : '已审核'}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='record-card__body'>
|
||||
<Text className='record-card__date'>{r.dialysis_date}</Text>
|
||||
{r.dialysis_duration != null && (
|
||||
<Text className='record-card__meta'>时长 {r.dialysis_duration}分钟</Text>
|
||||
)}
|
||||
{r.ultrafiltration_volume != null && (
|
||||
<Text className='record-card__meta'>超滤 {r.ultrafiltration_volume}ml</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
{total > 20 && (
|
||||
<View className='pagination'>
|
||||
<View
|
||||
className={`page-btn ${page <= 1 ? 'page-btn--disabled' : ''}`}
|
||||
onClick={() => page > 1 && loadRecords(page - 1)}
|
||||
>
|
||||
<Text>上一页</Text>
|
||||
</View>
|
||||
<Text className='page-info'>{page} / {Math.ceil(total / 20)}</Text>
|
||||
<View
|
||||
className={`page-btn ${page * 20 >= total ? 'page-btn--disabled' : ''}`}
|
||||
onClick={() => page * 20 < total && loadRecords(page + 1)}
|
||||
>
|
||||
<Text>下一页</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View
|
||||
className='fab'
|
||||
onClick={() => {
|
||||
if (!currentPatientId) {
|
||||
Taro.showToast({ title: '请先选择患者', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
safeNavigateTo(`/pages/pkg-doctor-clinical/dialysis/create/index?patientId=${currentPatientId}`);
|
||||
}}
|
||||
>
|
||||
<Text className='fab-text'>+</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
@import '../../../../styles/variables.scss';
|
||||
@import '../../../../styles/mixins.scss';
|
||||
|
||||
.create-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding: 24px;
|
||||
padding-bottom: 200px;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
flex-shrink: 0;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.form-value {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
|
||||
&.placeholder {
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
min-height: 120px;
|
||||
background: $bg;
|
||||
border-radius: $r-sm;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background: $pri;
|
||||
border-radius: $r-sm;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
|
||||
&:active {
|
||||
background: $pri-d;
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.submit-btn__text {
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $white;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useState } from 'react';
|
||||
import { View, Text, Input, Textarea, Picker, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { createDialysisPrescription } from '@/services/doctor/dialysis';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '../../../../hooks/useElderClass';
|
||||
import { useSafeTimeout } from '@/hooks/useSafeTimeout';
|
||||
import './index.scss';
|
||||
|
||||
interface FormState {
|
||||
dialyzer_model: string;
|
||||
membrane_area: string;
|
||||
dialysate_potassium: string;
|
||||
dialysate_calcium: string;
|
||||
dialysate_bicarbonate: string;
|
||||
anticoagulation_type: string;
|
||||
anticoagulation_dose: string;
|
||||
target_ultrafiltration_ml: string;
|
||||
target_dry_weight: string;
|
||||
blood_flow_rate: string;
|
||||
dialysate_flow_rate: string;
|
||||
frequency_per_week: string;
|
||||
duration_minutes: string;
|
||||
vascular_access_type: string;
|
||||
vascular_access_location: string;
|
||||
effective_from: string;
|
||||
effective_to: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
const initialForm: FormState = {
|
||||
dialyzer_model: '',
|
||||
membrane_area: '',
|
||||
dialysate_potassium: '',
|
||||
dialysate_calcium: '',
|
||||
dialysate_bicarbonate: '',
|
||||
anticoagulation_type: '',
|
||||
anticoagulation_dose: '',
|
||||
target_ultrafiltration_ml: '',
|
||||
target_dry_weight: '',
|
||||
blood_flow_rate: '',
|
||||
dialysate_flow_rate: '',
|
||||
frequency_per_week: '',
|
||||
duration_minutes: '',
|
||||
vascular_access_type: '',
|
||||
vascular_access_location: '',
|
||||
effective_from: '',
|
||||
effective_to: '',
|
||||
notes: '',
|
||||
};
|
||||
|
||||
export default function PrescriptionCreate() {
|
||||
const router = useRouter();
|
||||
const patientId = router.params.patientId || '';
|
||||
const modeClass = useElderClass();
|
||||
const [form, setForm] = useState<FormState>(initialForm);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { safeSetTimeout } = useSafeTimeout();
|
||||
|
||||
const updateField = (key: keyof FormState, value: string) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!patientId) {
|
||||
Taro.showToast({ title: '缺少患者信息', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
const num = (v: string) => v ? Number(v) : undefined;
|
||||
const payload = {
|
||||
patient_id: patientId,
|
||||
dialyzer_model: form.dialyzer_model || undefined,
|
||||
membrane_area: num(form.membrane_area),
|
||||
dialysate_potassium: num(form.dialysate_potassium),
|
||||
dialysate_calcium: num(form.dialysate_calcium),
|
||||
dialysate_bicarbonate: num(form.dialysate_bicarbonate),
|
||||
anticoagulation_type: form.anticoagulation_type || undefined,
|
||||
anticoagulation_dose: form.anticoagulation_dose || undefined,
|
||||
target_ultrafiltration_ml: num(form.target_ultrafiltration_ml),
|
||||
target_dry_weight: num(form.target_dry_weight),
|
||||
blood_flow_rate: num(form.blood_flow_rate),
|
||||
dialysate_flow_rate: num(form.dialysate_flow_rate),
|
||||
frequency_per_week: num(form.frequency_per_week),
|
||||
duration_minutes: num(form.duration_minutes),
|
||||
vascular_access_type: form.vascular_access_type || undefined,
|
||||
vascular_access_location: form.vascular_access_location || undefined,
|
||||
effective_from: form.effective_from || undefined,
|
||||
effective_to: form.effective_to || undefined,
|
||||
notes: form.notes || undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
await createDialysisPrescription(payload);
|
||||
Taro.showToast({ title: '创建成功', icon: 'success' });
|
||||
safeSetTimeout(() => Taro.navigateBack(), 1000);
|
||||
} catch {
|
||||
Taro.showToast({ title: '创建失败', icon: 'none' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const InputField = ({ label, field, placeholder, type = 'digit' }: {
|
||||
label: string; field: keyof FormState; placeholder: string; type?: string;
|
||||
}) => (
|
||||
<View className='form-row'>
|
||||
<Text className='form-label'>{label}</Text>
|
||||
<Input
|
||||
className='form-input'
|
||||
type={type as any}
|
||||
placeholder={placeholder}
|
||||
value={form[field]}
|
||||
onInput={(e) => updateField(field, e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className={`create-page ${modeClass}`}>
|
||||
{/* 透析器 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>透析器</Text>
|
||||
<InputField label='透析器型号' field='dialyzer_model' placeholder='请输入型号' type='text' />
|
||||
<InputField label='膜面积' field='membrane_area' placeholder='m²' />
|
||||
</View>
|
||||
|
||||
{/* 透析液 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>透析液配比</Text>
|
||||
<InputField label='钾浓度' field='dialysate_potassium' placeholder='mmol/L' />
|
||||
<InputField label='钙浓度' field='dialysate_calcium' placeholder='mmol/L' />
|
||||
<InputField label='碳酸氢盐' field='dialysate_bicarbonate' placeholder='mmol/L' />
|
||||
</View>
|
||||
|
||||
{/* 抗凝 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>抗凝方案</Text>
|
||||
<InputField label='抗凝类型' field='anticoagulation_type' placeholder='请输入' type='text' />
|
||||
<InputField label='抗凝剂量' field='anticoagulation_dose' placeholder='请输入' type='text' />
|
||||
</View>
|
||||
|
||||
{/* 参数 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>参数设置</Text>
|
||||
<InputField label='目标超滤量' field='target_ultrafiltration_ml' placeholder='ml' type='number' />
|
||||
<InputField label='目标干体重' field='target_dry_weight' placeholder='kg' />
|
||||
<InputField label='血流速' field='blood_flow_rate' placeholder='ml/min' type='number' />
|
||||
<InputField label='透析液流量' field='dialysate_flow_rate' placeholder='ml/min' type='number' />
|
||||
<InputField label='每周频次' field='frequency_per_week' placeholder='次/周' type='number' />
|
||||
<InputField label='每次时长' field='duration_minutes' placeholder='分钟' type='number' />
|
||||
</View>
|
||||
|
||||
{/* 血管通路 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>血管通路</Text>
|
||||
<InputField label='通路类型' field='vascular_access_type' placeholder='请输入' type='text' />
|
||||
<InputField label='通路位置' field='vascular_access_location' placeholder='请输入' type='text' />
|
||||
</View>
|
||||
|
||||
{/* 生效日期 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>生效日期</Text>
|
||||
<View className='form-row'>
|
||||
<Text className='form-label'>生效日期</Text>
|
||||
<Picker mode='date' value={form.effective_from} onChange={(e) => updateField('effective_from', e.detail.value)}>
|
||||
<Text className={`form-value ${!form.effective_from ? 'placeholder' : ''}`}>
|
||||
{form.effective_from || '请选择'}
|
||||
</Text>
|
||||
</Picker>
|
||||
</View>
|
||||
<View className='form-row'>
|
||||
<Text className='form-label'>失效日期</Text>
|
||||
<Picker mode='date' value={form.effective_to} onChange={(e) => updateField('effective_to', e.detail.value)}>
|
||||
<Text className={`form-value ${!form.effective_to ? 'placeholder' : ''}`}>
|
||||
{form.effective_to || '请选择'}
|
||||
</Text>
|
||||
</Picker>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 备注 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>备注</Text>
|
||||
<Textarea
|
||||
className='form-textarea'
|
||||
placeholder='请输入备注...'
|
||||
value={form.notes}
|
||||
onInput={(e) => updateField('notes', e.detail.value)}
|
||||
maxlength={500}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className={`submit-btn ${submitting ? 'submit-btn--disabled' : ''}`} onClick={handleSubmit}>
|
||||
<Text className='submit-btn__text'>{submitting ? '提交中...' : '创建处方'}</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
@import '../../../../styles/variables.scss';
|
||||
@import '../../../../styles/mixins.scss';
|
||||
|
||||
.prescription-detail {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding: 24px;
|
||||
padding-bottom: 200px;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.rx-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.rx-header__title {
|
||||
font-size: var(--tk-font-num-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.rx-header__status {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: $r-xs;
|
||||
font-size: var(--tk-font-body);
|
||||
background: $bd-l;
|
||||
color: $tx3;
|
||||
|
||||
&--active {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
}
|
||||
}
|
||||
|
||||
.rx-sub {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
text-align: right;
|
||||
flex: 1;
|
||||
margin-left: 24px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.notes-text {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
text-align: center;
|
||||
padding: 120px 0;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
border-radius: $r-sm;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
|
||||
&--secondary {
|
||||
background: $card;
|
||||
border: 1px solid $bd;
|
||||
|
||||
.action-btn__text {
|
||||
color: $pri;
|
||||
}
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background: $card;
|
||||
border: 1px solid $dan;
|
||||
|
||||
.action-btn__text {
|
||||
color: $dan;
|
||||
}
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn__text {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import {
|
||||
getDialysisPrescription, updateDialysisPrescription, deleteDialysisPrescription,
|
||||
type DialysisPrescription,
|
||||
} from '@/services/doctor/dialysis';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '../../../../hooks/useElderClass';
|
||||
import { useSafeTimeout } from '@/hooks/useSafeTimeout';
|
||||
import './index.scss';
|
||||
|
||||
export default function PrescriptionDetail() {
|
||||
const router = useRouter();
|
||||
const id = router.params.id || '';
|
||||
const modeClass = useElderClass();
|
||||
const [rx, setRx] = useState<DialysisPrescription | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { safeSetTimeout } = useSafeTimeout();
|
||||
|
||||
const loadRx = useCallback(async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getDialysisPrescription(id);
|
||||
setRx(data);
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
usePageData(loadRx, { throttleMs: 60000, enablePullDown: false, enabled: !!id });
|
||||
|
||||
const handleDeactivate = async () => {
|
||||
if (!rx) return;
|
||||
const { confirm } = await Taro.showModal({
|
||||
title: '确认停用',
|
||||
content: '停用后该处方将不再生效,确定停用吗?',
|
||||
});
|
||||
if (!confirm) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const updated = await updateDialysisPrescription(id, { status: 'inactive' }, rx.version);
|
||||
setRx(updated);
|
||||
Taro.showToast({ title: '已停用', icon: 'success' });
|
||||
} catch {
|
||||
Taro.showToast({ title: '操作失败', icon: 'none' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!rx) return;
|
||||
const { confirm } = await Taro.showModal({
|
||||
title: '确认删除',
|
||||
content: '删除后不可恢复,确定要删除这条处方吗?',
|
||||
});
|
||||
if (!confirm) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await deleteDialysisPrescription(id, rx.version);
|
||||
Taro.showToast({ title: '已删除', icon: 'success' });
|
||||
safeSetTimeout(() => Taro.navigateBack(), 1000);
|
||||
} catch {
|
||||
Taro.showToast({ title: '删除失败', icon: 'none' });
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const Row = ({ label, value, unit }: { label: string; value?: string | number | null; unit?: string }) => {
|
||||
if (value == null) return null;
|
||||
return (
|
||||
<View className='detail-row'>
|
||||
<Text className='detail-label'>{label}</Text>
|
||||
<Text className='detail-value'>{value}{unit || ''}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) return <Loading />;
|
||||
if (!rx) return <View className={`error-text ${modeClass}`}><Text>处方加载失败</Text></View>;
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className={`prescription-detail ${modeClass}`}>
|
||||
{/* 状态头部 */}
|
||||
<View className='section'>
|
||||
<View className='rx-header'>
|
||||
<Text className='rx-header__title'>{rx.dialyzer_model || '透析处方'}</Text>
|
||||
<Text className={`rx-header__status rx-header__status--${rx.status}`}>
|
||||
{rx.status === 'active' ? '生效中' : rx.status === 'inactive' ? '已停用' : rx.status}
|
||||
</Text>
|
||||
</View>
|
||||
{(rx.effective_from || rx.effective_to) && (
|
||||
<Text className='rx-sub'>{rx.effective_from || '...'} ~ {rx.effective_to || '...'}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 基本参数 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>基本参数</Text>
|
||||
<Row label='透析器型号' value={rx.dialyzer_model} />
|
||||
<Row label='膜面积' value={rx.membrane_area != null ? `${rx.membrane_area}` : null} unit=' m²' />
|
||||
<Row label='血流速' value={rx.blood_flow_rate} unit=' ml/min' />
|
||||
<Row label='透析液流量' value={rx.dialysate_flow_rate} unit=' ml/min' />
|
||||
<Row label='频率' value={rx.frequency_per_week != null ? `${rx.frequency_per_week} 次/周` : null} />
|
||||
<Row label='每次时长' value={rx.duration_minutes} unit=' 分钟' />
|
||||
</View>
|
||||
|
||||
{/* 透析液配比 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>透析液配比</Text>
|
||||
<Row label='钾浓度' value={rx.dialysate_potassium} unit=' mmol/L' />
|
||||
<Row label='钙浓度' value={rx.dialysate_calcium} unit=' mmol/L' />
|
||||
<Row label='碳酸氢盐' value={rx.dialysate_bicarbonate} unit=' mmol/L' />
|
||||
</View>
|
||||
|
||||
{/* 抗凝方案 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>抗凝方案</Text>
|
||||
<Row label='抗凝类型' value={rx.anticoagulation_type} />
|
||||
<Row label='抗凝剂量' value={rx.anticoagulation_dose} />
|
||||
</View>
|
||||
|
||||
{/* 血管通路 */}
|
||||
{(rx.vascular_access_type || rx.vascular_access_location) && (
|
||||
<View className='section'>
|
||||
<Text className='section-title'>血管通路</Text>
|
||||
<Row label='通路类型' value={rx.vascular_access_type} />
|
||||
<Row label='通路位置' value={rx.vascular_access_location} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 超滤目标 */}
|
||||
{(rx.target_ultrafiltration_ml != null || rx.target_dry_weight != null) && (
|
||||
<View className='section'>
|
||||
<Text className='section-title'>超滤目标</Text>
|
||||
<Row label='目标超滤量' value={rx.target_ultrafiltration_ml} unit=' ml' />
|
||||
<Row label='目标干体重' value={rx.target_dry_weight} unit=' kg' />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 备注 */}
|
||||
{rx.notes && (
|
||||
<View className='section'>
|
||||
<Text className='section-title'>备注</Text>
|
||||
<Text className='notes-text'>{rx.notes}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<View className='actions'>
|
||||
{rx.status === 'active' && (
|
||||
<View className={`action-btn action-btn--secondary ${submitting ? 'action-btn--disabled' : ''}`} onClick={handleDeactivate}>
|
||||
<Text className='action-btn__text'>停用处方</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className='action-btn action-btn--danger' onClick={handleDelete}>
|
||||
<Text className='action-btn__text'>删除</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
.prescription-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
padding: 16px 24px;
|
||||
background: $card;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
background: $bg;
|
||||
border-radius: $r-sm;
|
||||
padding: 16px 20px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
padding: 0 24px;
|
||||
background: $card;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
position: relative;
|
||||
|
||||
&--active {
|
||||
.tab-text {
|
||||
color: $pri;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 25%;
|
||||
right: 25%;
|
||||
height: 4px;
|
||||
background: $pri;
|
||||
border-radius: $r-xs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-text {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.prescription-list {
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.prescription-count {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
padding: 8px 0 16px;
|
||||
}
|
||||
|
||||
.prescription-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&:active {
|
||||
box-shadow: $shadow-md;
|
||||
}
|
||||
}
|
||||
|
||||
.prescription-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.prescription-card__model {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: $r-xs;
|
||||
font-size: var(--tk-font-body);
|
||||
background: $bd-l;
|
||||
color: $tx3;
|
||||
|
||||
&--active {
|
||||
background: $acc-l;
|
||||
color: $acc;
|
||||
}
|
||||
}
|
||||
|
||||
.prescription-card__body {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.prescription-card__meta {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.prescription-card__date {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
display: block;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
padding: 12px 24px;
|
||||
background: $card;
|
||||
border-radius: $r-sm;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $pri;
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 32px;
|
||||
bottom: 120px;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: $r-pill;
|
||||
background: $pri;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: $shadow-md;
|
||||
z-index: 10;
|
||||
|
||||
&:active {
|
||||
background: $pri-d;
|
||||
}
|
||||
}
|
||||
|
||||
.fab-text {
|
||||
font-size: var(--tk-font-hero);
|
||||
color: $white;
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { View, Text, Input, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { listDialysisPrescriptions, type DialysisPrescription } from '@/services/doctor/dialysis';
|
||||
import { listPatients } from '@/services/doctor/patient';
|
||||
import Loading from '@/components/Loading';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import { safeNavigateTo } from '@/utils/navigate';
|
||||
import './index.scss';
|
||||
|
||||
const TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'active', label: '生效中' },
|
||||
{ key: 'inactive', label: '已停用' },
|
||||
];
|
||||
|
||||
export default function PrescriptionList() {
|
||||
const router = useRouter();
|
||||
const patientId = router.params.patientId || '';
|
||||
const modeClass = useElderClass();
|
||||
const [searchPatient, setSearchPatient] = useState('');
|
||||
const [currentPatientId, setCurrentPatientId] = useState(patientId);
|
||||
const [activeTab, setActiveTab] = useState('');
|
||||
const [prescriptions, setPrescriptions] = useState<DialysisPrescription[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
const loadData = useCallback(async (p: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listDialysisPrescriptions({
|
||||
patient_id: currentPatientId || undefined,
|
||||
status: activeTab || undefined,
|
||||
page: p,
|
||||
page_size: 20,
|
||||
});
|
||||
setPrescriptions(res.data || []);
|
||||
setTotal(res.total || 0);
|
||||
setPage(p);
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentPatientId, activeTab]);
|
||||
|
||||
usePageData(
|
||||
useCallback(() => loadData(1), [loadData]),
|
||||
);
|
||||
|
||||
// tab/patientId 变化时重新加载(跳过首次 mount,由 usePageData 的 useDidShow 处理)
|
||||
useEffect(() => {
|
||||
if (mountedRef.current) {
|
||||
loadData(1);
|
||||
}
|
||||
mountedRef.current = true;
|
||||
}, [currentPatientId, activeTab, loadData]);
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchPatient.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listPatients({ search: searchPatient.trim(), page: 1, page_size: 1 });
|
||||
if (res.data && res.data.length > 0) {
|
||||
setCurrentPatientId(res.data[0].id);
|
||||
} else {
|
||||
Taro.showToast({ title: '未找到患者', icon: 'none' });
|
||||
}
|
||||
} catch {
|
||||
Taro.showToast({ title: '搜索失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && prescriptions.length === 0) return <Loading />;
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className={`prescription-page ${modeClass}`}>
|
||||
{!patientId && (
|
||||
<View className='search-bar'>
|
||||
<Input
|
||||
className='search-input'
|
||||
placeholder='搜索患者姓名'
|
||||
value={searchPatient}
|
||||
onInput={(e) => setSearchPatient(e.detail.value)}
|
||||
confirmType='search'
|
||||
onConfirm={handleSearch}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className='tabs'>
|
||||
{TABS.map((t) => (
|
||||
<View
|
||||
key={t.key}
|
||||
className={`tab ${activeTab === t.key ? 'tab--active' : ''}`}
|
||||
onClick={() => { setActiveTab(t.key); setPage(1); }}
|
||||
>
|
||||
<Text className='tab-text'>{t.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{prescriptions.length === 0 ? (
|
||||
<EmptyState text='暂无透析处方' />
|
||||
) : (
|
||||
<View className='prescription-list'>
|
||||
<View className='prescription-count'><Text>共 {total} 条处方</Text></View>
|
||||
{prescriptions.map((p) => (
|
||||
<View
|
||||
key={p.id}
|
||||
className='prescription-card'
|
||||
onClick={() => safeNavigateTo(`/pages/pkg-doctor-clinical/prescription/detail/index?id=${p.id}`)}
|
||||
>
|
||||
<View className='prescription-card__header'>
|
||||
<Text className='prescription-card__model'>{p.dialyzer_model || '透析处方'}</Text>
|
||||
<Text className={`status-tag status-tag--${p.status}`}>
|
||||
{p.status === 'active' ? '生效中' : p.status === 'inactive' ? '已停用' : p.status}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='prescription-card__body'>
|
||||
{p.frequency_per_week != null && (
|
||||
<Text className='prescription-card__meta'>{p.frequency_per_week}次/周</Text>
|
||||
)}
|
||||
{p.duration_minutes != null && (
|
||||
<Text className='prescription-card__meta'>每次{p.duration_minutes}分钟</Text>
|
||||
)}
|
||||
</View>
|
||||
{(p.effective_from || p.effective_to) && (
|
||||
<Text className='prescription-card__date'>
|
||||
{p.effective_from || '...'} ~ {p.effective_to || '...'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
{total > 20 && (
|
||||
<View className='pagination'>
|
||||
<View
|
||||
className={`page-btn ${page <= 1 ? 'page-btn--disabled' : ''}`}
|
||||
onClick={() => page > 1 && loadData(page - 1)}
|
||||
>
|
||||
<Text>上一页</Text>
|
||||
</View>
|
||||
<Text className='page-info'>{page} / {Math.ceil(total / 20)}</Text>
|
||||
<View
|
||||
className={`page-btn ${page * 20 >= total ? 'page-btn--disabled' : ''}`}
|
||||
onClick={() => page * 20 < total && loadData(page + 1)}
|
||||
>
|
||||
<Text>下一页</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View
|
||||
className='fab'
|
||||
onClick={() => {
|
||||
if (!currentPatientId) {
|
||||
Taro.showToast({ title: '请先选择患者', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
safeNavigateTo(`/pages/pkg-doctor-clinical/prescription/create/index?patientId=${currentPatientId}`);
|
||||
}}
|
||||
>
|
||||
<Text className='fab-text'>+</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
@import '../../../../styles/variables.scss';
|
||||
@import '../../../../styles/mixins.scss';
|
||||
|
||||
.report-detail {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding: 24px;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: 28px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@include section-title;
|
||||
}
|
||||
|
||||
.report-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
&__type {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
&__status {
|
||||
font-size: var(--tk-font-h2);
|
||||
padding: 6px 16px;
|
||||
border-radius: $r;
|
||||
font-weight: 500;
|
||||
|
||||
&--pending { background: $wrn-l; color: $wrn; }
|
||||
&--reviewed { background: $acc-l; color: $acc; }
|
||||
}
|
||||
}
|
||||
|
||||
.report-date {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.review-info {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $acc;
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.indicator-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.indicator-row {
|
||||
display: flex;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid $bd-l;
|
||||
align-items: center;
|
||||
|
||||
&--header {
|
||||
border-bottom: 2px solid $bd;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
&--abnormal {
|
||||
background: $dan-l;
|
||||
margin: 0 -12px;
|
||||
padding: 16px 12px;
|
||||
border-radius: $r-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.indicator-cell {
|
||||
font-size: var(--tk-font-h2);
|
||||
|
||||
&--name {
|
||||
flex: 2;
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&--value {
|
||||
@include serif-number;
|
||||
flex: 2;
|
||||
color: $tx;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&--ref {
|
||||
@include serif-number;
|
||||
flex: 2;
|
||||
color: $tx3;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&--flag {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
.indicator-row--abnormal &--flag {
|
||||
color: $dan;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.indicator-row--header & {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.notes-display {
|
||||
background: $pri-l;
|
||||
border-radius: $r;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.notes-text {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.notes-textarea {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
background: $bd-l;
|
||||
border-radius: $r;
|
||||
padding: 20px;
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $tx;
|
||||
box-sizing: border-box;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.review-btn {
|
||||
background: $pri;
|
||||
border-radius: $r;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
color: $card;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.error-text {
|
||||
text-align: center;
|
||||
padding: 80px 32px;
|
||||
color: $tx3;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { View, Text, Textarea, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { getLabReport, reviewLabReport, type LabReportDetail } from '@/services/doctor/labReport';
|
||||
import Loading from '@/components/Loading';
|
||||
import { useElderClass } from '../../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
export default function ReportDetail() {
|
||||
const router = useRouter();
|
||||
const patientId = router.params.patientId || '';
|
||||
const reportId = router.params.id || '';
|
||||
const modeClass = useElderClass();
|
||||
const [report, setReport] = useState<LabReportDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [doctorNotes, setDoctorNotes] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const loadReport = useCallback(async () => {
|
||||
if (!patientId || !reportId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await getLabReport(patientId, reportId);
|
||||
setReport(r);
|
||||
setDoctorNotes(r.doctor_notes || '');
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [patientId, reportId]);
|
||||
|
||||
usePageData(loadReport, { throttleMs: 60000, enablePullDown: false, enabled: !!(patientId && reportId) });
|
||||
|
||||
const handleReview = async () => {
|
||||
if (!report) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const updated = await reviewLabReport(patientId, reportId, {
|
||||
doctor_notes: doctorNotes.trim() || undefined,
|
||||
version: report.version,
|
||||
});
|
||||
setReport(updated);
|
||||
Taro.showToast({ title: '审核完成', icon: 'success' });
|
||||
} catch {
|
||||
Taro.showToast({ title: '审核失败', icon: 'none' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (d: string) => new Date(d).toLocaleDateString('zh-CN');
|
||||
|
||||
if (loading) return <Loading />;
|
||||
if (!report) return <View className={`error-text ${modeClass}`}><Text>报告加载失败</Text></View>;
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className={`report-detail ${modeClass}`}>
|
||||
{/* 基本信息 */}
|
||||
<View className='section'>
|
||||
<View className='report-header'>
|
||||
<Text className='report-header__type'>{report.report_type}</Text>
|
||||
<Text className={`report-header__status report-header__status--${report.status}`}>
|
||||
{report.status === 'pending' ? '待审核' : report.status === 'reviewed' ? '已审核' : report.status}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className='report-date'>报告日期: {formatDate(report.report_date)}</Text>
|
||||
{report.reviewed_at && (
|
||||
<Text className='review-info'>审核于: {formatDate(report.reviewed_at)}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 指标列表 */}
|
||||
{report.items && report.items.length > 0 && (
|
||||
<View className='section'>
|
||||
<Text className='section-title'>检验指标</Text>
|
||||
<View className='indicator-table'>
|
||||
<View className='indicator-row indicator-row--header'>
|
||||
<Text className='indicator-cell indicator-cell--name'>指标</Text>
|
||||
<Text className='indicator-cell indicator-cell--value'>结果</Text>
|
||||
<Text className='indicator-cell indicator-cell--ref'>参考值</Text>
|
||||
<Text className='indicator-cell indicator-cell--flag'>状态</Text>
|
||||
</View>
|
||||
{report.items.map((item, idx) => (
|
||||
<View
|
||||
key={idx}
|
||||
className={`indicator-row ${item.is_abnormal ? 'indicator-row--abnormal' : ''}`}
|
||||
>
|
||||
<Text className='indicator-cell indicator-cell--name'>{item.name}</Text>
|
||||
<Text className='indicator-cell indicator-cell--value'>
|
||||
{item.value} {item.unit || ''}
|
||||
</Text>
|
||||
<Text className='indicator-cell indicator-cell--ref'>
|
||||
{item.reference_min != null && item.reference_max != null
|
||||
? `${item.reference_min}-${item.reference_max}`
|
||||
: '-'}
|
||||
</Text>
|
||||
<Text className='indicator-cell indicator-cell--flag'>
|
||||
{item.is_abnormal ? '↑' : '正常'}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 医生注释 */}
|
||||
<View className='section'>
|
||||
<Text className='section-title'>医生注释</Text>
|
||||
{report.status === 'reviewed' && report.doctor_notes ? (
|
||||
<View className='notes-display'>
|
||||
<Text className='notes-text'>{report.doctor_notes}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View>
|
||||
<Textarea
|
||||
className='notes-textarea'
|
||||
placeholder='输入诊断意见和备注...'
|
||||
value={doctorNotes}
|
||||
onInput={(e) => setDoctorNotes(e.detail.value)}
|
||||
maxlength={1000}
|
||||
/>
|
||||
<View
|
||||
className={`review-btn ${submitting ? 'review-btn--disabled' : ''}`}
|
||||
onClick={handleReview}
|
||||
>
|
||||
<Text className='review-btn__text'>{submitting ? '提交中...' : '确认审核'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
.report-page {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding: 24px;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.search-input {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 20px 24px;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.report-count {
|
||||
margin-bottom: 16px;
|
||||
|
||||
text {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
.report-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.report-card {
|
||||
background: $card;
|
||||
border-radius: $r-lg;
|
||||
padding: 28px;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&:active {
|
||||
background: $bd-l;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__type {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
&__date {
|
||||
font-size: var(--tk-font-h2);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&__indicators {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
&__abnormal {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $dan;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__normal {
|
||||
font-size: var(--tk-font-h1);
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
&__reviewed {
|
||||
@include tag($acc-l, $acc);
|
||||
}
|
||||
}
|
||||
121
apps/miniprogram/src/pages/pkg-doctor-clinical/report/index.tsx
Normal file
121
apps/miniprogram/src/pages/pkg-doctor-clinical/report/index.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { View, Text, Input, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { listLabReports, type LabReportItem } from '@/services/doctor/labReport';
|
||||
import { listPatients } from '@/services/doctor/patient';
|
||||
import Loading from '@/components/Loading';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
export default function ReportList() {
|
||||
const router = useRouter();
|
||||
const patientId = router.params.patientId || '';
|
||||
const modeClass = useElderClass();
|
||||
const [searchPatient, setSearchPatient] = useState('');
|
||||
const [currentPatientId, setCurrentPatientId] = useState(patientId);
|
||||
const [reports, setReports] = useState<LabReportItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
const loadReports = useCallback(async () => {
|
||||
if (!currentPatientId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listLabReports(currentPatientId, { page: 1, page_size: 50 });
|
||||
setReports(res.data || []);
|
||||
setTotal(res.total || 0);
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentPatientId]);
|
||||
|
||||
usePageData(loadReports, { enabled: !!currentPatientId });
|
||||
|
||||
// patientId 变化时重新加载(跳过首次 mount,由 usePageData 的 useDidShow 处理)
|
||||
useEffect(() => {
|
||||
if (mountedRef.current && currentPatientId) {
|
||||
loadReports();
|
||||
}
|
||||
mountedRef.current = true;
|
||||
}, [currentPatientId, loadReports]);
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchPatient.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listPatients({ search: searchPatient.trim(), page: 1, page_size: 1 });
|
||||
if (res.data && res.data.length > 0) {
|
||||
setCurrentPatientId(res.data[0].id);
|
||||
Taro.setNavigationBarTitle({ title: res.data[0].name + '的化验报告' });
|
||||
} else {
|
||||
Taro.showToast({ title: '未找到患者', icon: 'none' });
|
||||
}
|
||||
} catch {
|
||||
Taro.showToast({ title: '搜索失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (d: string) => new Date(d).toLocaleDateString('zh-CN');
|
||||
|
||||
if (loading && reports.length === 0) return <Loading />;
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className={`report-page ${modeClass}`}>
|
||||
{!patientId && (
|
||||
<View className='search-bar'>
|
||||
<Input
|
||||
className='search-input'
|
||||
placeholder='搜索患者姓名'
|
||||
value={searchPatient}
|
||||
onInput={(e) => setSearchPatient(e.detail.value)}
|
||||
confirmType='search'
|
||||
onConfirm={handleSearch}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!currentPatientId ? (
|
||||
<EmptyState text='请搜索并选择患者' />
|
||||
) : reports.length === 0 ? (
|
||||
<EmptyState text='暂无化验报告' />
|
||||
) : (
|
||||
<View className='report-list'>
|
||||
<View className='report-count'>
|
||||
<Text>共 {total} 份报告</Text>
|
||||
</View>
|
||||
{reports.map((r) => (
|
||||
<View
|
||||
key={r.id}
|
||||
className='report-card'
|
||||
onClick={() => Taro.navigateTo({
|
||||
url: `/pages/pkg-doctor-clinical/report/detail/index?patientId=${currentPatientId}&id=${r.id}`,
|
||||
})}
|
||||
>
|
||||
<View className='report-card__header'>
|
||||
<Text className='report-card__type'>{r.report_type}</Text>
|
||||
<Text className='report-card__date'>{formatDate(r.report_date)}</Text>
|
||||
</View>
|
||||
<View className='report-card__indicators'>
|
||||
{(r.abnormal_count ?? 0) > 0 ? (
|
||||
<Text className='report-card__abnormal'>{r.abnormal_count} 项异常</Text>
|
||||
) : (
|
||||
<Text className='report-card__normal'>指标正常</Text>
|
||||
)}
|
||||
{r.status === 'reviewed' && (
|
||||
<Text className='report-card__reviewed'>已审核</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user