feat(health+miniprogram): 预约/报告/随访/资讯/家庭管理 — Chunk 4-6
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

后端:
- 添加 articles 表迁移 + Entity + Service + Handler
- 健康数据趋势 API (get_mini_trend) 注册路由
- article CRUD (list/get) + DTO

前端 (11个新页面 + 5个服务):
- 预约挂号: 列表/创建向导/详情页
- 报告管理: 列表/详情页
- 随访管理: 任务列表/记录详情页
- 资讯文章: 文章详情页
- 个人中心: 就诊人管理/新增/我的报告/我的随访/用药提醒/设置
- 更新 app.config.ts 注册全部路由
- 更新 profile/article 页面为真实功能
This commit is contained in:
iven
2026-04-24 00:58:40 +08:00
parent ee9a5c4da1
commit 9ef65b9a9f
53 changed files with 6044 additions and 32 deletions

View File

@@ -0,0 +1,118 @@
@import '../../../styles/variables.scss';
.detail-page {
min-height: 100vh;
background: $bg;
padding: 24px;
padding-bottom: 40px;
}
.detail-card {
background: $card;
border-radius: $r;
padding: 28px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.detail-title {
font-size: 34px;
font-weight: bold;
color: $tx;
display: block;
margin-bottom: 20px;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid $bd-l;
&:last-child {
border-bottom: none;
}
}
.detail-label {
font-size: 26px;
color: $tx2;
}
.detail-value {
font-size: 26px;
color: $tx;
}
.detail-desc {
margin-top: 16px;
padding: 20px;
background: $bd-l;
border-radius: $r-sm;
}
.detail-desc-text {
font-size: 26px;
color: $tx;
line-height: 1.6;
}
.submit-card {
background: $card;
border-radius: $r;
padding: 28px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.section-title {
font-size: 30px;
font-weight: bold;
color: $tx;
display: block;
margin-bottom: 16px;
}
.submit-textarea {
width: 100%;
min-height: 200px;
font-size: 28px;
color: $tx;
background: $bd-l;
border-radius: $r-sm;
padding: 20px;
box-sizing: border-box;
border: none;
outline: none;
margin-bottom: 20px;
}
.submit-btn {
background: $pri;
border-radius: $r-sm;
padding: 24px;
text-align: center;
&.disabled {
opacity: 0.6;
}
}
.submit-btn-text {
font-size: 30px;
color: white;
font-weight: bold;
}
.loading-state,
.empty-state {
display: flex;
justify-content: center;
align-items: center;
padding: 120px 0;
}
.loading-text,
.empty-text {
font-size: 28px;
color: $tx3;
}

View File

@@ -0,0 +1,119 @@
import React, { useState, useEffect } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import { listTasks, submitRecord, FollowUpTask } from '../../../services/followup';
import './index.scss';
export default function FollowUpDetail() {
const router = useRouter();
const id = router.params.id || '';
const [task, setTask] = useState<FollowUpTask | null>(null);
const [content, setContent] = useState('');
const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!id) return;
setLoading(true);
listTasks()
.then((res) => {
const found = (res.data || []).find((t) => t.id === id);
setTask(found || null);
})
.catch(() => Taro.showToast({ title: '加载失败', icon: 'none' }))
.finally(() => setLoading(false));
}, [id]);
const handleSubmit = async () => {
if (!content.trim()) {
Taro.showToast({ title: '请输入内容', icon: 'none' });
return;
}
setSubmitting(true);
try {
await submitRecord({
task_id: id,
content: { text: content.trim() },
});
Taro.showToast({ title: '提交成功', icon: 'success' });
setContent('');
} catch {
Taro.showToast({ title: '提交失败', icon: 'none' });
} finally {
setSubmitting(false);
}
};
const getStatusLabel = (status: string) => {
if (status === 'completed') return '已完成';
if (status === 'overdue') return '已过期';
return '待完成';
};
if (loading) {
return (
<View className='detail-page'>
<View className='loading-state'>
<Text className='loading-text'>...</Text>
</View>
</View>
);
}
if (!task) {
return (
<View className='detail-page'>
<View className='empty-state'>
<Text className='empty-text'></Text>
</View>
</View>
);
}
const isCompleted = task.status === 'completed';
return (
<View className='detail-page'>
{/* 任务详情 */}
<View className='detail-card'>
<Text className='detail-title'>{task.task_type}</Text>
<View className='detail-row'>
<Text className='detail-label'></Text>
<Text className='detail-value'>{getStatusLabel(task.status)}</Text>
</View>
<View className='detail-row'>
<Text className='detail-label'></Text>
<Text className='detail-value'>{task.due_date}</Text>
</View>
{task.description && (
<View className='detail-desc'>
<Text className='detail-desc-text'>{task.description}</Text>
</View>
)}
</View>
{/* 提交表单 */}
{!isCompleted && (
<View className='submit-card'>
<Text className='section-title'>访</Text>
<textarea
className='submit-textarea'
placeholder='请输入随访内容...'
value={content}
onInput={(e) => setContent(e.detail.value)}
maxlength={500}
/>
<View
className={`submit-btn ${submitting ? 'disabled' : ''}`}
onClick={submitting ? undefined : handleSubmit}
>
<Text className='submit-btn-text'>
{submitting ? '提交中...' : '提交'}
</Text>
</View>
</View>
)}
</View>
);
}

