feat(mp+health): 小程序分包迁移 + 积分商城后台列表 API
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

- 小程序页面迁移到 pkg-health/pkg-mall/pkg-profile 分包目录
- 删除旧 pages/health/input、pages/mall/detail 等旧路径
- 导航路径更新为分包路径(/pages/pkg-mall/exchange/index 等)
- TrendChart 组件优化
- 后台添加 admin_list_products API(支持查看已下架商品)
- config/index.ts 添加 defineConstants 环境变量
- mp e2e check-readiness 路径修正
This commit is contained in:
iven
2026-04-29 07:29:49 +08:00
parent 9015a2b85e
commit cb6f5cc651
32 changed files with 229 additions and 516 deletions

View File

@@ -0,0 +1,116 @@
@import '../../../styles/variables.scss';
@mixin section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $tx;
margin-bottom: 20px;
display: block;
}
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.family-add-page {
min-height: 100vh;
background: $bg;
padding: 32px 24px;
padding-bottom: 160px;
}
.page-title {
@include section-title;
padding-left: 4px;
}
.form-card {
background: $card;
border-radius: $r;
padding: 4px 28px;
box-shadow: $shadow-sm;
}
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28px 0;
border-bottom: 1px solid $bd-l;
&:last-child {
border-bottom: none;
}
}
.form-label {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
color: $tx;
flex-shrink: 0;
width: 140px;
font-weight: 500;
}
.form-input {
flex: 1;
font-size: 28px;
color: $tx;
text-align: right;
border: none;
background: transparent;
outline: none;
}
.form-placeholder {
color: $tx3;
}
.form-picker {
display: flex;
align-items: center;
flex: 1;
justify-content: flex-end;
}
.form-picker-text {
font-size: 28px;
color: $tx;
margin-right: 10px;
&.placeholder {
color: $tx3;
}
}
.form-picker-arrow {
font-size: 24px;
color: $tx3;
font-family: 'Georgia', 'Times New Roman', serif;
}
.submit-btn {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: $pri;
padding: 28px;
text-align: center;
box-shadow: 0 -2px 12px rgba(196, 98, 58, 0.15);
&.disabled {
opacity: 0.5;
}
}
.submit-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 32px;
color: #fff;
font-weight: bold;
letter-spacing: 2px;
}

View File

