fix(web): Phase 3 前端 UX/i18n 修复 — 名称解析/确认对话框/日历切换/删除替换
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

- ConsultationList: 批量解析患者/医生名称替代截断 UUID
- PointsOrderList: 使用 product_name + 批量解析患者/核销人名称
- AppointmentList: 破坏性状态变更添加 Modal.confirm + 取消原因收集
- CalendarView: 添加 onPanelChange 回调支持月份切换
- DoctorSchedule: 日历视图切换月份自动刷新数据
- PointsRuleList: 移除无效删除按钮,Switch 添加启用/停用文字
- PointsProductList: 删除按钮替换为上架/下架 Switch
- PatientSelect: 性别显示中文化 (male→男, female→女)
- VitalSignsChart: API 失败时显示 Alert 错误提示
- PointsOrder 类型: 添加 product_name 字段
This commit is contained in:
iven
2026-04-25 19:49:25 +08:00
parent e8a794ff69
commit 5b520a168c
10 changed files with 184 additions and 66 deletions

View File

@@ -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' ? (
<Input.TextArea
rows={3}
placeholder="请输入取消原因"
onChange={(e) => { cancelReason = e.target.value; }}
/>
) : (
<span>"{transition.label}"</span>
),
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('状态更新失败');
}
};

View File

@@ -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<string, string> = {};
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<string, string> = {};
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);

View File

@@ -94,17 +94,19 @@ export default function DoctorSchedule() {
}, [page, pageSize, selectedDoctorId]);
// ---- 日历数据获取 ----
const fetchCalendar = useCallback(async () => {
const [calendarMonth, setCalendarMonth] = useState<Dayjs>(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() {
) : (
<Spin spinning={calendarLoading}>
<div style={{ marginTop: 16 }}>
<CalendarView schedules={calendarScheduleMap} />
<CalendarView
schedules={calendarScheduleMap}
onPanelChange={(date) => {
setCalendarMonth(date);
fetchCalendar(date);
}}
/>
</div>
</Spin>
)}

View File

@@ -22,6 +22,7 @@ import {
pointsApi,
type PointsOrder,
} from '../../api/health/points';
import { patientApi } from '../../api/health/patients';
/** 订单状态映射 */
const STATUS_MAP: Record<string, { text: string; color: string }> = {
@@ -54,6 +55,9 @@ export default function PointsOrderList() {
const [verifyForm] = Form.useForm();
const [verifying, setVerifying] = useState(false);
// 名称缓存
const [nameCache, setNameCache] = useState<Record<string, string>>({});
// ---- 数据获取 ----
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<string, string> = {};
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) => (
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{truncateId(val)}</span>
),
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) => (
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{truncateId(val)}</span>
),
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 ? <Tag color="blue">{truncateId(val)}</Tag> : '-',
width: 100,
render: (val: string | null) => val ? <Tag color="blue">{nameCache[val] || val.slice(0, 8)}</Tag> : '-',
},
{
title: '过期时间',

View File

@@ -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() {
>
</Button>
<Popconfirm
title="确定删除该商品?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
<Switch
size="small"
checked={record.is_active}
checkedChildren="上架"
unCheckedChildren="下架"
onChange={() => handleToggleActive(record)}
/>
</Space>
),
},

View File

@@ -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() {
<Switch
size="small"
checked={record.is_active}
checkedChildren="启用"
unCheckedChildren="停用"
onChange={() => handleToggleActive(record)}
/>
<Popconfirm
title="确定删除该规则?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},

View File

@@ -13,9 +13,10 @@ export interface ScheduleItem {
interface Props {
schedules: Record<string, ScheduleItem[]>;
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 <Calendar cellRender={cellRender} />;
return (
<Calendar
cellRender={cellRender}
onPanelChange={(date) => onPanelChange?.(date)}
/>
);
}

View File

@@ -14,6 +14,8 @@ export function PatientSelect({ value, onChange, placeholder }: Props) {
>([]);
const [fetching, setFetching] = useState(false);
const genderMap: Record<string, string> = { 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 {

View File

@@ -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 <Spin />;
if (error) return <Alert type="error" message="加载数据失败,请稍后重试" />;
if (data.length === 0) return <Empty description="暂无数据" />;
const config = {