View File

@@ -0,0 +1,132 @@
@import '../../styles/variables.scss';
.followup-page {
min-height: 100vh;
background: $bg;
}
.tab-bar {
display: flex;
background: $card;
padding: 0;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
}
.tab-item {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
padding: 24px 0;
position: relative;
&.active {
&::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 48px;
height: 4px;
background: $pri;
border-radius: 2px;
}
}
}
.tab-text {
font-size: 28px;
color: $tx2;
.tab-item.active & {
color: $pri;
font-weight: bold;
}
}
.task-list {
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.task-card {
background: $card;
border-radius: $r;
padding: 28px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.task-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.task-name {
font-size: 30px;
font-weight: bold;
color: $tx;
}
.task-status {
font-size: 24px;
padding: 4px 16px;
border-radius: 20px;
&.pending {
color: $wrn;
background: $wrn-l;
}
&.completed {
color: $acc;
background: $acc-l;
}
&.overdue {
color: $dan;
background: $dan-l;
}
}
.task-desc {
font-size: 26px;
color: $tx2;
display: block;
margin-bottom: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-due {
font-size: 24px;
color: $tx3;
display: block;
}
.empty-state {
display: flex;
justify-content: center;
align-items: center;
padding: 120px 0;
}
.empty-text {
font-size: 28px;
color: $tx3;
}
.loading-hint {
text-align: center;
padding: 24px 0;
}
.loading-text {
font-size: 24px;
color: $tx3;
}

View File

@@ -0,0 +1,107 @@
import React, { useState, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow } from '@tarojs/taro';
import { listTasks, FollowUpTask } from '../../services/followup';
import './index.scss';
const TABS = [
{ key: 'pending', label: '待完成' },
{ key: 'completed', label: '已完成' },
{ key: 'overdue', label: '已过期' },
];
export default function FollowUpList() {
const [activeTab, setActiveTab] = useState('pending');
const [tasks, setTasks] = useState<FollowUpTask[]>([]);
const [loading, setLoading] = useState(false);
const fetchTasks = useCallback(async (status: string) => {
setLoading(true);
try {
const res = await listTasks(status);
setTasks(res.data || []);
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
}
}, []);
useDidShow(() => {
fetchTasks(activeTab);
}, [activeTab, fetchTasks]);
const handleTabChange = (key: string) => {
setActiveTab(key);
setTasks([]);
fetchTasks(key);
};
const goToDetail = (id: string) => {
Taro.navigateTo({ url: `/pages/followup/detail/index?id=${id}` });
};
const getStatusClass = (status: string) => {
if (status === 'completed') return 'completed';
if (status === 'overdue') return 'overdue';
return 'pending';
};
const getStatusLabel = (status: string) => {
if (status === 'completed') return '已完成';
if (status === 'overdue') return '已过期';
return '待完成';
};
return (
<View className='followup-page'>
{/* Tab 栏 */}
<View className='tab-bar'>
{TABS.map((tab) => (
<View
className={`tab-item ${activeTab === tab.key ? 'active' : ''}`}
key={tab.key}
onClick={() => handleTabChange(tab.key)}
>
<Text className='tab-text'>{tab.label}</Text>
</View>
))}
</View>
{/* 任务列表 */}
<View className='task-list'>
{tasks.map((t) => (
<View
className='task-card'
key={t.id}
onClick={() => goToDetail(t.id)}
>
<View className='task-top'>
<Text className='task-name'>{t.task_type}</Text>
<Text className={`task-status ${getStatusClass(t.status)}`}>
{getStatusLabel(t.status)}
</Text>
</View>
<Text className='task-desc'>{t.description}</Text>
<Text className='task-due'>{t.due_date}</Text>
</View>
))}
</View>
{tasks.length === 0 && !loading && (
<View className='empty-state'>
<Text className='empty-text'>{(() => {
const tab = TABS.find((t) => t.key === activeTab);
return tab ? tab.label : '';
})()}</Text>
</View>
)}
{loading && (
<View className='loading-hint'>
<Text className='loading-text'>...</Text>
</View>
)}
</View>
);
}