@@ -0,0 +1,131 @@
import React, { useState, useEffect } from 'react';
import { View, Text, Input, Picker } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import { createPatient, updatePatient, Patient } from '../../../services/patient';
import './index.scss';
const RELATION_OPTIONS = ['本人', '配偶', '父母', '子女', '其他'];
const GENDER_OPTIONS = ['男', '女'];
export default function FamilyAdd() {
const router = useRouter();
const editId = router.params.id || '';
const editData = Taro.getStorageSync('edit_patient') as Patient | null;
const [name, setName] = useState(editData?.name || '');
const [relationIdx, setRelationIdx] = useState(
editData?.relation ? RELATION_OPTIONS.indexOf(editData.relation) : 0
);
const [genderIdx, setGenderIdx] = useState(
editData?.gender === 'female' ? 1 : 0
);
const [birthDate, setBirthDate] = useState(editData?.birth_date || '');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
return () => { Taro.removeStorageSync('edit_patient'); };
}, []);
const handleSubmit = async () => {
if (!name.trim()) {
Taro.showToast({ title: '请输入姓名', icon: 'none' });
return;
}
setSubmitting(true);
try {
if (editId && editData) {
await updatePatient(editId, {
name: name.trim(),
gender: GENDER_OPTIONS[genderIdx] === '男' ? 'male' : 'female',
birth_date: birthDate || undefined,
relation: RELATION_OPTIONS[relationIdx],
}, editData.version);
Taro.showToast({ title: '修改成功', icon: 'success' });
} else {
await createPatient({
name: name.trim(),
gender: GENDER_OPTIONS[genderIdx] === '男' ? 'male' : 'female',
birth_date: birthDate || undefined,
});
Taro.showToast({ title: '添加成功', icon: 'success' });
}
setTimeout(() => Taro.navigateBack(), 1000);
} catch {
Taro.showToast({ title: editId ? '修改失败' : '添加失败', icon: 'none' });
} finally {
setSubmitting(false);
}
};
return (
<View className='family-add-page'>
<Text className='page-title'>{editId ? '编辑就诊人' : '添加就诊人'}</Text>
<View className='form-card'>
<View className='form-item'>
<Text className='form-label'></Text>
<Input
className='form-input'
placeholder='请输入姓名'
placeholderClass='form-placeholder'
value={name}
onInput={(e) => setName(e.detail.value)}
/>
</View>
<View className='form-item'>
<Text className='form-label'></Text>
<Picker
mode='selector'
range={RELATION_OPTIONS}
value={relationIdx}
onChange={(e) => setRelationIdx(Number(e.detail.value))}
>
<View className='form-picker'>
<Text className='form-picker-text'>{RELATION_OPTIONS[relationIdx]}</Text>
<Text className='form-picker-arrow'>></Text>
</View>
</Picker>
</View>
<View className='form-item'>
<Text className='form-label'></Text>
<Picker
mode='selector'
range={GENDER_OPTIONS}
value={genderIdx}
onChange={(e) => setGenderIdx(Number(e.detail.value))}
>
<View className='form-picker'>
<Text className='form-picker-text'>{GENDER_OPTIONS[genderIdx]}</Text>
<Text className='form-picker-arrow'>></Text>
</View>
</Picker>
</View>
<View className='form-item'>
<Text className='form-label'></Text>
<Picker
mode='date'
value={birthDate || '2000-01-01'}
onChange={(e) => setBirthDate(e.detail.value)}
>
<View className='form-picker'>
<Text className={`form-picker-text ${!birthDate ? 'placeholder' : ''}`}>
{birthDate || '请选择'}
</Text>
<Text className='form-picker-arrow'>></Text>
</View>
</Picker>
</View>
</View>
<View
className={`submit-btn ${submitting ? 'disabled' : ''}`}
onClick={submitting ? undefined : handleSubmit}
>
<Text className='submit-text'>{submitting ? '提交中...' : editId ? '保存修改' : '确认添加'}</Text>
</View>
</View>
);
}

View File

