fix(web): Phase 3 前端 UX/i18n 修复 — 名称解析/确认对话框/日历切换/删除替换
- 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:
@@ -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('状态更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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: '过期时间',
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user