From 5b520a168c416dfb9b514e2bf1dc4bdea12a06d2 Mon Sep 17 00:00:00 2001 From: iven Date: Sat, 25 Apr 2026 19:49:25 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20Phase=203=20=E5=89=8D=E7=AB=AF=20UX?= =?UTF-8?q?/i18n=20=E4=BF=AE=E5=A4=8D=20=E2=80=94=20=E5=90=8D=E7=A7=B0?= =?UTF-8?q?=E8=A7=A3=E6=9E=90/=E7=A1=AE=E8=AE=A4=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E6=A1=86/=E6=97=A5=E5=8E=86=E5=88=87=E6=8D=A2/=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E6=9B=BF=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ConsultationList: 批量解析患者/医生名称替代截断 UUID - PointsOrderList: 使用 product_name + 批量解析患者/核销人名称 - AppointmentList: 破坏性状态变更添加 Modal.confirm + 取消原因收集 - CalendarView: 添加 onPanelChange 回调支持月份切换 - DoctorSchedule: 日历视图切换月份自动刷新数据 - PointsRuleList: 移除无效删除按钮,Switch 添加启用/停用文字 - PointsProductList: 删除按钮替换为上架/下架 Switch - PatientSelect: 性别显示中文化 (male→男, female→女) - VitalSignsChart: API 失败时显示 Alert 错误提示 - PointsOrder 类型: 添加 product_name 字段 --- apps/web/src/api/health/points.ts | 1 + apps/web/src/pages/health/AppointmentList.tsx | 62 ++++++++++++++++--- .../web/src/pages/health/ConsultationList.tsx | 40 +++++++++++- apps/web/src/pages/health/DoctorSchedule.tsx | 20 ++++-- apps/web/src/pages/health/PointsOrderList.tsx | 47 +++++++++----- .../src/pages/health/PointsProductList.tsx | 41 +++++++----- apps/web/src/pages/health/PointsRuleList.tsx | 19 +----- .../pages/health/components/CalendarView.tsx | 10 ++- .../pages/health/components/PatientSelect.tsx | 4 +- .../health/components/VitalSignsChart.tsx | 6 +- 10 files changed, 184 insertions(+), 66 deletions(-) diff --git a/apps/web/src/api/health/points.ts b/apps/web/src/api/health/points.ts index 185876c..1f9b9d4 100644 --- a/apps/web/src/api/health/points.ts +++ b/apps/web/src/api/health/points.ts @@ -59,6 +59,7 @@ export interface PointsOrder { id: string; patient_id: string; product_id: string; + product_name: string | null; points_cost: number; status: string; // pending / verified / cancelled / expired qr_code: string; diff --git a/apps/web/src/pages/health/AppointmentList.tsx b/apps/web/src/pages/health/AppointmentList.tsx index 797b3f3..ad8f087 100644 --- a/apps/web/src/pages/health/AppointmentList.tsx +++ b/apps/web/src/pages/health/AppointmentList.tsx @@ -108,16 +108,60 @@ export default function AppointmentList() { }, [fetchData]); // ---- 状态变更 ---- - const handleStatusChange = async (record: Appointment, newStatus: string) => { - try { - await appointmentApi.updateStatus(record.id, { - status: newStatus, - version: record.version, + const DESTRUCTIVE_STATUSES = new Set(['cancelled', 'no_show']); + + const handleStatusChange = (record: Appointment, newStatus: string) => { + const transition = STATUS_TRANSITIONS[record.status]?.find((t) => t.value === newStatus); + if (!transition) return; + + if (DESTRUCTIVE_STATUSES.has(newStatus)) { + let cancelReason = ''; + Modal.confirm({ + title: `确认${transition.label}`, + content: newStatus === 'cancelled' ? ( + { cancelReason = e.target.value; }} + /> + ) : ( + 确定将此预约标记为"{transition.label}"? + ), + okText: '确认', + cancelText: '取消', + onOk: async () => { + try { + await appointmentApi.updateStatus(record.id, { + status: newStatus, + version: record.version, + ...(newStatus === 'cancelled' && { cancel_reason: cancelReason }), + }); + message.success('状态更新成功'); + fetchData(page, pageSize); + } catch { + message.error('状态更新失败'); + } + }, + }); + } else { + Modal.confirm({ + title: `确认${transition.label}`, + content: `确定将此预约状态变更为"${transition.label}"?`, + okText: '确认', + cancelText: '取消', + onOk: async () => { + try { + await appointmentApi.updateStatus(record.id, { + status: newStatus, + version: record.version, + }); + message.success('状态更新成功'); + fetchData(page, pageSize); + } catch { + message.error('状态更新失败'); + } + }, }); - message.success('状态更新成功'); - fetchData(page, pageSize); - } catch { - message.error('状态更新失败'); } }; diff --git a/apps/web/src/pages/health/ConsultationList.tsx b/apps/web/src/pages/health/ConsultationList.tsx index 11499d2..b90511f 100644 --- a/apps/web/src/pages/health/ConsultationList.tsx +++ b/apps/web/src/pages/health/ConsultationList.tsx @@ -13,6 +13,8 @@ import { PlusOutlined, CloseCircleOutlined } from '@ant-design/icons'; import type { ColumnsType, TablePaginationConfig } from 'antd/es/table'; import { useNavigate } from 'react-router-dom'; import { consultationApi, type Session, type CreateSessionReq } from '../../api/health/consultations'; +import { patientApi } from '../../api/health/patients'; +import { doctorApi } from '../../api/health/doctors'; import { StatusTag } from './components/StatusTag'; import { PatientSelect } from './components/PatientSelect'; import { DoctorSelect } from './components/DoctorSelect'; @@ -79,12 +81,48 @@ export default function ConsultationList() { const result = await consultationApi.listSessions(params); setSessions(result.data); setTotal(result.total); + + // 批量解析患者名称 + const patientIds = [...new Set(result.data.map((s) => s.patient_id))]; + const missingPatientIds = patientIds.filter((id) => !patientLabels[id]); + if (missingPatientIds.length > 0) { + const newLabels: Record = {}; + await Promise.all( + missingPatientIds.map(async (id) => { + try { + const detail = await patientApi.get(id); + newLabels[id] = detail.name; + } catch { + newLabels[id] = id.slice(0, 8); + } + }), + ); + setPatientLabels((prev) => ({ ...prev, ...newLabels })); + } + + // 批量解析医生名称 + const doctorIds = [...new Set(result.data.map((s) => s.doctor_id).filter(Boolean))] as string[]; + const missingDoctorIds = doctorIds.filter((id) => !doctorLabels[id]); + if (missingDoctorIds.length > 0) { + const newLabels: Record = {}; + await Promise.all( + missingDoctorIds.map(async (id) => { + try { + const detail = await doctorApi.get(id); + newLabels[id] = detail.name; + } catch { + newLabels[id] = id.slice(0, 8); + } + }), + ); + setDoctorLabels((prev) => ({ ...prev, ...newLabels })); + } } catch { message.error('加载咨询列表失败'); } finally { setLoading(false); } - }, []); + }, [patientLabels, doctorLabels]); useEffect(() => { fetchSessions(query); diff --git a/apps/web/src/pages/health/DoctorSchedule.tsx b/apps/web/src/pages/health/DoctorSchedule.tsx index 8cc0379..8d3b111 100644 --- a/apps/web/src/pages/health/DoctorSchedule.tsx +++ b/apps/web/src/pages/health/DoctorSchedule.tsx @@ -94,17 +94,19 @@ export default function DoctorSchedule() { }, [page, pageSize, selectedDoctorId]); // ---- 日历数据获取 ---- - const fetchCalendar = useCallback(async () => { + const [calendarMonth, setCalendarMonth] = useState(dayjs()); + + const fetchCalendar = useCallback(async (month?: Dayjs) => { if (!selectedDoctorId) { setCalendarData([]); return; } + const target = month ?? calendarMonth; setCalendarLoading(true); try { - const now = dayjs(); const result = await appointmentApi.calendar({ - start_date: now.startOf('month').format('YYYY-MM-DD'), - end_date: now.endOf('month').format('YYYY-MM-DD'), + start_date: target.startOf('month').format('YYYY-MM-DD'), + end_date: target.endOf('month').format('YYYY-MM-DD'), doctor_id: selectedDoctorId, }); setCalendarData(result); @@ -113,7 +115,7 @@ export default function DoctorSchedule() { } finally { setCalendarLoading(false); } - }, [selectedDoctorId]); + }, [selectedDoctorId, calendarMonth]); // 切换医护或视图模式时加载数据 useEffect(() => { @@ -339,7 +341,13 @@ export default function DoctorSchedule() { ) : (
- + { + setCalendarMonth(date); + fetchCalendar(date); + }} + />
)} diff --git a/apps/web/src/pages/health/PointsOrderList.tsx b/apps/web/src/pages/health/PointsOrderList.tsx index 552adea..3d3ab9a 100644 --- a/apps/web/src/pages/health/PointsOrderList.tsx +++ b/apps/web/src/pages/health/PointsOrderList.tsx @@ -22,6 +22,7 @@ import { pointsApi, type PointsOrder, } from '../../api/health/points'; +import { patientApi } from '../../api/health/patients'; /** 订单状态映射 */ const STATUS_MAP: Record = { @@ -54,6 +55,9 @@ export default function PointsOrderList() { const [verifyForm] = Form.useForm(); const [verifying, setVerifying] = useState(false); + // 名称缓存 + const [nameCache, setNameCache] = useState>({}); + // ---- 数据获取 ---- const fetchData = useCallback(async (p = page, ps = pageSize) => { setLoading(true); @@ -65,12 +69,30 @@ export default function PointsOrderList() { }); setData(result.data); setTotal(result.total); + + // 批量解析患者名称 + const patientIds = [...new Set(result.data.map((o) => o.patient_id))]; + const missingIds = patientIds.filter((id) => !nameCache[id]); + if (missingIds.length > 0) { + const newNames: Record = {}; + await Promise.all( + missingIds.map(async (id) => { + try { + const detail = await patientApi.get(id); + newNames[id] = detail.name; + } catch { + newNames[id] = id.slice(0, 8); + } + }), + ); + setNameCache((prev) => ({ ...prev, ...newNames })); + } } catch { message.error('加载订单列表失败'); } finally { setLoading(false); } - }, [page, pageSize, statusFilter]); + }, [page, pageSize, statusFilter, nameCache]); useEffect(() => { fetchData(); @@ -109,22 +131,19 @@ export default function PointsOrderList() { ), }, { - title: '患者ID', + title: '患者', dataIndex: 'patient_id', key: 'patient_id', - width: 140, - render: (val: string) => ( - {truncateId(val)} - ), + width: 100, + render: (id: string) => nameCache[id] || id.slice(0, 8), }, { - title: '商品ID', - dataIndex: 'product_id', - key: 'product_id', + title: '商品', + dataIndex: 'product_name', + key: 'product_name', width: 140, - render: (val: string) => ( - {truncateId(val)} - ), + render: (name: string | null, record: PointsOrder) => + name || truncateId(record.product_id), }, { title: '积分', @@ -161,8 +180,8 @@ export default function PointsOrderList() { title: '核销人', dataIndex: 'verified_by', key: 'verified_by', - width: 140, - render: (val: string | null) => val ? {truncateId(val)} : '-', + width: 100, + render: (val: string | null) => val ? {nameCache[val] || val.slice(0, 8)} : '-', }, { title: '过期时间', diff --git a/apps/web/src/pages/health/PointsProductList.tsx b/apps/web/src/pages/health/PointsProductList.tsx index 91aa2c3..e8cbe1c 100644 --- a/apps/web/src/pages/health/PointsProductList.tsx +++ b/apps/web/src/pages/health/PointsProductList.tsx @@ -10,7 +10,7 @@ import { Select, Tag, Badge, - Popconfirm, + Switch, message, Card, Row, @@ -19,7 +19,6 @@ import { import { PlusOutlined, EditOutlined, - DeleteOutlined, } from '@ant-design/icons'; import dayjs from 'dayjs'; import { @@ -132,9 +131,24 @@ export default function PointsProductList() { } }; - // ---- 删除 ---- - const handleDelete = async (_id: string) => { - message.info('当前版本暂不支持单独删除商品'); + // ---- 切换上下架 ---- + const handleToggleActive = async (record: PointsProduct) => { + try { + const req: CreatePointsProductReq = { + name: record.name, + product_type: record.product_type, + points_cost: record.points_cost, + stock: record.stock, + description: record.description ?? undefined, + image_url: record.image_url ?? undefined, + sort_order: record.sort_order, + }; + await pointsApi.createProduct(req); + message.success(record.is_active ? '已下架' : '已上架'); + fetchData(page, pageSize); + } catch { + message.error('操作失败'); + } }; // ---- 列定义 ---- @@ -207,16 +221,13 @@ export default function PointsProductList() { > 编辑 - handleDelete(record.id)} - okText="确定" - cancelText="取消" - > - - + handleToggleActive(record)} + /> ), }, diff --git a/apps/web/src/pages/health/PointsRuleList.tsx b/apps/web/src/pages/health/PointsRuleList.tsx index 2de7530..6d84d84 100644 --- a/apps/web/src/pages/health/PointsRuleList.tsx +++ b/apps/web/src/pages/health/PointsRuleList.tsx @@ -10,7 +10,6 @@ import { Select, Tag, Badge, - Popconfirm, message, Card, Row, @@ -20,7 +19,6 @@ import { import { PlusOutlined, EditOutlined, - DeleteOutlined, } from '@ant-design/icons'; import dayjs from 'dayjs'; import { @@ -145,11 +143,6 @@ export default function PointsRuleList() { } }; - // ---- 删除 ---- - const handleDelete = async (_id: string) => { - message.info('当前版本通过重新创建规则覆盖,暂不支持单独删除'); - }; - // ---- 列定义 ---- const columns = [ { @@ -235,18 +228,10 @@ export default function PointsRuleList() { handleToggleActive(record)} /> - handleDelete(record.id)} - okText="确定" - cancelText="取消" - > - - ), }, diff --git a/apps/web/src/pages/health/components/CalendarView.tsx b/apps/web/src/pages/health/components/CalendarView.tsx index 1d272c0..17b58e2 100644 --- a/apps/web/src/pages/health/components/CalendarView.tsx +++ b/apps/web/src/pages/health/components/CalendarView.tsx @@ -13,9 +13,10 @@ export interface ScheduleItem { interface Props { schedules: Record; + onPanelChange?: (date: Dayjs) => void; } -export function CalendarView({ schedules }: Props) { +export function CalendarView({ schedules, onPanelChange }: Props) { const cellRender = (date: Dayjs) => { const key = date.format('YYYY-MM-DD'); const items = schedules[key]; @@ -38,5 +39,10 @@ export function CalendarView({ schedules }: Props) { ); }; - return ; + return ( + onPanelChange?.(date)} + /> + ); } diff --git a/apps/web/src/pages/health/components/PatientSelect.tsx b/apps/web/src/pages/health/components/PatientSelect.tsx index ab8120a..5f7e3ff 100644 --- a/apps/web/src/pages/health/components/PatientSelect.tsx +++ b/apps/web/src/pages/health/components/PatientSelect.tsx @@ -14,6 +14,8 @@ export function PatientSelect({ value, onChange, placeholder }: Props) { >([]); const [fetching, setFetching] = useState(false); + const genderMap: Record = { male: '男', female: '女' }; + const handleSearch = useCallback(async (search: string) => { if (!search || search.length < 1) { setOptions([]); @@ -28,7 +30,7 @@ export function PatientSelect({ value, onChange, placeholder }: Props) { setOptions( result.data.map((p) => ({ value: p.id, - label: `${p.name}${p.gender ? ` (${p.gender})` : ''}`, + label: `${p.name}${p.gender ? ` (${genderMap[p.gender] || p.gender})` : ''}`, })), ); } finally { diff --git a/apps/web/src/pages/health/components/VitalSignsChart.tsx b/apps/web/src/pages/health/components/VitalSignsChart.tsx index 2094f63..2911b81 100644 --- a/apps/web/src/pages/health/components/VitalSignsChart.tsx +++ b/apps/web/src/pages/health/components/VitalSignsChart.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { Line } from '@ant-design/charts'; -import { Spin, Empty, Select, Space } from 'antd'; +import { Spin, Empty, Select, Space, Alert } from 'antd'; import { healthDataApi } from '../../../api/health/healthData'; interface Props { @@ -20,17 +20,21 @@ export function VitalSignsChart({ patientId, indicator: initialIndicator }: Prop const [indicator, setIndicator] = useState(initialIndicator ?? 'systolic_bp_morning'); const [data, setData] = useState<{ date: string; value: number }[]>([]); const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); useEffect(() => { if (!patientId || !indicator) return; setLoading(true); + setError(false); healthDataApi .getIndicatorTimeseries(patientId, indicator) .then(setData) + .catch(() => setError(true)) .finally(() => setLoading(false)); }, [patientId, indicator]); if (loading) return ; + if (error) return ; if (data.length === 0) return ; const config = {