@@ -0,0 +1,159 @@
@import '../../../styles/variables.scss';
@mixin serif-number {
font-family: 'Georgia', 'Times New Roman', serif;
font-variant-numeric: tabular-nums;
}
@mixin section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $tx;
margin-bottom: 20px;
display: block;
}
@mixin tag($bg, $color) {
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
font-size: 20px;
font-weight: 500;
background: $bg;
color: $color;
}
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.family-page {
min-height: 100vh;
background: $bg;
padding: 32px 24px;
padding-bottom: 160px;
}
.family-page-title {
@include section-title;
padding-left: 4px;
}
.family-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.family-item {
display: flex;
align-items: center;
background: $card;
border-radius: $r;
padding: 24px;
box-shadow: $shadow-sm;
transition: box-shadow 0.2s;
&:active {
box-shadow: $shadow-md;
}
&.active {
box-shadow: $shadow-md;
}
}
.family-avatar {
@include flex-center;
width: 80px;
height: 80px;
border-radius: $r;
background: $pri-l;
flex-shrink: 0;
margin-right: 20px;
}
.family-avatar-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 36px;
font-weight: bold;
color: $pri-d;
}
.family-info {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.family-name-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.family-name {
font-size: 30px;
font-weight: bold;
color: $tx;
}
.family-current-tag {
@include tag($pri, #fff);
font-size: 18px;
padding: 2px 10px;
}
.family-meta {
display: flex;
align-items: center;
gap: 12px;
}
.family-relation-tag {
@include tag($pri-l, $pri-d);
font-size: 20px;
padding: 2px 12px;
}
.family-gender {
font-size: 24px;
color: $tx2;
}
.family-edit {
flex-shrink: 0;
margin-left: 16px;
padding: 8px 20px;
border: 1px solid $bd;
border-radius: $r-pill;
}
.family-edit-text {
font-size: 24px;
color: $tx2;
}
.family-add-btn {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: $pri;
padding: 28px;
text-align: center;
box-shadow: 0 -2px 12px rgba(196, 98, 58, 0.15);
}
.family-add-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 32px;
color: #fff;
font-weight: bold;
letter-spacing: 2px;
}

View File

@@ -0,0 +1,106 @@
import React, { useState, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow } from '@tarojs/taro';
import { listPatients, Patient } from '../../../services/patient';
import { useAuthStore } from '../../../stores/auth';
import EmptyState from '../../../components/EmptyState';
import './index.scss';
export default function FamilyList() {
const [patients, setPatients] = useState<Patient[]>([]);
const [loading, setLoading] = useState(false);
const { currentPatient, setCurrentPatient } = useAuthStore();
const fetchPatients = useCallback(async () => {
setLoading(true);
try {
const res = await listPatients();
setPatients(res.data || []);
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
}
}, []);
useDidShow(() => {
fetchPatients();
});
const handleSelect = (patient: Patient) => {
setCurrentPatient({
id: patient.id,
name: patient.name,
gender: patient.gender,
birth_date: patient.birth_date,
relation: patient.relation || '本人',
});
Taro.showToast({ title: `已切换为 ${patient.name}`, icon: 'success' });
};
const goToAdd = () => {
Taro.navigateTo({ url: '/pages/pkg-profile/family-add/index' });
};
const goToEdit = (patient: Patient) => {
Taro.setStorageSync('edit_patient', patient);
Taro.navigateTo({ url: `/pages/pkg-profile/family-add/index?id=${patient.id}` });
};
const genderText = (g?: string) => {
if (g === 'male') return '男';
if (g === 'female') return '女';
return '未知';
};
const relationInitial = (relation: string) => {
return relation ? relation.charAt(0) : '本';
};
return (
<View className='family-page'>
<Text className='family-page-title'></Text>
<View className='family-list'>
{patients.map((p) => {
const isActive = currentPatient?.id === p.id;
return (
<View
className={`family-item ${isActive ? 'active' : ''}`}
key={p.id}
onClick={() => handleSelect(p)}
>
<View className='family-avatar'>
<Text className='family-avatar-text'>{relationInitial(p.relation || '本人')}</Text>
</View>
<View className='family-info'>
<View className='family-name-row'>
<Text className='family-name'>{p.name}</Text>
{isActive && <Text className='family-current-tag'></Text>}
</View>
<View className='family-meta'>
<Text className='family-relation-tag'>{p.relation || '本人'}</Text>
<Text className='family-gender'>{genderText(p.gender)}</Text>
</View>
</View>
<View
className='family-edit'
onClick={(e) => { e.stopPropagation(); goToEdit(p); }}
>
<Text className='family-edit-text'></Text>
</View>
</View>
);
})}
</View>
{patients.length === 0 && !loading && (
<EmptyState text='暂无就诊人' />
)}
<View className='family-add-btn' onClick={goToAdd}>
<Text className='family-add-text'></Text>
</View>
</View>
);
}

View File

@@ -0,0 +1,121 @@
@import '../../../styles/variables.scss';
@mixin tag($bg, $color) {
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
font-size: 20px;
font-weight: 500;
background: $bg;
color: $color;
}
@mixin serif-number {
font-family: 'Georgia', 'Times New Roman', serif;
font-variant-numeric: tabular-nums;
}
.my-followups-page {
min-height: 100vh;
background: $bg;
}
.tab-bar {
display: flex;
background: $card;
padding: 0;
box-shadow: $shadow-sm;
}
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 24px 0 20px;
position: relative;
}
.tab-text {
font-size: 28px;
color: $tx2;
margin-bottom: 8px;
.tab-item.active & {
color: $pri;
font-weight: bold;
}
}
.tab-indicator {
width: 32px;
height: 4px;
background: $pri;
border-radius: 2px;
}
.task-list {
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.task-card {
background: $card;
border-radius: $r;
padding: 28px;
box-shadow: $shadow-sm;
&:active {
box-shadow: $shadow-md;
}
}
.task-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.task-name {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $tx;
}
.task-status {
@include tag($bd-l, $tx2);
&.pending {
@include tag($wrn-l, $wrn);
}
&.completed {
@include tag($acc-l, $acc);
}
&.overdue {
@include tag($dan-l, $dan);
}
}
.task-desc {
font-size: 26px;
color: $tx2;
display: block;
margin-bottom: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-due {
@include serif-number;
font-size: 24px;
color: $tx3;
display: block;
}

View File

@@ -0,0 +1,103 @@
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 EmptyState from '../../../components/EmptyState';
import Loading from '../../../components/Loading';
import './index.scss';
const TABS = [
{ key: 'pending', label: '待完成' },
{ key: 'completed', label: '已完成' },
{ key: 'overdue', label: '已过期' },
];
export default function MyFollowUps() {
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);
});
const handleTabChange = (key: string) => {
setActiveTab(key);
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='my-followups-page'>
<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>
{activeTab === tab.key && <View className='tab-indicator' />}
</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.follow_up_type}</Text>
<Text className={`task-status ${getStatusClass(t.status)}`}>
{getStatusLabel(t.status)}
</Text>
</View>
<Text className='task-desc'>{t.content_template}</Text>
<Text className='task-due'>: {t.planned_date}</Text>
</View>
))}
</View>
{tasks.length === 0 && !loading && (
<EmptyState text={`暂无${(() => {
const tab = TABS.find((t) => t.key === activeTab);
return tab ? tab.label : '';
})()}任务`} />
)}
{loading && (
<Loading />
)}
</View>
);
}

View File

@@ -0,0 +1,260 @@
@import '../../../styles/variables.scss';
@mixin section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $tx;
margin-bottom: 20px;
display: block;
}
@mixin serif-number {
font-family: 'Georgia', 'Times New Roman', serif;
font-variant-numeric: tabular-nums;
}
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.medication-page {
min-height: 100vh;
background: $bg;
padding: 32px 24px;
padding-bottom: 160px;
}
.page-title {
@include section-title;
padding-left: 4px;
}
.reminder-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.reminder-card {
display: flex;
align-items: center;
background: $card;
border-radius: $r;
padding: 24px;
box-shadow: $shadow-sm;
&.disabled {
opacity: 0.55;
}
}
.reminder-avatar {
@include flex-center;
width: 72px;
height: 72px;
border-radius: $r;
background: $acc-l;
flex-shrink: 0;
margin-right: 20px;
}
.reminder-avatar-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 32px;
font-weight: bold;
color: $acc;
}
.reminder-info {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.reminder-name {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $tx;
margin-bottom: 4px;
}
.reminder-dosage {
@include serif-number;
font-size: 24px;
color: $tx2;
}
.reminder-actions {
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
margin-left: 12px;
}
.toggle {
width: 80px;
height: 44px;
border-radius: $r-pill;
padding: 4px;
position: relative;
transition: background 0.3s;
&.on {
background: $pri;
}
&.off {
background: $bd;
}
}
.toggle-dot {
width: 36px;
height: 36px;
border-radius: 50%;
background: #fff;
position: absolute;
top: 4px;
transition: left 0.3s;
.toggle.on & {
left: 40px;
}
.toggle.off & {
left: 4px;
}
}
.delete-btn {
font-size: 24px;
color: $dan;
padding: 4px 12px;
}
.form-card {
background: $card;
border-radius: $r;
padding: 28px;
margin-top: 24px;
box-shadow: $shadow-sm;
}
.form-card-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
font-weight: bold;
color: $tx;
margin-bottom: 20px;
display: block;
}
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 0;
border-bottom: 1px solid $bd-l;
&:last-of-type {
border-bottom: none;
}
}
.form-label {
font-size: 28px;
color: $tx;
flex-shrink: 0;
width: 160px;
}
.form-input {
flex: 1;
font-size: 28px;
color: $tx;
text-align: right;
border: none;
background: transparent;
outline: none;
}
.form-placeholder {
color: $tx3;
}
.time-picker-wrap {
flex: 1;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.time-value {
@include serif-number;
font-size: 28px;
color: $tx;
}
.time-modify {
font-size: 24px;
color: $pri;
}
.form-actions {
display: flex;
gap: 16px;
margin-top: 24px;
}
.form-cancel {
flex: 1;
background: $bd-l;
border-radius: $r-sm;
padding: 20px;
text-align: center;
}
.form-cancel-text {
font-size: 28px;
color: $tx2;
}
.form-confirm {
flex: 1;
background: $pri;
border-radius: $r-sm;
padding: 20px;
text-align: center;
}
.form-confirm-text {
font-size: 28px;
color: #fff;
font-weight: bold;
}
.add-btn {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: $pri;
padding: 28px;
text-align: center;
box-shadow: 0 -2px 12px rgba(196, 98, 58, 0.15);
}
.add-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 32px;
color: #fff;
font-weight: bold;
letter-spacing: 2px;
}

View File

@@ -0,0 +1,175 @@
import React, { useState, useEffect } from 'react';
import { View, Text, Input, Picker } from '@tarojs/components';
import Taro from '@tarojs/taro';
import EmptyState from '../../../components/EmptyState';
import './index.scss';
interface MedicationReminder {
id: string;
name: string;
dosage: string;
time: string;
enabled: boolean;
}
const STORAGE_KEY = 'medication_reminders';
function loadReminders(): MedicationReminder[] {
return Taro.getStorageSync(STORAGE_KEY) || [];
}
function saveReminders(reminders: MedicationReminder[]) {
Taro.setStorageSync(STORAGE_KEY, reminders);
}
export default function MedicationReminder() {
const [reminders, setReminders] = useState<MedicationReminder[]>([]);
const [showForm, setShowForm] = useState(false);
const [formName, setFormName] = useState('');
const [formDosage, setFormDosage] = useState('');
const [formTime, setFormTime] = useState('08:00');
useEffect(() => {
setReminders(loadReminders());
}, []);
const updateReminders = (updated: MedicationReminder[]) => {
setReminders(updated);
saveReminders(updated);
};
const handleToggle = (id: string) => {
const updated = reminders.map((r) =>
r.id === id ? { ...r, enabled: !r.enabled } : r
);
updateReminders(updated);
};
const handleDelete = (id: string) => {
Taro.showModal({
title: '确认删除',
content: '确定要删除这个提醒吗?',
}).then((res) => {
if (res.confirm) {
updateReminders(reminders.filter((r) => r.id !== id));
}
});
};
const handleAdd = () => {
if (!formName.trim()) {
Taro.showToast({ title: '请输入药品名称', icon: 'none' });
return;
}
const newReminder: MedicationReminder = {
id: Date.now().toString(),
name: formName.trim(),
dosage: formDosage.trim(),
time: formTime,
enabled: true,
};
updateReminders([...reminders, newReminder]);
setFormName('');
setFormDosage('');
setFormTime('08:00');
setShowForm(false);
Taro.showToast({ title: '添加成功', icon: 'success' });
};
const nameInitial = (name: string) => {
return name ? name.charAt(0) : '药';
};
return (
<View className='medication-page'>
<Text className='page-title'></Text>
<View className='reminder-list'>
{reminders.map((r) => (
<View className={`reminder-card ${!r.enabled ? 'disabled' : ''}`} key={r.id}>
<View className='reminder-avatar'>
<Text className='reminder-avatar-text'>{nameInitial(r.name)}</Text>
</View>
<View className='reminder-info'>
<Text className='reminder-name'>{r.name}</Text>
<Text className='reminder-dosage'>
{r.dosage} | {r.time}
</Text>
</View>
<View className='reminder-actions'>
<View
className={`toggle ${r.enabled ? 'on' : 'off'}`}
onClick={() => handleToggle(r.id)}
>
<View className='toggle-dot' />
</View>
<Text
className='delete-btn'
onClick={() => handleDelete(r.id)}
>
</Text>
</View>
</View>
))}
</View>
{reminders.length === 0 && (
<EmptyState text='暂无用药提醒' />
)}
{showForm && (
<View className='form-card'>
<Text className='form-card-title'></Text>
<View className='form-item'>
<Text className='form-label'></Text>
<Input
className='form-input'
placeholder='请输入药品名称'
placeholderClass='form-placeholder'
value={formName}
onInput={(e) => setFormName(e.detail.value)}
/>
</View>
<View className='form-item'>
<Text className='form-label'></Text>
<Input
className='form-input'
placeholder='如: 1片、10ml'
placeholderClass='form-placeholder'
value={formDosage}
onInput={(e) => setFormDosage(e.detail.value)}
/>
</View>
<View className='form-item'>
<Text className='form-label'></Text>
<Picker
mode='time'
value={formTime}
onChange={(e) => setFormTime(e.detail.value)}
>
<View className='time-picker-wrap'>
<Text className='time-value'>{formTime}</Text>
<Text className='time-modify'></Text>
</View>
</Picker>
</View>
<View className='form-actions'>
<View className='form-cancel' onClick={() => setShowForm(false)}>
<Text className='form-cancel-text'></Text>
</View>
<View className='form-confirm' onClick={handleAdd}>
<Text className='form-confirm-text'></Text>
</View>
</View>
</View>
)}
{!showForm && (
<View className='add-btn' onClick={() => setShowForm(true)}>
<Text className='add-text'></Text>
</View>
)}
</View>
);
}

View File

@@ -0,0 +1,116 @@
@import '../../../styles/variables.scss';
@mixin section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $tx;
margin-bottom: 20px;
display: block;
}
@mixin serif-number {
font-family: 'Georgia', 'Times New Roman', serif;
font-variant-numeric: tabular-nums;
}
@mixin tag($bg, $color) {
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
font-size: 20px;
font-weight: 500;
background: $bg;
color: $color;
}
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.my-reports-page {
min-height: 100vh;
background: $bg;
padding: 32px 24px;
padding-bottom: 40px;
}
.page-title {
@include section-title;
padding-left: 4px;
}
.report-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.report-card {
background: $card;
border-radius: $r;
padding: 28px;
box-shadow: $shadow-sm;
&:active {
box-shadow: $shadow-md;
}
}
.report-card-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.report-type-row {
display: flex;
align-items: center;
}
.report-avatar {
@include flex-center;
width: 56px;
height: 56px;
border-radius: $r-sm;
background: $pri-l;
margin-right: 16px;
flex-shrink: 0;
}
.report-avatar-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
font-weight: bold;
color: $pri-d;
}
.report-type {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $tx;
}
.report-status {
@include tag($bd-l, $tx2);
&.normal {
@include tag($acc-l, $acc);
}
&.abnormal {
@include tag($dan-l, $dan);
}
}
.report-date {
@include serif-number;
font-size: 26px;
color: $tx2;
display: block;
padding-left: 72px;
}

View File

@@ -0,0 +1,106 @@
import React, { useState, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh, useReachBottom } from '@tarojs/taro';
import { listReports, LabReport } from '../../../services/report';
import EmptyState from '../../../components/EmptyState';
import Loading from '../../../components/Loading';
import './index.scss';
export default function MyReports() {
const [reports, setReports] = useState<LabReport[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const fetchData = useCallback(async (p: number, append = false) => {
const patientId = Taro.getStorageSync('current_patient_id') || '';
if (!patientId) {
setReports([]);
return;
}
setLoading(true);
try {
const res = await listReports(patientId, p);
const list = res.data || [];
setReports(append ? (prev) => [...prev, ...list] : list);
setTotal(res.total);
setPage(p);
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
}
}, []);
useDidShow(() => {
fetchData(1);
});
usePullDownRefresh(() => {
fetchData(1).finally(() => {
Taro.stopPullDownRefresh();
});
});
useReachBottom(() => {
if (!loading && reports.length < total) {
fetchData(page + 1, true);
}
});
const goToDetail = (id: string) => {
Taro.navigateTo({ url: `/pages/report/detail/index?id=${id}` });
};
const formatStatus = (report: LabReport) => {
const indicators = report.indicators;
if (!indicators || typeof indicators !== 'object') return 'unknown';
const vals = Object.values(indicators) as Array<{ status?: string }>;
const hasAbnormal = vals.some((v) => v.status === 'high' || v.status === 'low');
return hasAbnormal ? 'abnormal' : 'normal';
};
const typeInitial = (type: string) => {
return type ? type.charAt(0) : '报';
};
return (
<View className='my-reports-page'>
<Text className='page-title'></Text>
<View className='report-list'>
{reports.map((r) => {
const status = formatStatus(r);
return (
<View
className='report-card'
key={r.id}
onClick={() => goToDetail(r.id)}
>
<View className='report-card-top'>
<View className='report-type-row'>
<View className='report-avatar'>
<Text className='report-avatar-text'>{typeInitial(r.report_type)}</Text>
</View>
<Text className='report-type'>{r.report_type}</Text>
</View>
<Text className={`report-status ${status}`}>
{status === 'normal' ? '正常' : status === 'abnormal' ? '异常' : '未知'}
</Text>
</View>
<Text className='report-date'>{r.report_date}</Text>
</View>
);
})}
</View>
{reports.length === 0 && !loading && (
<EmptyState text={Taro.getStorageSync('current_patient_id') ? '暂无报告记录' : '请先在就诊人管理中选择就诊人'} />
)}
{loading && (
<Loading />
)}
</View>
);
}

View File

@@ -0,0 +1,85 @@
@import '../../../styles/variables.scss';
@mixin section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $tx;
margin-bottom: 20px;
display: block;
}
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.settings-page {
min-height: 100vh;
background: $bg;
padding: 32px 24px;
}
.page-title {
@include section-title;
padding-left: 4px;
}
.settings-group {
background: $card;
border-radius: $r;
overflow: hidden;
margin-bottom: 24px;
box-shadow: $shadow-sm;
}
.settings-item {
display: flex;
align-items: center;
padding: 28px 24px;
border-bottom: 1px solid $bd-l;
&:last-child {
border-bottom: none;
}
&.logout-item {
justify-content: center;
}
}
.settings-icon {
@include flex-center;
width: 48px;
height: 48px;
border-radius: $r-sm;
background: $pri-l;
margin-right: 16px;
flex-shrink: 0;
}
.settings-icon-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 24px;
font-weight: bold;
color: $pri-d;
}
.settings-label {
flex: 1;
font-size: 30px;
color: $tx;
}
.logout-label {
color: $dan;
font-weight: bold;
}
.settings-arrow {
font-size: 24px;
color: $tx3;
font-family: 'Georgia', 'Times New Roman', serif;
flex-shrink: 0;
}

View File

@@ -0,0 +1,96 @@
import React from 'react';
import { View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useAuthStore } from '../../../stores/auth';
import './index.scss';
export default function Settings() {
const { logout } = useAuthStore();
const handleClearCache = () => {
Taro.showModal({
title: '清除缓存',
content: '确定要清除本地缓存数据吗?不会影响账号信息。',
}).then((res) => {
if (res.confirm) {
const preservedKeys = ['access_token', 'refresh_token', 'user_data', 'user_roles', 'tenant_id', 'wechat_openid', 'current_patient', 'current_patient_id'];
const preservedData: Record<string, unknown> = {};
for (const key of preservedKeys) {
const val = Taro.getStorageSync(key);
if (val) preservedData[key] = val;
}
Taro.clearStorageSync();
for (const [key, val] of Object.entries(preservedData)) {
Taro.setStorageSync(key, val);
}
Taro.showToast({ title: '缓存已清除', icon: 'success' });
}
});
};
const handleAbout = () => {
Taro.showModal({
title: '关于我们',
content: 'HMS 健康管理平台 v1.0.0\n为您的健康保驾护航',
showCancel: false,
});
};
const handlePrivacy = () => {
Taro.showModal({
title: '隐私政策',
content: '我们重视您的隐私保护。所有健康数据均经过加密存储,仅用于为您提供健康管理服务,不会向第三方分享。',
showCancel: false,
});
};
const handleLogout = () => {
Taro.showModal({
title: '退出登录',
content: '确定要退出登录吗?',
}).then((res) => {
if (res.confirm) {
logout();
}
});
};
return (
<View className='settings-page'>
<Text className='page-title'></Text>
<View className='settings-group'>
<View className='settings-item' onClick={handleClearCache}>
<View className='settings-icon'>
<Text className='settings-icon-text'></Text>
</View>
<Text className='settings-label'></Text>
<Text className='settings-arrow'>></Text>
</View>
<View className='settings-item' onClick={handleAbout}>
<View className='settings-icon'>
<Text className='settings-icon-text'></Text>
</View>
<Text className='settings-label'></Text>
<Text className='settings-arrow'>></Text>
</View>
<View className='settings-item' onClick={handlePrivacy}>
<View className='settings-icon'>
<Text className='settings-icon-text'></Text>
</View>
<Text className='settings-label'></Text>
<Text className='settings-arrow'>></Text>
</View>
</View>
<View className='settings-group'>
<View className='settings-item logout-item' onClick={handleLogout}>
<Text className='settings-label logout-label'>退</Text>
</View>
</View>
</View>
);
}