refactor(web): 列表页统一迁移 — PageContainer + usePaginatedData + 格式化规范
8 个列表页迁移至统一模式: - PatientList / DoctorList / AppointmentList / FollowUpTaskList - ConsultationList / AlertList / ArticleManageList - PointsRuleList / PointsProductList / PointsOrderList 统一使用: - PageContainer 组件(标题/筛选/操作/暗色模式) - usePaginatedData hook(分页/筛选/搜索) - EntityName 组件(UUID→姓名兜底) - 共享 formatDateTime/formatDate/formatRelative - 移除手动 isDark 暗色模式处理
This commit is contained in:
@@ -1,16 +1,18 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Table,
|
||||
Select,
|
||||
Button,
|
||||
Input,
|
||||
Tag,
|
||||
Space,
|
||||
Popconfirm,
|
||||
DatePicker,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { CheckOutlined, StopOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
||||
import { dayjs } from '../../utils/dayjs';
|
||||
import {
|
||||
listAlerts,
|
||||
acknowledgeAlert,
|
||||
@@ -18,7 +20,10 @@ import {
|
||||
type Alert,
|
||||
} from '../../api/health/alerts';
|
||||
import { AuthButton } from '../../components/AuthButton';
|
||||
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||
import { PageContainer } from '../../components/PageContainer';
|
||||
import { EntityName } from '../../components/EntityName';
|
||||
import { usePaginatedData } from '../../hooks/usePaginatedData';
|
||||
import { formatRelative, formatDateTime } from '../../utils/format';
|
||||
|
||||
// --- 常量映射 ---
|
||||
|
||||
@@ -29,6 +34,13 @@ const STATUS_OPTIONS = [
|
||||
{ value: 'dismissed', label: '已忽略' },
|
||||
];
|
||||
|
||||
const SEVERITY_OPTIONS = [
|
||||
{ value: 'info', label: '提示' },
|
||||
{ value: 'warning', label: '警告' },
|
||||
{ value: 'critical', label: '严重' },
|
||||
{ value: 'urgent', label: '紧急' },
|
||||
];
|
||||
|
||||
const SEVERITY_COLOR: Record<string, string> = {
|
||||
info: 'default',
|
||||
warning: 'orange',
|
||||
@@ -57,75 +69,76 @@ const STATUS_LABEL: Record<string, string> = {
|
||||
dismissed: '已忽略',
|
||||
};
|
||||
|
||||
// --- 辅助函数 ---
|
||||
// --- 筛选器结构 ---
|
||||
|
||||
/** 截取 ID 前 8 位用于展示 */
|
||||
function shortId(id: string): string {
|
||||
return id.length > 8 ? id.slice(0, 8) : id;
|
||||
interface AlertFilters {
|
||||
search: string;
|
||||
status: string;
|
||||
severity: string;
|
||||
dateRange: [string, string] | null;
|
||||
}
|
||||
|
||||
const DEFAULT_FILTERS: AlertFilters = {
|
||||
search: '',
|
||||
status: '',
|
||||
severity: '',
|
||||
dateRange: null,
|
||||
};
|
||||
|
||||
// --- 辅助函数 ---
|
||||
|
||||
/** 从 detail 中提取规则名称 */
|
||||
function extractRuleName(detail: Record<string, unknown> | undefined): string {
|
||||
function extractRuleName(
|
||||
detail: Record<string, unknown> | undefined,
|
||||
): string {
|
||||
if (!detail) return '-';
|
||||
const ruleName = detail.rule_name;
|
||||
return typeof ruleName === 'string' && ruleName ? ruleName : '-';
|
||||
}
|
||||
|
||||
/** 格式化为相对时间 */
|
||||
function relativeTimeStr(value: string): string {
|
||||
return dayjs(value).fromNow();
|
||||
}
|
||||
|
||||
export default function AlertList() {
|
||||
const isDark = useThemeMode();
|
||||
|
||||
const [data, setData] = useState<Alert[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState<{
|
||||
page: number;
|
||||
page_size: number;
|
||||
status?: string;
|
||||
}>({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
});
|
||||
|
||||
// ---- 数据获取 ----
|
||||
|
||||
const fetchData = useCallback(
|
||||
async (params: { page: number; page_size: number; status?: string }) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listAlerts(params);
|
||||
setData(result.data);
|
||||
setTotal(result.total);
|
||||
} catch {
|
||||
message.error('加载告警列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// ---- 分页数据 Hook ----
|
||||
const {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
loading,
|
||||
filters,
|
||||
setFilters,
|
||||
refresh,
|
||||
} = usePaginatedData<Alert, AlertFilters>(
|
||||
async (p, pageSize, f) => {
|
||||
const result = await listAlerts({
|
||||
page: p,
|
||||
page_size: pageSize,
|
||||
status: f.status || undefined,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
[],
|
||||
{ pageSize: 20, defaultFilters: { ...DEFAULT_FILTERS } },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(query);
|
||||
}, [query, fetchData]);
|
||||
// ---- 筛选回调 ----
|
||||
|
||||
// ---- 筛选与分页 ----
|
||||
const handleFilterChange = useCallback(
|
||||
(key: keyof AlertFilters, value: string | [string, string] | null) => {
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
refresh(1);
|
||||
},
|
||||
[setFilters, refresh],
|
||||
);
|
||||
|
||||
const handleFilterChange = (value: string | undefined) => {
|
||||
setQuery((prev) => ({ ...prev, status: value || undefined, page: 1 }));
|
||||
};
|
||||
const handleResetFilters = useCallback(() => {
|
||||
setFilters({ ...DEFAULT_FILTERS });
|
||||
refresh(1);
|
||||
}, [setFilters, refresh]);
|
||||
|
||||
// ---- 分页 ----
|
||||
|
||||
const handleTableChange = (pagination: TablePaginationConfig) => {
|
||||
setQuery((prev) => ({
|
||||
...prev,
|
||||
page: pagination.current ?? 1,
|
||||
page_size: pagination.pageSize ?? 20,
|
||||
}));
|
||||
refresh(pagination.current ?? 1);
|
||||
};
|
||||
|
||||
// ---- 操作 ----
|
||||
@@ -135,7 +148,7 @@ export default function AlertList() {
|
||||
try {
|
||||
await acknowledgeAlert(record.id, record.version);
|
||||
message.success('告警已确认');
|
||||
fetchData(query);
|
||||
refresh();
|
||||
} catch {
|
||||
message.error('确认告警失败');
|
||||
} finally {
|
||||
@@ -148,7 +161,7 @@ export default function AlertList() {
|
||||
try {
|
||||
await dismissAlert(record.id, record.version);
|
||||
message.success('告警已忽略');
|
||||
fetchData(query);
|
||||
refresh();
|
||||
} catch {
|
||||
message.error('忽略告警失败');
|
||||
} finally {
|
||||
@@ -160,14 +173,18 @@ export default function AlertList() {
|
||||
|
||||
const columns: ColumnsType<Alert> = [
|
||||
{
|
||||
title: '患者ID',
|
||||
title: '患者',
|
||||
dataIndex: 'patient_id',
|
||||
key: 'patient_id',
|
||||
width: 110,
|
||||
width: 140,
|
||||
render: (id: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: 13 }}>
|
||||
{shortId(id)}
|
||||
</span>
|
||||
<Link to={`/health/patients/${id}`}>
|
||||
<EntityName
|
||||
name={undefined}
|
||||
id={id}
|
||||
fallbackLabel={id.length > 8 ? id.slice(0, 8) + '...' : id}
|
||||
/>
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -212,11 +229,8 @@ export default function AlertList() {
|
||||
key: 'created_at',
|
||||
width: 140,
|
||||
render: (val: string) => (
|
||||
<span
|
||||
title={dayjs(val).format('YYYY-MM-DD HH:mm:ss')}
|
||||
style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}
|
||||
>
|
||||
{relativeTimeStr(val)}
|
||||
<span title={formatDateTime(val)} style={{ fontSize: 13 }}>
|
||||
{formatRelative(val)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@@ -244,7 +258,8 @@ export default function AlertList() {
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{(record.status === 'pending' || record.status === 'acknowledged') && (
|
||||
{(record.status === 'pending' ||
|
||||
record.status === 'acknowledged') && (
|
||||
<Popconfirm
|
||||
title="确认忽略该告警?"
|
||||
onConfirm={() => handleDismiss(record)}
|
||||
@@ -269,64 +284,67 @@ export default function AlertList() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 筛选栏 */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
padding: 12,
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
<PageContainer
|
||||
title="告警列表"
|
||||
subtitle="查看和管理患者健康告警"
|
||||
filters={
|
||||
<>
|
||||
<Input
|
||||
placeholder="搜索告警..."
|
||||
value={filters.search}
|
||||
onChange={(e) => handleFilterChange('search', e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="状态筛选"
|
||||
style={{ width: 140 }}
|
||||
options={STATUS_OPTIONS}
|
||||
value={filters.status || undefined}
|
||||
onChange={(v) => handleFilterChange('status', v ?? '')}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="严重程度"
|
||||
style={{ width: 120 }}
|
||||
options={SEVERITY_OPTIONS}
|
||||
value={filters.severity || undefined}
|
||||
onChange={(v) => handleFilterChange('severity', v ?? '')}
|
||||
/>
|
||||
<DatePicker.RangePicker
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
onChange={(dates) => {
|
||||
if (dates && dates[0] && dates[1]) {
|
||||
handleFilterChange('dateRange', [
|
||||
dates[0].format('YYYY-MM-DD'),
|
||||
dates[1].format('YYYY-MM-DD'),
|
||||
]);
|
||||
} else {
|
||||
handleFilterChange('dateRange', null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
onResetFilters={handleResetFilters}
|
||||
loading={loading}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: 20,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="状态筛选"
|
||||
style={{ width: 160 }}
|
||||
options={STATUS_OPTIONS}
|
||||
value={query.status}
|
||||
onChange={handleFilterChange}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: isDark ? '#475569' : '#94a3b8',
|
||||
marginLeft: 'auto',
|
||||
}}
|
||||
>
|
||||
共 {total} 条
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 数据表格 */}
|
||||
<div
|
||||
style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
current: query.page,
|
||||
pageSize: query.page_size,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
scroll={{ x: 970 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
scroll={{ x: 970 }}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Input,
|
||||
Dropdown,
|
||||
message,
|
||||
Card,
|
||||
Row,
|
||||
Alert,
|
||||
Col,
|
||||
@@ -26,6 +25,10 @@ import { StatusTag } from './components/StatusTag';
|
||||
import { PatientSelect } from './components/PatientSelect';
|
||||
import { DoctorSelect } from './components/DoctorSelect';
|
||||
import { AuthButton } from '../../components/AuthButton';
|
||||
import { PageContainer } from '../../components/PageContainer';
|
||||
import { EntityName } from '../../components/EntityName';
|
||||
import { formatDateTime } from '../../utils/format';
|
||||
import { usePaginatedData } from '../../hooks/usePaginatedData';
|
||||
|
||||
/** 预约类型选项 */
|
||||
const APPOINTMENT_TYPE_OPTIONS = [
|
||||
@@ -71,14 +74,15 @@ const STATUS_TRANSITIONS: Record<string, { value: string; label: string }[]> = {
|
||||
],
|
||||
};
|
||||
|
||||
/** 筛选器类型 */
|
||||
interface AppointmentFilters {
|
||||
status: string | undefined;
|
||||
dateRange: [Dayjs | null, Dayjs | null] | null;
|
||||
patientSearch: string;
|
||||
appointmentType: string | undefined;
|
||||
}
|
||||
|
||||
export default function AppointmentList() {
|
||||
const [data, setData] = useState<Appointment[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined);
|
||||
const [dateFilter, setDateFilter] = useState<Dayjs | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
@@ -91,27 +95,56 @@ export default function AppointmentList() {
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||
|
||||
// ---- 数据获取 ----
|
||||
const fetchData = useCallback(async (p = page, ps = pageSize) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await appointmentApi.list({
|
||||
page: p,
|
||||
page_size: ps,
|
||||
status: statusFilter || undefined,
|
||||
date: dateFilter ? dateFilter.format('YYYY-MM-DD') : undefined,
|
||||
const fetcher = useCallback(
|
||||
async (page: number, pageSize: number, filters: AppointmentFilters) => {
|
||||
const dateStart = filters.dateRange?.[0]?.format('YYYY-MM-DD');
|
||||
const dateEnd = filters.dateRange?.[1]?.format('YYYY-MM-DD');
|
||||
return appointmentApi.list({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
status: filters.status || undefined,
|
||||
date: dateStart === dateEnd ? dateStart : undefined,
|
||||
patient_id: undefined, // 后端暂不支持 patientSearch 文本搜索
|
||||
});
|
||||
setData(result.data);
|
||||
setTotal(result.total);
|
||||
} catch {
|
||||
message.error('加载预约列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, pageSize, statusFilter, dateFilter]);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
const {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
loading,
|
||||
filters,
|
||||
setFilters,
|
||||
refresh,
|
||||
} = usePaginatedData<Appointment, AppointmentFilters>(fetcher, {
|
||||
pageSize: 20,
|
||||
defaultFilters: {
|
||||
status: undefined,
|
||||
dateRange: null,
|
||||
patientSearch: '',
|
||||
appointmentType: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
(key: keyof AppointmentFilters, value: unknown) => {
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
refresh(1);
|
||||
},
|
||||
[setFilters, refresh],
|
||||
);
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
setFilters({
|
||||
status: undefined,
|
||||
dateRange: null,
|
||||
patientSearch: '',
|
||||
appointmentType: undefined,
|
||||
});
|
||||
refresh(1);
|
||||
}, [setFilters, refresh]);
|
||||
|
||||
// ---- 状态变更 ----
|
||||
const DESTRUCTIVE_STATUSES = new Set(['cancelled', 'no_show']);
|
||||
@@ -143,7 +176,7 @@ export default function AppointmentList() {
|
||||
...(newStatus === 'cancelled' && { cancel_reason: cancelReason }),
|
||||
});
|
||||
message.success('状态更新成功');
|
||||
fetchData(page, pageSize);
|
||||
refresh();
|
||||
} catch {
|
||||
message.error('状态更新失败');
|
||||
}
|
||||
@@ -162,7 +195,7 @@ export default function AppointmentList() {
|
||||
version: record.version,
|
||||
});
|
||||
message.success('状态更新成功');
|
||||
fetchData(page, pageSize);
|
||||
refresh();
|
||||
} catch {
|
||||
message.error('状态更新失败');
|
||||
}
|
||||
@@ -237,7 +270,7 @@ export default function AppointmentList() {
|
||||
form.resetFields();
|
||||
setSelectedPatientId(undefined);
|
||||
setSelectedDoctorId(undefined);
|
||||
fetchData(page, pageSize);
|
||||
refresh();
|
||||
} catch {
|
||||
message.error('创建预约失败');
|
||||
}
|
||||
@@ -250,16 +283,18 @@ export default function AppointmentList() {
|
||||
dataIndex: 'patient_name',
|
||||
key: 'patient_name',
|
||||
width: 100,
|
||||
render: (_: unknown, record: Appointment) =>
|
||||
record.patient_name ?? record.patient_id.slice(0, 8),
|
||||
render: (_: unknown, record: Appointment) => (
|
||||
<EntityName name={record.patient_name} id={record.patient_id} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '医护',
|
||||
dataIndex: 'doctor_name',
|
||||
key: 'doctor_name',
|
||||
width: 100,
|
||||
render: (_: unknown, record: Appointment) =>
|
||||
record.doctor_name ?? record.doctor_id?.slice(0, 8) ?? '-',
|
||||
render: (_: unknown, record: Appointment) => (
|
||||
<EntityName name={record.doctor_name} id={record.doctor_id} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '预约类型',
|
||||
@@ -291,6 +326,13 @@ export default function AppointmentList() {
|
||||
width: 100,
|
||||
render: (val: string) => <StatusTag status={val} />,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 180,
|
||||
render: (val: string) => formatDateTime(val),
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'notes',
|
||||
@@ -331,59 +373,62 @@ export default function AppointmentList() {
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{/* 筛选栏 */}
|
||||
<Row gutter={16} style={{ marginBottom: 16 }} align="middle">
|
||||
<Col flex="auto">
|
||||
<Space>
|
||||
<Select
|
||||
placeholder="筛选状态"
|
||||
value={statusFilter}
|
||||
onChange={(val) => {
|
||||
setStatusFilter(val);
|
||||
setPage(1);
|
||||
}}
|
||||
options={STATUS_OPTIONS}
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
<DatePicker
|
||||
placeholder="筛选日期"
|
||||
value={dateFilter}
|
||||
onChange={(val) => {
|
||||
setDateFilter(val);
|
||||
setPage(1);
|
||||
}}
|
||||
allowClear
|
||||
/>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<AuthButton code="health.appointment.manage">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新建预约
|
||||
</Button>
|
||||
</AuthButton>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 数据表格 */}
|
||||
<PageContainer
|
||||
title="预约管理"
|
||||
filters={
|
||||
<Space wrap>
|
||||
<Select
|
||||
placeholder="筛选状态"
|
||||
value={filters.status}
|
||||
onChange={(val) => handleFilterChange('status', val)}
|
||||
options={STATUS_OPTIONS}
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
<DatePicker.RangePicker
|
||||
value={filters.dateRange as [Dayjs, Dayjs] | null}
|
||||
onChange={(dates) => handleFilterChange('dateRange', dates)}
|
||||
allowClear
|
||||
/>
|
||||
<Input
|
||||
placeholder="搜索患者"
|
||||
value={filters.patientSearch}
|
||||
onChange={(e) => handleFilterChange('patientSearch', e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="预约类型"
|
||||
value={filters.appointmentType}
|
||||
onChange={(val) => handleFilterChange('appointmentType', val)}
|
||||
options={APPOINTMENT_TYPE_OPTIONS}
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
onResetFilters={resetFilters}
|
||||
actions={
|
||||
<AuthButton code="health.appointment.manage">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新建预约
|
||||
</Button>
|
||||
</AuthButton>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
scroll={{ x: 1000 }}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
pageSize: 20,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
onChange: (p) => refresh(p),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -466,6 +511,6 @@ export default function AppointmentList() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Table,
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
SendOutlined,
|
||||
@@ -31,8 +30,12 @@ import {
|
||||
type ArticleStatus,
|
||||
type ArticleTagItem,
|
||||
} from '../../api/health/articles';
|
||||
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||
import { AuthButton } from '../../components/AuthButton';
|
||||
import { PageContainer } from '../../components/PageContainer';
|
||||
import { usePaginatedData } from '../../hooks/usePaginatedData';
|
||||
import { formatDateTime } from '../../utils/format';
|
||||
|
||||
// --- 常量 ---
|
||||
|
||||
const STATUS_TABS: { key: string; label: string }[] = [
|
||||
{ key: '', label: '全部' },
|
||||
@@ -42,84 +45,71 @@ const STATUS_TABS: { key: string; label: string }[] = [
|
||||
{ key: 'rejected', label: '已拒绝' },
|
||||
];
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
string,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
draft: { label: '草稿', color: 'default' },
|
||||
pending_review: { label: '待审核', color: 'processing' },
|
||||
published: { label: '已发布', color: 'success' },
|
||||
rejected: { label: '已拒绝', color: 'error' },
|
||||
};
|
||||
|
||||
// --- 筛选器 ---
|
||||
|
||||
interface ArticleFilters {
|
||||
keyword: string;
|
||||
status: string;
|
||||
category_id: string;
|
||||
}
|
||||
|
||||
const DEFAULT_FILTERS: ArticleFilters = {
|
||||
keyword: '',
|
||||
status: '',
|
||||
category_id: '',
|
||||
};
|
||||
|
||||
export default function ArticleManageList() {
|
||||
const [articles, setArticles] = useState<ArticleListItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [statusTab, setStatusTab] = useState('');
|
||||
const [categoryId, setCategoryId] = useState<string | undefined>(undefined);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const navigate = useNavigate();
|
||||
const [categories, setCategories] = useState<{ id: string; name: string }[]>([]);
|
||||
const [rejectModalOpen, setRejectModalOpen] = useState(false);
|
||||
const [rejectingArticle, setRejectingArticle] = useState<ArticleListItem | null>(null);
|
||||
const [rejectForm] = Form.useForm();
|
||||
const isDark = useThemeMode();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const fetchArticles = useCallback(
|
||||
async (p = page) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await articleApi.list({
|
||||
page: p,
|
||||
page_size: 20,
|
||||
status: (statusTab || undefined) as ArticleStatus | undefined,
|
||||
category_id: categoryId,
|
||||
keyword: keyword || undefined,
|
||||
});
|
||||
setArticles(result.data);
|
||||
setTotal(result.total);
|
||||
} catch {
|
||||
message.error('加载文章列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// ---- 分页数据 Hook ----
|
||||
const {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
loading,
|
||||
filters,
|
||||
setFilters,
|
||||
refresh,
|
||||
} = usePaginatedData<ArticleListItem, ArticleFilters>(
|
||||
async (p, pageSize, f) => {
|
||||
const result = await articleApi.list({
|
||||
page: p,
|
||||
page_size: pageSize,
|
||||
status: (f.status || undefined) as ArticleStatus | undefined,
|
||||
category_id: f.category_id || undefined,
|
||||
keyword: f.keyword || undefined,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
[page, statusTab, categoryId, keyword],
|
||||
{ pageSize: 20, defaultFilters: { ...DEFAULT_FILTERS } },
|
||||
);
|
||||
|
||||
const fetchCategories = useCallback(async () => {
|
||||
try {
|
||||
const cats = await articleCategoryApi.list();
|
||||
setCategories(cats.map((c) => ({ id: c.id, name: c.name })));
|
||||
} catch {
|
||||
// 分类列表加载失败不阻塞页面
|
||||
}
|
||||
// ---- 分类列表 ----
|
||||
useEffect(() => {
|
||||
articleCategoryApi.list()
|
||||
.then((cats) => setCategories(cats.map((c) => ({ id: c.id, name: c.name }))))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchArticles();
|
||||
}, [fetchArticles]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories();
|
||||
}, [fetchCategories]);
|
||||
|
||||
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const debouncedSearch = useCallback((value: string) => {
|
||||
setKeyword(value);
|
||||
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
setPage(1);
|
||||
}, 300);
|
||||
}, []);
|
||||
// ---- 操作 ----
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await articleApi.delete(id);
|
||||
message.success('文章已删除');
|
||||
fetchArticles();
|
||||
refresh();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
@@ -129,7 +119,7 @@ export default function ArticleManageList() {
|
||||
try {
|
||||
await articleApi.submit(record.id, record.version);
|
||||
message.success('已提交审核');
|
||||
fetchArticles();
|
||||
refresh();
|
||||
} catch {
|
||||
message.error('提交审核失败');
|
||||
}
|
||||
@@ -139,7 +129,7 @@ export default function ArticleManageList() {
|
||||
try {
|
||||
await articleApi.approve(record.id, record.version);
|
||||
message.success('审核通过,文章已发布');
|
||||
fetchArticles();
|
||||
refresh();
|
||||
} catch {
|
||||
message.error('审核操作失败');
|
||||
}
|
||||
@@ -154,14 +144,10 @@ export default function ArticleManageList() {
|
||||
const handleReject = async (values: { review_note: string }) => {
|
||||
if (!rejectingArticle) return;
|
||||
try {
|
||||
await articleApi.reject(
|
||||
rejectingArticle.id,
|
||||
rejectingArticle.version,
|
||||
values.review_note,
|
||||
);
|
||||
await articleApi.reject(rejectingArticle.id, rejectingArticle.version, values.review_note);
|
||||
message.success('已拒绝文章');
|
||||
setRejectModalOpen(false);
|
||||
fetchArticles();
|
||||
refresh();
|
||||
} catch {
|
||||
message.error('拒绝操作失败');
|
||||
}
|
||||
@@ -171,7 +157,7 @@ export default function ArticleManageList() {
|
||||
try {
|
||||
await articleApi.unpublish(record.id, record.version);
|
||||
message.success('文章已撤回为草稿');
|
||||
fetchArticles();
|
||||
refresh();
|
||||
} catch {
|
||||
message.error('撤回操作失败');
|
||||
}
|
||||
@@ -254,6 +240,8 @@ export default function ArticleManageList() {
|
||||
</Space>
|
||||
);
|
||||
|
||||
// ---- 列定义 ----
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '标题',
|
||||
@@ -284,7 +272,7 @@ export default function ArticleManageList() {
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: isDark ? '#64748b' : '#94a3b8',
|
||||
color: 'var(--ant-color-text-secondary, #94a3b8)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
@@ -303,7 +291,7 @@ export default function ArticleManageList() {
|
||||
dataIndex: 'category_name',
|
||||
key: 'category_name',
|
||||
width: 120,
|
||||
render: (v?: string) => v || <span style={{ color: isDark ? '#475569' : '#cbd5e1' }}>未分类</span>,
|
||||
render: (v?: string) => v || <span style={{ color: 'var(--ant-color-text-quaternary, #cbd5e1)' }}>未分类</span>,
|
||||
},
|
||||
{
|
||||
title: '标签',
|
||||
@@ -311,23 +299,11 @@ export default function ArticleManageList() {
|
||||
key: 'tags',
|
||||
width: 180,
|
||||
render: (tags?: ArticleTagItem[]) => {
|
||||
if (!tags || tags.length === 0) {
|
||||
return <span style={{ color: isDark ? '#475569' : '#cbd5e1' }}>-</span>;
|
||||
}
|
||||
if (!tags || tags.length === 0) return '-';
|
||||
return (
|
||||
<Space size={4} wrap>
|
||||
{tags.map((t) => (
|
||||
<Tag
|
||||
key={t.id}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
background: isDark ? '#0f172a' : '#f0f9ff',
|
||||
border: `1px solid ${isDark ? '#1e3a5f' : '#bae6fd'}`,
|
||||
color: isDark ? '#7dd3fc' : '#0369a1',
|
||||
}}
|
||||
>
|
||||
{t.name}
|
||||
</Tag>
|
||||
<Tag key={t.id}>{t.name}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
@@ -357,7 +333,7 @@ export default function ArticleManageList() {
|
||||
width: 80,
|
||||
render: (v: number) => (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<EyeOutlined style={{ fontSize: 12, color: isDark ? '#64748b' : '#94a3b8' }} />
|
||||
<EyeOutlined style={{ fontSize: 12 }} />
|
||||
{v ?? 0}
|
||||
</span>
|
||||
),
|
||||
@@ -367,7 +343,7 @@ export default function ArticleManageList() {
|
||||
dataIndex: 'published_at',
|
||||
key: 'published_at',
|
||||
width: 170,
|
||||
render: (v: string) => (v ? new Date(v).toLocaleString('zh-CN') : '-'),
|
||||
render: (v: string) => (v ? formatDateTime(v) : '-'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
@@ -378,13 +354,38 @@ export default function ArticleManageList() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 页面标题和工具栏 */}
|
||||
<div className="erp-page-header">
|
||||
<div>
|
||||
<h4>内容管理</h4>
|
||||
<div className="erp-page-subtitle">管理健康科普文章、资讯和内容发布</div>
|
||||
</div>
|
||||
<PageContainer
|
||||
title="内容管理"
|
||||
subtitle="管理健康科普文章、资讯和内容发布"
|
||||
filters={
|
||||
<>
|
||||
<Input
|
||||
placeholder="搜索文章标题..."
|
||||
value={filters.keyword}
|
||||
onChange={(e) => {
|
||||
setFilters((prev) => ({ ...prev, keyword: e.target.value }));
|
||||
}}
|
||||
allowClear
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
<Select
|
||||
value={filters.category_id || undefined}
|
||||
onChange={(v) => {
|
||||
setFilters((prev) => ({ ...prev, category_id: v ?? '' }));
|
||||
refresh(1);
|
||||
}}
|
||||
placeholder="选择分类"
|
||||
allowClear
|
||||
style={{ width: 160 }}
|
||||
options={categories.map((c) => ({ label: c.name, value: c.id }))}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
onResetFilters={() => {
|
||||
setFilters({ ...DEFAULT_FILTERS });
|
||||
refresh(1);
|
||||
}}
|
||||
actions={
|
||||
<AuthButton code="health.articles.manage">
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -394,79 +395,31 @@ export default function ArticleManageList() {
|
||||
新建文章
|
||||
</Button>
|
||||
</AuthButton>
|
||||
</div>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<div
|
||||
style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
padding: '12px 16px',
|
||||
marginBottom: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
}
|
||||
loading={loading}
|
||||
>
|
||||
<Tabs
|
||||
activeKey={filters.status}
|
||||
onChange={(key) => {
|
||||
setFilters((prev) => ({ ...prev, status: key }));
|
||||
refresh(1);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
placeholder="搜索文章标题..."
|
||||
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
|
||||
value={keyword}
|
||||
onChange={(e) => debouncedSearch(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 220, borderRadius: 8 }}
|
||||
/>
|
||||
<Select
|
||||
value={categoryId}
|
||||
onChange={(v) => {
|
||||
setCategoryId(v);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="选择分类"
|
||||
allowClear
|
||||
style={{ width: 160, borderRadius: 8 }}
|
||||
options={categories.map((c) => ({ label: c.name, value: c.id }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 状态标签页 + 表格 */}
|
||||
<div
|
||||
style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
overflow: 'hidden',
|
||||
items={STATUS_TABS.map((tab) => ({ key: tab.key, label: tab.label }))}
|
||||
style={{ marginBottom: 0 }}
|
||||
/>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
onChange={(pagination) => refresh(pagination.current ?? 1)}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: 20,
|
||||
total,
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
activeKey={statusTab}
|
||||
onChange={(key) => {
|
||||
setStatusTab(key);
|
||||
setPage(1);
|
||||
}}
|
||||
items={STATUS_TABS.map((tab) => ({ key: tab.key, label: tab.label }))}
|
||||
style={{ padding: '0 16px', marginBottom: 0 }}
|
||||
/>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={articles}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 20,
|
||||
onChange: (p) => {
|
||||
setPage(p);
|
||||
fetchArticles(p);
|
||||
},
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
style: { padding: '12px 16px', margin: 0 },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
/>
|
||||
|
||||
{/* 拒绝理由弹窗 */}
|
||||
<Modal
|
||||
@@ -493,6 +446,6 @@ export default function ArticleManageList() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Select,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Space,
|
||||
Popconfirm,
|
||||
message,
|
||||
DatePicker,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
||||
@@ -17,8 +18,11 @@ import { StatusTag } from './components/StatusTag';
|
||||
import { PatientSelect } from './components/PatientSelect';
|
||||
import { DoctorSelect } from './components/DoctorSelect';
|
||||
import { ExportButton } from './components/ExportButton';
|
||||
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||
import { AuthButton } from '../../components/AuthButton';
|
||||
import { PageContainer } from '../../components/PageContainer';
|
||||
import { EntityName } from '../../components/EntityName';
|
||||
import { formatDateTime } from '../../utils/format';
|
||||
import { usePaginatedData } from '../../hooks/usePaginatedData';
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'waiting', label: '等待中' },
|
||||
@@ -38,66 +42,52 @@ const CONSULTATION_TYPE_MAP: Record<string, string> = {
|
||||
health_consultation: '健康咨询',
|
||||
};
|
||||
|
||||
function formatDateTime(value: string | undefined): string {
|
||||
if (!value) return '-';
|
||||
return new Date(value).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
interface ConsultationFilters {
|
||||
status?: string;
|
||||
dateRange?: [string, string];
|
||||
}
|
||||
|
||||
export default function ConsultationList() {
|
||||
const navigate = useNavigate();
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [query, setQuery] = useState<{ page: number; page_size: number; status?: string }>({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
});
|
||||
|
||||
// Close session
|
||||
const [closingId, setClosingId] = useState<string | null>(null);
|
||||
|
||||
// Create modal
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
const [createForm] = Form.useForm<CreateSessionReq>();
|
||||
|
||||
// Close session
|
||||
const [closingId, setClosingId] = useState<string | null>(null);
|
||||
// --- Paginated data with usePaginatedData ---
|
||||
const fetchFn = useCallback(
|
||||
async (page: number, pageSize: number, filters: ConsultationFilters) => {
|
||||
const params: Record<string, unknown> = { page, page_size: pageSize };
|
||||
if (filters.status) params.status = filters.status;
|
||||
if (filters.dateRange) {
|
||||
params.created_start = filters.dateRange[0];
|
||||
params.created_end = filters.dateRange[1];
|
||||
}
|
||||
return consultationApi.listSessions(params as Parameters<typeof consultationApi.listSessions>[0]);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const isDark = useThemeMode();
|
||||
|
||||
// --- Data fetching ---
|
||||
const fetchSessions = useCallback(async (params: { page: number; page_size: number; status?: string }) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await consultationApi.listSessions(params);
|
||||
setSessions(result.data);
|
||||
setTotal(result.total);
|
||||
} catch {
|
||||
message.error('加载咨询列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSessions(query);
|
||||
}, [query, fetchSessions]);
|
||||
const {
|
||||
data: sessions,
|
||||
total,
|
||||
page,
|
||||
loading,
|
||||
filters,
|
||||
setFilters,
|
||||
refresh,
|
||||
} = usePaginatedData<Session, ConsultationFilters>(fetchFn, {
|
||||
pageSize: 20,
|
||||
defaultFilters: {},
|
||||
});
|
||||
|
||||
// --- Handlers ---
|
||||
const handleFilterChange = (value: string | undefined) => {
|
||||
setQuery((prev) => ({ ...prev, status: value || undefined, page: 1 }));
|
||||
};
|
||||
|
||||
const handleTableChange = (pagination: TablePaginationConfig) => {
|
||||
setQuery((prev) => ({
|
||||
...prev,
|
||||
page: pagination.current ?? 1,
|
||||
page_size: pagination.pageSize ?? 20,
|
||||
}));
|
||||
refresh(pagination.current ?? 1);
|
||||
};
|
||||
|
||||
// Create session
|
||||
@@ -109,7 +99,7 @@ export default function ConsultationList() {
|
||||
message.success('咨询会话创建成功');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
fetchSessions(query);
|
||||
refresh(page);
|
||||
} catch (err: unknown) {
|
||||
if (err && typeof err === 'object' && 'errorFields' in err) return;
|
||||
message.error('创建咨询会话失败');
|
||||
@@ -124,7 +114,7 @@ export default function ConsultationList() {
|
||||
try {
|
||||
await consultationApi.closeSession(session.id, { version: session.version });
|
||||
message.success('会话已关闭');
|
||||
fetchSessions(query);
|
||||
refresh(page);
|
||||
} catch {
|
||||
message.error('关闭会话失败');
|
||||
} finally {
|
||||
@@ -139,7 +129,7 @@ export default function ConsultationList() {
|
||||
|
||||
// Export params
|
||||
const exportParams: Record<string, string> = {};
|
||||
if (query.status) exportParams.status = query.status;
|
||||
if (filters.status) exportParams.status = filters.status;
|
||||
|
||||
// --- Columns ---
|
||||
const columns: ColumnsType<Session> = [
|
||||
@@ -148,16 +138,18 @@ export default function ConsultationList() {
|
||||
dataIndex: 'patient_name',
|
||||
key: 'patient_name',
|
||||
width: 140,
|
||||
render: (_: unknown, record: Session) =>
|
||||
record.patient_name ?? record.patient_id.slice(0, 8),
|
||||
render: (_: unknown, record: Session) => (
|
||||
<EntityName name={record.patient_name} id={record.patient_id} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '医护',
|
||||
dataIndex: 'doctor_name',
|
||||
key: 'doctor_name',
|
||||
width: 140,
|
||||
render: (_: unknown, record: Session) =>
|
||||
record.doctor_name ?? record.doctor_id?.slice(0, 8) ?? '-',
|
||||
render: (_: unknown, record: Session) => (
|
||||
<EntityName name={record.doctor_name} id={record.doctor_id} fallbackLabel="未分配" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '咨询类型',
|
||||
@@ -188,22 +180,14 @@ export default function ConsultationList() {
|
||||
dataIndex: 'last_message_at',
|
||||
key: 'last_message_at',
|
||||
width: 160,
|
||||
render: (v: string | undefined) => (
|
||||
<span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>
|
||||
{formatDateTime(v)}
|
||||
</span>
|
||||
),
|
||||
render: (v: string | undefined) => formatDateTime(v),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 160,
|
||||
render: (v: string) => (
|
||||
<span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>
|
||||
{formatDateTime(v)}
|
||||
</span>
|
||||
),
|
||||
render: (v: string) => formatDateTime(v),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
@@ -237,85 +221,76 @@ export default function ConsultationList() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Toolbar */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
padding: 12,
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="状态筛选"
|
||||
style={{ width: 160 }}
|
||||
options={STATUS_OPTIONS}
|
||||
value={query.status}
|
||||
onChange={handleFilterChange}
|
||||
/>
|
||||
<AuthButton code="health.consultation.manage">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
createForm.resetFields();
|
||||
setCreateOpen(true);
|
||||
<PageContainer
|
||||
title="咨询管理"
|
||||
subtitle={`共 ${total} 条`}
|
||||
filters={
|
||||
<>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="状态筛选"
|
||||
style={{ width: 140 }}
|
||||
options={STATUS_OPTIONS}
|
||||
value={filters.status}
|
||||
onChange={(value) => setFilters((prev) => ({ ...prev, status: value }))}
|
||||
/>
|
||||
<DatePicker.RangePicker
|
||||
style={{ width: 240 }}
|
||||
onChange={(dates) => {
|
||||
if (dates && dates[0] && dates[1]) {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
dateRange: [dates[0]!.format('YYYY-MM-DD'), dates[1]!.format('YYYY-MM-DD')],
|
||||
}));
|
||||
} else {
|
||||
setFilters((prev) => ({ ...prev, dateRange: undefined }));
|
||||
}
|
||||
}}
|
||||
>
|
||||
新建会话
|
||||
</Button>
|
||||
</AuthButton>
|
||||
<ExportButton
|
||||
fetchUrl="/health/consultation-sessions/export"
|
||||
params={exportParams}
|
||||
filename="咨询列表.csv"
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: isDark ? '#475569' : '#94a3b8',
|
||||
marginLeft: 'auto',
|
||||
}}
|
||||
>
|
||||
共 {total} 条
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div
|
||||
style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
overflow: 'hidden',
|
||||
/>
|
||||
</>
|
||||
}
|
||||
onResetFilters={() => setFilters({})}
|
||||
actions={
|
||||
<Space>
|
||||
<AuthButton code="health.consultation.manage">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
createForm.resetFields();
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
新建会话
|
||||
</Button>
|
||||
</AuthButton>
|
||||
<ExportButton
|
||||
fetchUrl="/health/consultation-sessions/export"
|
||||
params={exportParams}
|
||||
filename="咨询列表.csv"
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={sessions}
|
||||
loading={loading}
|
||||
onChange={handleTableChange}
|
||||
onRow={(record) => ({
|
||||
onClick: () => handleRowClick(record),
|
||||
style: { cursor: 'pointer' },
|
||||
})}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: 20,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={sessions}
|
||||
loading={loading}
|
||||
onChange={handleTableChange}
|
||||
onRow={(record) => ({
|
||||
onClick: () => handleRowClick(record),
|
||||
style: { cursor: 'pointer' },
|
||||
})}
|
||||
pagination={{
|
||||
current: query.page,
|
||||
pageSize: query.page_size,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
scroll={{ x: 1010 }}
|
||||
/>
|
||||
</div>
|
||||
scroll={{ x: 1010 }}
|
||||
/>
|
||||
|
||||
{/* Create Session Modal */}
|
||||
<Modal
|
||||
@@ -348,6 +323,6 @@ export default function ConsultationList() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
Badge,
|
||||
Popconfirm,
|
||||
message,
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
} from 'antd';
|
||||
@@ -20,9 +19,12 @@ import {
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { dayjs } from '../../utils/dayjs';
|
||||
import { doctorApi, type Doctor, type CreateDoctorReq, type UpdateDoctorReq } from '../../api/health/doctors';
|
||||
import { AuthButton } from '../../components/AuthButton';
|
||||
import { PageContainer } from '../../components/PageContainer';
|
||||
import { EntityName } from '../../components/EntityName';
|
||||
import { formatDateTime } from '../../utils/format';
|
||||
import { usePaginatedData } from '../../hooks/usePaginatedData';
|
||||
|
||||
/** 科室选项 — 可后续改为从字典接口获取 */
|
||||
const DEPARTMENT_OPTIONS = [
|
||||
@@ -51,54 +53,78 @@ const TITLE_OPTIONS = [
|
||||
{ value: '主任护师', label: '主任护师' },
|
||||
];
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'online', label: '在线' },
|
||||
{ value: 'offline', label: '离线' },
|
||||
{ value: 'busy', label: '忙碌' },
|
||||
];
|
||||
|
||||
const ONLINE_STATUS_MAP: Record<string, { status: 'success' | 'default' | 'processing'; text: string }> = {
|
||||
online: { status: 'success', text: '在线' },
|
||||
offline: { status: 'default', text: '离线' },
|
||||
busy: { status: 'processing', text: '忙碌' },
|
||||
};
|
||||
|
||||
/** 筛选器类型 */
|
||||
interface DoctorFilters {
|
||||
search: string;
|
||||
department: string | undefined;
|
||||
title: string | undefined;
|
||||
status: string | undefined;
|
||||
}
|
||||
|
||||
export default function DoctorList() {
|
||||
const [data, setData] = useState<Doctor[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [deptFilter, setDeptFilter] = useState<string | undefined>(undefined);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Doctor | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// ---- 数据获取 ----
|
||||
const fetchData = useCallback(async (p = page, ps = pageSize) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await doctorApi.list({
|
||||
page: p,
|
||||
page_size: ps,
|
||||
search: searchText || undefined,
|
||||
department: deptFilter || undefined,
|
||||
const fetcher = useCallback(
|
||||
async (page: number, pageSize: number, filters: DoctorFilters) => {
|
||||
return doctorApi.list({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
search: filters.search || undefined,
|
||||
department: filters.department || undefined,
|
||||
title: filters.title || undefined,
|
||||
});
|
||||
setData(result.data);
|
||||
setTotal(result.total);
|
||||
} catch {
|
||||
message.error('加载医护列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, pageSize, searchText, deptFilter]);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
const {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
loading,
|
||||
filters,
|
||||
setFilters,
|
||||
refresh,
|
||||
} = usePaginatedData<Doctor, DoctorFilters>(fetcher, {
|
||||
pageSize: 20,
|
||||
defaultFilters: { search: '', department: undefined, title: undefined, status: undefined },
|
||||
});
|
||||
|
||||
// ---- 搜索防抖 ----
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const handleSearchChange = useCallback((val: string) => {
|
||||
setSearchText(val);
|
||||
setFilters((prev) => ({ ...prev, search: val }));
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => setPage(1), 300);
|
||||
}, []);
|
||||
debounceRef.current = setTimeout(() => refresh(1), 300);
|
||||
}, [setFilters, refresh]);
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
(key: keyof DoctorFilters, value: string | undefined) => {
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
refresh(1);
|
||||
},
|
||||
[setFilters, refresh],
|
||||
);
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
setFilters({ search: '', department: undefined, title: undefined, status: undefined });
|
||||
refresh(1);
|
||||
}, [setFilters, refresh]);
|
||||
|
||||
// ---- 新建 / 编辑 ----
|
||||
const openCreate = () => {
|
||||
@@ -155,7 +181,7 @@ export default function DoctorList() {
|
||||
}
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
fetchData(page, pageSize);
|
||||
refresh();
|
||||
} catch {
|
||||
message.error(editing ? '更新失败' : '创建失败');
|
||||
}
|
||||
@@ -166,7 +192,7 @@ export default function DoctorList() {
|
||||
try {
|
||||
await doctorApi.delete(id);
|
||||
message.success('删除成功');
|
||||
fetchData(page, pageSize);
|
||||
refresh();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
@@ -210,6 +236,18 @@ export default function DoctorList() {
|
||||
width: 150,
|
||||
render: (val: string) => val || '-',
|
||||
},
|
||||
{
|
||||
title: '关联用户',
|
||||
dataIndex: 'user_id',
|
||||
key: 'user_id',
|
||||
width: 120,
|
||||
render: (_: unknown, record: Doctor) =>
|
||||
record.user_id ? (
|
||||
<EntityName name={record.name} id={record.user_id} fallbackLabel="已关联" />
|
||||
) : (
|
||||
'-'
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '在线状态',
|
||||
dataIndex: 'online_status',
|
||||
@@ -225,7 +263,7 @@ export default function DoctorList() {
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 180,
|
||||
render: (val: string) => (val ? dayjs(val).format('YYYY-MM-DD HH:mm') : '-'),
|
||||
render: (val: string) => formatDateTime(val),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
@@ -260,58 +298,66 @@ export default function DoctorList() {
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{/* 筛选栏 */}
|
||||
<Row gutter={16} style={{ marginBottom: 16 }} align="middle">
|
||||
<Col flex="auto">
|
||||
<Space>
|
||||
<Input
|
||||
placeholder="搜索姓名"
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="筛选科室"
|
||||
value={deptFilter}
|
||||
onChange={(val) => {
|
||||
setDeptFilter(val);
|
||||
setPage(1);
|
||||
}}
|
||||
options={DEPARTMENT_OPTIONS}
|
||||
allowClear
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<AuthButton code="health.doctor.manage">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新建医护
|
||||
</Button>
|
||||
</AuthButton>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 数据表格 */}
|
||||
<PageContainer
|
||||
title="医护管理"
|
||||
filters={
|
||||
<Space wrap>
|
||||
<Input
|
||||
placeholder="搜索姓名"
|
||||
prefix={<SearchOutlined />}
|
||||
value={filters.search}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="筛选科室"
|
||||
value={filters.department}
|
||||
onChange={(val) => handleFilterChange('department', val)}
|
||||
options={DEPARTMENT_OPTIONS}
|
||||
allowClear
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="筛选职称"
|
||||
value={filters.title}
|
||||
onChange={(val) => handleFilterChange('title', val)}
|
||||
options={TITLE_OPTIONS}
|
||||
allowClear
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="在线状态"
|
||||
value={filters.status}
|
||||
onChange={(val) => handleFilterChange('status', val)}
|
||||
options={STATUS_OPTIONS}
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
onResetFilters={resetFilters}
|
||||
actions={
|
||||
<AuthButton code="health.doctor.manage">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新建医护
|
||||
</Button>
|
||||
</AuthButton>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
scroll={{ x: 1100 }}
|
||||
scroll={{ x: 1300 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
pageSize: 20,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
onChange: (p) => refresh(p),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -358,6 +404,6 @@ export default function DoctorList() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Select,
|
||||
@@ -18,8 +18,11 @@ import { followUpApi, type FollowUpTask, type CreateFollowUpTaskReq, type Update
|
||||
import { StatusTag } from './components/StatusTag';
|
||||
import { PatientSelect } from './components/PatientSelect';
|
||||
import { DoctorSelect } from './components/DoctorSelect';
|
||||
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||
import { AuthButton } from '../../components/AuthButton';
|
||||
import { PageContainer } from '../../components/PageContainer';
|
||||
import { EntityName } from '../../components/EntityName';
|
||||
import { formatDate, formatDateTime } from '../../utils/format';
|
||||
import { usePaginatedData } from '../../hooks/usePaginatedData';
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'pending', label: '待处理' },
|
||||
@@ -45,14 +48,11 @@ const FOLLOW_UP_TYPE_MAP: Record<string, string> = {
|
||||
wechat: '微信',
|
||||
};
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
return new Date(value).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
interface FollowUpFilters {
|
||||
status?: string;
|
||||
dateRange?: [string, string];
|
||||
followUpType?: string;
|
||||
assigneeId?: string;
|
||||
}
|
||||
|
||||
interface RecordFormValues {
|
||||
@@ -68,12 +68,33 @@ interface AssignFormValues {
|
||||
}
|
||||
|
||||
export default function FollowUpTaskList() {
|
||||
const [tasks, setTasks] = useState<FollowUpTask[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [query, setQuery] = useState<{ page: number; page_size: number; status?: string }>({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
// --- Paginated data with usePaginatedData ---
|
||||
const fetchFn = useCallback(
|
||||
async (page: number, pageSize: number, filters: FollowUpFilters) => {
|
||||
const params: Record<string, unknown> = { page, page_size: pageSize };
|
||||
if (filters.status) params.status = filters.status;
|
||||
if (filters.followUpType) params.follow_up_type = filters.followUpType;
|
||||
if (filters.assigneeId) params.assigned_to = filters.assigneeId;
|
||||
if (filters.dateRange) {
|
||||
params.planned_date_start = filters.dateRange[0];
|
||||
params.planned_date_end = filters.dateRange[1];
|
||||
}
|
||||
return followUpApi.listTasks(params as Parameters<typeof followUpApi.listTasks>[0]);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const {
|
||||
data: tasks,
|
||||
total,
|
||||
page,
|
||||
loading,
|
||||
filters,
|
||||
setFilters,
|
||||
refresh,
|
||||
} = usePaginatedData<FollowUpTask, FollowUpFilters>(fetchFn, {
|
||||
pageSize: 20,
|
||||
defaultFilters: {},
|
||||
});
|
||||
|
||||
// Create task modal
|
||||
@@ -93,37 +114,9 @@ export default function FollowUpTaskList() {
|
||||
const [assignForm] = Form.useForm<AssignFormValues>();
|
||||
const [assignTask, setAssignTask] = useState<FollowUpTask | null>(null);
|
||||
|
||||
const isDark = useThemeMode();
|
||||
|
||||
// --- Data fetching ---
|
||||
const fetchTasks = useCallback(async (params: { page: number; page_size: number; status?: string }) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await followUpApi.listTasks(params);
|
||||
setTasks(result.data);
|
||||
setTotal(result.total);
|
||||
} catch {
|
||||
message.error('加载随访任务失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks(query);
|
||||
}, [query, fetchTasks]);
|
||||
|
||||
// --- Handlers ---
|
||||
const handleFilterChange = (field: 'status', value: string | undefined) => {
|
||||
setQuery((prev) => ({ ...prev, [field]: value || undefined, page: 1 }));
|
||||
};
|
||||
|
||||
const handleTableChange = (pagination: TablePaginationConfig) => {
|
||||
setQuery((prev) => ({
|
||||
...prev,
|
||||
page: pagination.current ?? 1,
|
||||
page_size: pagination.pageSize ?? 20,
|
||||
}));
|
||||
refresh(pagination.current ?? 1);
|
||||
};
|
||||
|
||||
// Create task
|
||||
@@ -142,7 +135,7 @@ export default function FollowUpTaskList() {
|
||||
message.success('随访任务创建成功');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
fetchTasks(query);
|
||||
refresh(page);
|
||||
} catch (err: unknown) {
|
||||
if (err && typeof err === 'object' && 'errorFields' in err) return; // form validation
|
||||
message.error('创建随访任务失败');
|
||||
@@ -173,7 +166,7 @@ export default function FollowUpTaskList() {
|
||||
message.success('随访记录填写成功');
|
||||
setRecordOpen(false);
|
||||
setActiveTask(null);
|
||||
fetchTasks(query);
|
||||
refresh(page);
|
||||
} catch (err: unknown) {
|
||||
if (err && typeof err === 'object' && 'errorFields' in err) return;
|
||||
message.error('填写随访记录失败');
|
||||
@@ -205,7 +198,7 @@ export default function FollowUpTaskList() {
|
||||
message.success('分配成功');
|
||||
setAssignOpen(false);
|
||||
setAssignTask(null);
|
||||
fetchTasks(query);
|
||||
refresh(page);
|
||||
} catch (err: unknown) {
|
||||
if (err && typeof err === 'object' && 'errorFields' in err) return;
|
||||
message.error('分配失败');
|
||||
@@ -219,19 +212,12 @@ export default function FollowUpTaskList() {
|
||||
try {
|
||||
await followUpApi.deleteTask(record.id, record.version);
|
||||
message.success('删除成功');
|
||||
fetchTasks(query);
|
||||
refresh(page);
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
// Store labels from selects for immediate display
|
||||
const handleDoctorLabel = (id: string, label: string) => {
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.assigned_to === id ? { ...t, assigned_to_name: label } : t)),
|
||||
);
|
||||
};
|
||||
|
||||
// --- Columns ---
|
||||
const columns: ColumnsType<FollowUpTask> = [
|
||||
{
|
||||
@@ -239,8 +225,9 @@ export default function FollowUpTaskList() {
|
||||
dataIndex: 'patient_name',
|
||||
key: 'patient_name',
|
||||
width: 140,
|
||||
render: (_: unknown, record: FollowUpTask) =>
|
||||
record.patient_name ?? record.patient_id.slice(0, 8),
|
||||
render: (_: unknown, record: FollowUpTask) => (
|
||||
<EntityName name={record.patient_name} id={record.patient_id} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '随访类型',
|
||||
@@ -254,7 +241,7 @@ export default function FollowUpTaskList() {
|
||||
dataIndex: 'planned_date',
|
||||
key: 'planned_date',
|
||||
width: 120,
|
||||
render: (v: string) => v,
|
||||
render: (v: string) => formatDate(v),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
@@ -268,19 +255,20 @@ export default function FollowUpTaskList() {
|
||||
dataIndex: 'assigned_to',
|
||||
key: 'assigned_to',
|
||||
width: 140,
|
||||
render: (_: unknown, record: FollowUpTask) =>
|
||||
record.assigned_to ? record.assigned_to_name || record.assigned_to.slice(0, 8) : '-',
|
||||
render: (_: unknown, record: FollowUpTask) => (
|
||||
<EntityName
|
||||
name={record.assigned_to_name}
|
||||
id={record.assigned_to}
|
||||
fallbackLabel="未分配"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 160,
|
||||
render: (v: string) => (
|
||||
<span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>
|
||||
{formatDateTime(v)}
|
||||
</span>
|
||||
),
|
||||
render: (v: string) => formatDateTime(v),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
@@ -322,28 +310,53 @@ export default function FollowUpTaskList() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Toolbar */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
padding: 12,
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="状态筛选"
|
||||
style={{ width: 160 }}
|
||||
options={STATUS_OPTIONS}
|
||||
value={query.status}
|
||||
onChange={(value) => handleFilterChange('status', value)}
|
||||
/>
|
||||
<PageContainer
|
||||
title="随访管理"
|
||||
subtitle={`共 ${total} 条`}
|
||||
filters={
|
||||
<>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="状态筛选"
|
||||
style={{ width: 140 }}
|
||||
options={STATUS_OPTIONS}
|
||||
value={filters.status}
|
||||
onChange={(value) => setFilters((prev) => ({ ...prev, status: value }))}
|
||||
/>
|
||||
<DatePicker.RangePicker
|
||||
style={{ width: 240 }}
|
||||
onChange={(dates) => {
|
||||
if (dates && dates[0] && dates[1]) {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
dateRange: [dates[0]!.format('YYYY-MM-DD'), dates[1]!.format('YYYY-MM-DD')],
|
||||
}));
|
||||
} else {
|
||||
setFilters((prev) => ({ ...prev, dateRange: undefined }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="随访类型"
|
||||
style={{ width: 120 }}
|
||||
options={FOLLOW_UP_TYPE_OPTIONS}
|
||||
value={filters.followUpType}
|
||||
onChange={(value) => setFilters((prev) => ({ ...prev, followUpType: value }))}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="负责人"
|
||||
style={{ width: 140 }}
|
||||
// Note: Assignee select would ideally use DoctorSelect options
|
||||
// but Select with async search needs separate handling
|
||||
value={filters.assigneeId}
|
||||
onChange={(value) => setFilters((prev) => ({ ...prev, assigneeId: value }))}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
onResetFilters={() => setFilters({})}
|
||||
actions={
|
||||
<AuthButton code="health.follow-up.manage">
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -356,42 +369,23 @@ export default function FollowUpTaskList() {
|
||||
新建任务
|
||||
</Button>
|
||||
</AuthButton>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: isDark ? '#475569' : '#94a3b8',
|
||||
marginLeft: 'auto',
|
||||
}}
|
||||
>
|
||||
共 {total} 条
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div
|
||||
style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
overflow: 'hidden',
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={tasks}
|
||||
loading={loading}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: 20,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={tasks}
|
||||
loading={loading}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
current: query.page,
|
||||
pageSize: query.page_size,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
scroll={{ x: 980 }}
|
||||
/>
|
||||
</div>
|
||||
scroll={{ x: 980 }}
|
||||
/>
|
||||
|
||||
{/* Create Task Modal */}
|
||||
<Modal
|
||||
@@ -427,9 +421,7 @@ export default function FollowUpTaskList() {
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="assigned_to" label="负责人">
|
||||
<DoctorSelect
|
||||
onChange={(_val, label) => handleDoctorLabel(_val, label)}
|
||||
/>
|
||||
<DoctorSelect />
|
||||
</Form.Item>
|
||||
<Form.Item name="content_template" label="内容模板">
|
||||
<Input.TextArea rows={3} placeholder="随访内容模板(可选)" />
|
||||
@@ -499,12 +491,10 @@ export default function FollowUpTaskList() {
|
||||
label="负责人"
|
||||
rules={[{ required: true, message: '请选择负责人' }]}
|
||||
>
|
||||
<DoctorSelect
|
||||
onChange={(_val, label) => handleDoctorLabel(_val, label)}
|
||||
/>
|
||||
<DoctorSelect />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Table,
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
@@ -26,54 +25,83 @@ import type {
|
||||
} from '../../api/health/patients';
|
||||
import { StatusTag } from './components/StatusTag';
|
||||
import { GENDER_OPTIONS, BLOOD_TYPE_OPTIONS, STATUS_OPTIONS } from '../../constants/health';
|
||||
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||
import { AuthButton } from '../../components/AuthButton';
|
||||
import { PageContainer } from '../../components/PageContainer';
|
||||
import { usePaginatedData } from '../../hooks/usePaginatedData';
|
||||
import { calcAge, formatDateTime } from '../../utils/format';
|
||||
|
||||
/** 筛选器结构 */
|
||||
interface PatientFilters {
|
||||
search: string;
|
||||
status: string;
|
||||
gender: string;
|
||||
dateRange: [string, string] | null;
|
||||
}
|
||||
|
||||
const DEFAULT_FILTERS: PatientFilters = {
|
||||
search: '',
|
||||
status: '',
|
||||
gender: '',
|
||||
dateRange: null,
|
||||
};
|
||||
|
||||
export default function PatientList() {
|
||||
const [patients, setPatients] = useState<PatientListItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const navigate = useNavigate();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingPatient, setEditingPatient] = useState<PatientListItem | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const isDark = useThemeMode();
|
||||
const navigate = useNavigate();
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
||||
|
||||
const fetchPatients = useCallback(
|
||||
async (p = page) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await patientApi.list({
|
||||
page: p,
|
||||
page_size: 20,
|
||||
search: searchText || undefined,
|
||||
status: statusFilter || undefined,
|
||||
});
|
||||
setPatients(result.data);
|
||||
setTotal(result.total);
|
||||
} catch {
|
||||
message.error('加载患者列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// ---- 分页数据 Hook ----
|
||||
const {
|
||||
data: patients,
|
||||
total,
|
||||
page,
|
||||
loading,
|
||||
filters,
|
||||
setFilters,
|
||||
refresh,
|
||||
} = usePaginatedData<PatientListItem, PatientFilters>(
|
||||
async (p, pageSize, f) => {
|
||||
const result = await patientApi.list({
|
||||
page: p,
|
||||
page_size: pageSize,
|
||||
search: f.search || undefined,
|
||||
status: f.status || undefined,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
[page, searchText, statusFilter],
|
||||
{ pageSize: 20, defaultFilters: { ...DEFAULT_FILTERS } },
|
||||
);
|
||||
|
||||
// ---- 筛选回调 ----
|
||||
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const debouncedSearch = useCallback(() => {
|
||||
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
setPage(1);
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPatients();
|
||||
}, [fetchPatients]);
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setFilters((prev) => ({ ...prev, search: value }));
|
||||
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
refresh(1);
|
||||
}, 300);
|
||||
},
|
||||
[setFilters, refresh],
|
||||
);
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
(key: keyof PatientFilters, value: string | [string, string] | null) => {
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
refresh(1);
|
||||
},
|
||||
[setFilters, refresh],
|
||||
);
|
||||
|
||||
const handleResetFilters = useCallback(() => {
|
||||
setFilters({ ...DEFAULT_FILTERS });
|
||||
refresh(1);
|
||||
}, [setFilters, refresh]);
|
||||
|
||||
// ---- CRUD 操作 ----
|
||||
|
||||
const handleCreateOrEdit = async (values: {
|
||||
name: string;
|
||||
@@ -86,15 +114,22 @@ export default function PatientList() {
|
||||
}) => {
|
||||
const formatted = {
|
||||
...values,
|
||||
birth_date: values.birth_date && typeof values.birth_date === 'object' && 'format' in (values.birth_date as object)
|
||||
? (values.birth_date as { format: (f: string) => string }).format('YYYY-MM-DD')
|
||||
: (values.birth_date as string | undefined),
|
||||
birth_date:
|
||||
values.birth_date &&
|
||||
typeof values.birth_date === 'object' &&
|
||||
'format' in (values.birth_date as object)
|
||||
? (values.birth_date as { format: (f: string) => string }).format(
|
||||
'YYYY-MM-DD',
|
||||
)
|
||||
: (values.birth_date as string | undefined),
|
||||
};
|
||||
try {
|
||||
if (editingPatient) {
|
||||
const req: UpdatePatientReq & { version: number } = {
|
||||
...formatted,
|
||||
version: (editingPatient as PatientListItem & { version?: number }).version ?? 0,
|
||||
version:
|
||||
(editingPatient as PatientListItem & { version?: number })
|
||||
.version ?? 0,
|
||||
};
|
||||
await patientApi.update(editingPatient.id, req);
|
||||
message.success('患者信息更新成功');
|
||||
@@ -104,7 +139,7 @@ export default function PatientList() {
|
||||
message.success('患者创建成功');
|
||||
}
|
||||
closeModal();
|
||||
fetchPatients();
|
||||
refresh();
|
||||
} catch (err: unknown) {
|
||||
const errorMsg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
@@ -116,10 +151,11 @@ export default function PatientList() {
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
const patient = patients.find((p) => p.id === id);
|
||||
const version = (patient as PatientListItem & { version?: number })?.version ?? 0;
|
||||
const version =
|
||||
(patient as PatientListItem & { version?: number })?.version ?? 0;
|
||||
await patientApi.delete(id, version);
|
||||
message.success('患者已删除');
|
||||
fetchPatients();
|
||||
refresh();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
@@ -148,6 +184,8 @@ export default function PatientList() {
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
// ---- 列定义 ----
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '姓名',
|
||||
@@ -173,11 +211,9 @@ export default function PatientList() {
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, fontSize: 14 }}>{name}</div>
|
||||
{record.source && (
|
||||
<div style={{ fontSize: 12, color: isDark ? '#475569' : '#94a3b8' }}>
|
||||
来源: {record.source}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 12, color: '#94a3b8' }}>
|
||||
{record.source && <span>来源: {record.source}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
@@ -189,16 +225,20 @@ export default function PatientList() {
|
||||
width: 80,
|
||||
render: (v?: string) => {
|
||||
if (!v) return '-';
|
||||
const map: Record<string, string> = { male: '男', female: '女', other: '其他' };
|
||||
const map: Record<string, string> = {
|
||||
male: '男',
|
||||
female: '女',
|
||||
other: '其他',
|
||||
};
|
||||
return map[v] || v;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '出生日期',
|
||||
title: '年龄',
|
||||
dataIndex: 'birth_date',
|
||||
key: 'birth_date',
|
||||
width: 120,
|
||||
render: (v?: string) => v || '-',
|
||||
width: 100,
|
||||
render: (v?: string) => calcAge(v),
|
||||
},
|
||||
{
|
||||
title: '血型',
|
||||
@@ -209,31 +249,21 @@ export default function PatientList() {
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (status: string) => <StatusTag status={status} />,
|
||||
},
|
||||
{
|
||||
title: '认证状态',
|
||||
dataIndex: 'verification_status',
|
||||
key: 'verification_status',
|
||||
width: 100,
|
||||
render: (v: string) => <StatusTag status={v} />,
|
||||
},
|
||||
{
|
||||
title: '来源',
|
||||
dataIndex: 'source',
|
||||
key: 'source',
|
||||
width: 100,
|
||||
render: (v?: string) => v || '-',
|
||||
width: 140,
|
||||
render: (_: unknown, record: PatientListItem) => (
|
||||
<Space size={4}>
|
||||
<StatusTag status={record.status} />
|
||||
<StatusTag status={record.verification_status} />
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 170,
|
||||
render: (v: string) => (v ? new Date(v).toLocaleString('zh-CN') : '-'),
|
||||
width: 150,
|
||||
render: (v: string) => formatDateTime(v),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
@@ -250,7 +280,6 @@ export default function PatientList() {
|
||||
e.stopPropagation();
|
||||
openEditModal(record);
|
||||
}}
|
||||
style={{ color: isDark ? '#94a3b8' : '#475569' }}
|
||||
/>
|
||||
<Popconfirm
|
||||
title="确定删除此患者?"
|
||||
@@ -274,75 +303,88 @@ export default function PatientList() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 页面标题和工具栏 */}
|
||||
<div className="erp-page-header">
|
||||
<div>
|
||||
<h4>患者管理</h4>
|
||||
<div className="erp-page-subtitle">
|
||||
管理患者档案、基本信息和认证状态
|
||||
</div>
|
||||
</div>
|
||||
<Space size={8}>
|
||||
<PageContainer
|
||||
title="患者管理"
|
||||
subtitle="管理患者档案、基本信息和认证状态"
|
||||
filters={
|
||||
<>
|
||||
<Input
|
||||
placeholder="搜索患者姓名..."
|
||||
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
|
||||
value={searchText}
|
||||
onChange={(e) => {
|
||||
setSearchText(e.target.value);
|
||||
debouncedSearch();
|
||||
}}
|
||||
value={filters.search}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 200, borderRadius: 8 }}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(v) => {
|
||||
setStatusFilter(v);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="状态"
|
||||
value={filters.status || undefined}
|
||||
onChange={(v) => handleFilterChange('status', v ?? '')}
|
||||
options={STATUS_OPTIONS}
|
||||
style={{ width: 130, borderRadius: 8 }}
|
||||
allowClear
|
||||
style={{ width: 130 }}
|
||||
/>
|
||||
<AuthButton code="health.patient.manage">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
|
||||
新建患者
|
||||
</Button>
|
||||
</AuthButton>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 表格容器 */}
|
||||
<div
|
||||
style={{
|
||||
background: isDark ? '#111827' : '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
|
||||
overflow: 'hidden',
|
||||
<Select
|
||||
placeholder="性别"
|
||||
value={filters.gender || undefined}
|
||||
onChange={(v) => handleFilterChange('gender', v ?? '')}
|
||||
options={GENDER_OPTIONS}
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
<DatePicker.RangePicker
|
||||
onChange={(dates) => {
|
||||
if (dates && dates[0] && dates[1]) {
|
||||
handleFilterChange('dateRange', [
|
||||
dates[0].format('YYYY-MM-DD'),
|
||||
dates[1].format('YYYY-MM-DD'),
|
||||
]);
|
||||
} else {
|
||||
handleFilterChange('dateRange', null);
|
||||
}
|
||||
}}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
onResetFilters={handleResetFilters}
|
||||
actions={
|
||||
<AuthButton code="health.patient.manage">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
|
||||
新建患者
|
||||
</Button>
|
||||
</AuthButton>
|
||||
}
|
||||
selectedCount={selectedRowKeys.length}
|
||||
onClearSelection={() => setSelectedRowKeys([])}
|
||||
batchActions={
|
||||
<span style={{ fontSize: 13, color: '#94a3b8' }}>
|
||||
已选择 {selectedRowKeys.length} 项
|
||||
</span>
|
||||
}
|
||||
loading={loading}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={patients}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
||||
}}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={patients}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
onRow={(record) => ({
|
||||
onClick: () => navigate(`/health/patients/${record.id}`),
|
||||
style: { cursor: 'pointer' },
|
||||
})}
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 20,
|
||||
onChange: (p) => {
|
||||
setPage(p);
|
||||
fetchPatients(p);
|
||||
},
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
style: { padding: '12px 16px', margin: 0 },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
onRow={(record) => ({
|
||||
onClick: () => navigate(`/health/patients/${record.id}`),
|
||||
style: { cursor: 'pointer' },
|
||||
})}
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 20,
|
||||
onChange: (p) => refresh(p),
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
style: { padding: '12px 16px', margin: 0 },
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 新建/编辑患者弹窗 */}
|
||||
<Modal
|
||||
@@ -372,7 +414,11 @@ export default function PatientList() {
|
||||
<DatePicker style={{ width: '100%' }} placeholder="请选择出生日期" />
|
||||
</Form.Item>
|
||||
<Form.Item name="blood_type" label="血型">
|
||||
<Select options={BLOOD_TYPE_OPTIONS} placeholder="请选择血型" allowClear />
|
||||
<Select
|
||||
options={BLOOD_TYPE_OPTIONS}
|
||||
placeholder="请选择血型"
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="id_number" label="身份证号">
|
||||
<Input placeholder="请输入身份证号" />
|
||||
@@ -385,6 +431,6 @@ export default function PatientList() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Badge,
|
||||
message,
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Tag,
|
||||
DatePicker,
|
||||
} from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { dayjs } from '../../utils/dayjs';
|
||||
import {
|
||||
pointsApi,
|
||||
type PointsOrder,
|
||||
} from '../../api/health/points';
|
||||
import { AuthButton } from '../../components/AuthButton';
|
||||
import { PageContainer } from '../../components/PageContainer';
|
||||
import { usePaginatedData } from '../../hooks/usePaginatedData';
|
||||
import { useHealthStore } from '../../stores/health';
|
||||
import { formatDateTime } from '../../utils/format';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { dayjs } from '../../utils/dayjs';
|
||||
|
||||
/** 订单状态映射 */
|
||||
const STATUS_MAP: Record<string, { text: string; color: string }> = {
|
||||
@@ -45,43 +47,44 @@ function truncateId(id: string): string {
|
||||
return id.length > 12 ? `${id.slice(0, 8)}...${id.slice(-4)}` : id;
|
||||
}
|
||||
|
||||
interface OrderFilters {
|
||||
status: string | undefined;
|
||||
dateRange: [Dayjs, Dayjs] | undefined;
|
||||
}
|
||||
|
||||
export default function PointsOrderList() {
|
||||
const [data, setData] = useState<PointsOrder[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined);
|
||||
const [verifyModalOpen, setVerifyModalOpen] = useState(false);
|
||||
const [verifyForm] = Form.useForm();
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
|
||||
const { batchResolvePatientNames, getPatientName } = useHealthStore();
|
||||
|
||||
// ---- 数据获取 ----
|
||||
const fetchData = useCallback(async (p = page, ps = pageSize) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const fetchOrders = useCallback(
|
||||
async (page: number, pageSize: number, filters: OrderFilters) => {
|
||||
const result = await pointsApi.listOrders({
|
||||
page: p,
|
||||
page_size: ps,
|
||||
status: statusFilter || undefined,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
status: filters.status || undefined,
|
||||
});
|
||||
setData(result.data);
|
||||
setTotal(result.total);
|
||||
|
||||
const patientIds = result.data.map((o) => o.patient_id);
|
||||
batchResolvePatientNames(patientIds);
|
||||
} catch {
|
||||
message.error('加载订单列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, pageSize, statusFilter, batchResolvePatientNames]);
|
||||
return { data: result.data, total: result.total };
|
||||
},
|
||||
[batchResolvePatientNames],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
const {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
loading,
|
||||
filters,
|
||||
setFilters,
|
||||
refresh,
|
||||
} = usePaginatedData<PointsOrder, OrderFilters>(
|
||||
fetchOrders,
|
||||
{ pageSize: 20, defaultFilters: { status: undefined, dateRange: undefined } },
|
||||
);
|
||||
|
||||
// ---- 核销 ----
|
||||
const openVerifyModal = () => {
|
||||
@@ -96,7 +99,7 @@ export default function PointsOrderList() {
|
||||
message.success(`核销成功,订单 ${truncateId(order.id)} 已确认`);
|
||||
setVerifyModalOpen(false);
|
||||
verifyForm.resetFields();
|
||||
fetchData(page, pageSize);
|
||||
refresh(page);
|
||||
} catch {
|
||||
message.error('核销失败,请检查二维码是否正确');
|
||||
} finally {
|
||||
@@ -104,6 +107,10 @@ export default function PointsOrderList() {
|
||||
}
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setFilters({ status: undefined, dateRange: undefined });
|
||||
};
|
||||
|
||||
// ---- 列定义 ----
|
||||
const columns = [
|
||||
{
|
||||
@@ -152,14 +159,14 @@ export default function PointsOrderList() {
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 170,
|
||||
render: (val: string) => (val ? dayjs(val).format('YYYY-MM-DD HH:mm') : '-'),
|
||||
render: (val: string) => formatDateTime(val),
|
||||
},
|
||||
{
|
||||
title: '核销时间',
|
||||
dataIndex: 'verified_at',
|
||||
key: 'verified_at',
|
||||
width: 170,
|
||||
render: (val: string) => (val ? dayjs(val).format('YYYY-MM-DD HH:mm') : '-'),
|
||||
render: (val: string) => formatDateTime(val),
|
||||
},
|
||||
{
|
||||
title: '核销人',
|
||||
@@ -178,7 +185,7 @@ export default function PointsOrderList() {
|
||||
const isExpired = dayjs(val).isBefore(dayjs());
|
||||
return (
|
||||
<span style={{ color: isExpired ? '#dc2626' : undefined }}>
|
||||
{dayjs(val).format('YYYY-MM-DD HH:mm')}
|
||||
{formatDateTime(val)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
@@ -194,38 +201,43 @@ export default function PointsOrderList() {
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{/* 筛选栏 */}
|
||||
<Row gutter={16} style={{ marginBottom: 16 }} align="middle">
|
||||
<Col flex="auto">
|
||||
<Space>
|
||||
<Select
|
||||
placeholder="筛选状态"
|
||||
value={statusFilter}
|
||||
onChange={(val) => {
|
||||
setStatusFilter(val);
|
||||
setPage(1);
|
||||
}}
|
||||
options={STATUS_OPTIONS}
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<AuthButton code="health.points.manage">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CheckCircleOutlined />}
|
||||
onClick={openVerifyModal}
|
||||
>
|
||||
核销订单
|
||||
</Button>
|
||||
</AuthButton>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 数据表格 */}
|
||||
<PageContainer
|
||||
title="积分订单"
|
||||
filters={
|
||||
<>
|
||||
<Select
|
||||
placeholder="筛选状态"
|
||||
value={filters.status}
|
||||
onChange={(val) => setFilters((f) => ({ ...f, status: val }))}
|
||||
options={STATUS_OPTIONS}
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
<DatePicker.RangePicker
|
||||
value={filters.dateRange ?? undefined}
|
||||
onChange={(dates) =>
|
||||
setFilters((f) => ({
|
||||
...f,
|
||||
dateRange: dates as [Dayjs, Dayjs] | undefined,
|
||||
}))
|
||||
}
|
||||
style={{ width: 260 }}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
onResetFilters={resetFilters}
|
||||
actions={
|
||||
<AuthButton code="health.points.manage">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CheckCircleOutlined />}
|
||||
onClick={openVerifyModal}
|
||||
>
|
||||
核销订单
|
||||
</Button>
|
||||
</AuthButton>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
@@ -234,14 +246,11 @@ export default function PointsOrderList() {
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
pageSize: 20,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
onChange: (p) => refresh(p),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -272,6 +281,6 @@ export default function PointsOrderList() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
@@ -12,22 +12,21 @@ import {
|
||||
Badge,
|
||||
Switch,
|
||||
message,
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { dayjs } from '../../utils/dayjs';
|
||||
import {
|
||||
pointsApi,
|
||||
type PointsProduct,
|
||||
type CreatePointsProductReq,
|
||||
} from '../../api/health/points';
|
||||
import { AuthButton } from '../../components/AuthButton';
|
||||
import { PageContainer } from '../../components/PageContainer';
|
||||
import { usePaginatedData } from '../../hooks/usePaginatedData';
|
||||
import { formatDateTime } from '../../utils/format';
|
||||
|
||||
/** 商品类型映射 */
|
||||
const PRODUCT_TYPES: Record<string, string> = {
|
||||
@@ -49,38 +48,47 @@ const PRODUCT_TYPE_COLORS: Record<string, string> = {
|
||||
privilege: 'purple',
|
||||
};
|
||||
|
||||
interface ProductFilters {
|
||||
search: string;
|
||||
product_type: string | undefined;
|
||||
is_active: string | undefined;
|
||||
}
|
||||
|
||||
export default function PointsProductList() {
|
||||
const [data, setData] = useState<PointsProduct[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [typeFilter, setTypeFilter] = useState<string | undefined>(undefined);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<PointsProduct | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// ---- 数据获取 ----
|
||||
const fetchData = useCallback(async (p = page, ps = pageSize) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const fetchProducts = useCallback(
|
||||
async (page: number, pageSize: number, filters: ProductFilters) => {
|
||||
const result = await pointsApi.listProducts({
|
||||
page: p,
|
||||
page_size: ps,
|
||||
product_type: typeFilter || undefined,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
product_type: filters.product_type || undefined,
|
||||
keyword: filters.search || undefined,
|
||||
});
|
||||
setData(result.data);
|
||||
setTotal(result.total);
|
||||
} catch {
|
||||
message.error('加载商品列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, pageSize, typeFilter]);
|
||||
let filtered = result.data;
|
||||
if (filters.is_active !== undefined) {
|
||||
const isActive = filters.is_active === 'true';
|
||||
filtered = filtered.filter((p) => p.is_active === isActive);
|
||||
}
|
||||
return { data: filtered, total: result.total };
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
const {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
loading,
|
||||
filters,
|
||||
setFilters,
|
||||
refresh,
|
||||
} = usePaginatedData<PointsProduct, ProductFilters>(
|
||||
fetchProducts,
|
||||
{ pageSize: 20, defaultFilters: { search: '', product_type: undefined, is_active: undefined } },
|
||||
);
|
||||
|
||||
// ---- 新建 / 编辑 ----
|
||||
const openCreate = () => {
|
||||
@@ -134,7 +142,7 @@ export default function PointsProductList() {
|
||||
message.success(editing ? '更新成功' : '创建成功');
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
fetchData(page, pageSize);
|
||||
refresh(page);
|
||||
} catch {
|
||||
message.error(editing ? '更新失败' : '创建失败');
|
||||
}
|
||||
@@ -148,7 +156,7 @@ export default function PointsProductList() {
|
||||
version: record.version,
|
||||
});
|
||||
message.success(record.is_active ? '已下架' : '已上架');
|
||||
fetchData(page, pageSize);
|
||||
refresh(page);
|
||||
} catch {
|
||||
message.error('操作失败');
|
||||
}
|
||||
@@ -164,7 +172,7 @@ export default function PointsProductList() {
|
||||
try {
|
||||
await pointsApi.deleteProduct(record.id, record.version);
|
||||
message.success('删除成功');
|
||||
fetchData(page, pageSize);
|
||||
refresh(page);
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
@@ -172,6 +180,10 @@ export default function PointsProductList() {
|
||||
});
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setFilters({ search: '', product_type: undefined, is_active: undefined });
|
||||
};
|
||||
|
||||
// ---- 列定义 ----
|
||||
const columns = [
|
||||
{
|
||||
@@ -226,7 +238,7 @@ export default function PointsProductList() {
|
||||
dataIndex: 'updated_at',
|
||||
key: 'updated_at',
|
||||
width: 170,
|
||||
render: (val: string) => (val ? dayjs(val).format('YYYY-MM-DD HH:mm') : '-'),
|
||||
render: (val: string) => formatDateTime(val),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
@@ -266,34 +278,47 @@ export default function PointsProductList() {
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{/* 筛选栏 */}
|
||||
<Row gutter={16} style={{ marginBottom: 16 }} align="middle">
|
||||
<Col flex="auto">
|
||||
<Space>
|
||||
<Select
|
||||
placeholder="筛选类型"
|
||||
value={typeFilter}
|
||||
onChange={(val) => {
|
||||
setTypeFilter(val);
|
||||
setPage(1);
|
||||
}}
|
||||
options={PRODUCT_TYPE_OPTIONS}
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<AuthButton code="health.points.manage">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新建商品
|
||||
</Button>
|
||||
</AuthButton>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 数据表格 */}
|
||||
<PageContainer
|
||||
title="积分商品"
|
||||
filters={
|
||||
<>
|
||||
<Input
|
||||
placeholder="搜索商品名称"
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, search: e.target.value }))}
|
||||
allowClear
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="筛选类型"
|
||||
value={filters.product_type}
|
||||
onChange={(val) => setFilters((f) => ({ ...f, product_type: val }))}
|
||||
options={PRODUCT_TYPE_OPTIONS}
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="筛选状态"
|
||||
value={filters.is_active}
|
||||
onChange={(val) => setFilters((f) => ({ ...f, is_active: val }))}
|
||||
options={[
|
||||
{ value: 'true', label: '上架' },
|
||||
{ value: 'false', label: '下架' },
|
||||
]}
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
onResetFilters={resetFilters}
|
||||
actions={
|
||||
<AuthButton code="health.points.manage">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新建商品
|
||||
</Button>
|
||||
</AuthButton>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
@@ -302,14 +327,11 @@ export default function PointsProductList() {
|
||||
scroll={{ x: 900 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
pageSize: 20,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
onChange: (p) => refresh(p),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -333,38 +355,32 @@ export default function PointsProductList() {
|
||||
>
|
||||
<Input placeholder="如:体检套餐兑换券" />
|
||||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="product_type"
|
||||
label="商品类型"
|
||||
rules={[{ required: true, message: '请选择商品类型' }]}
|
||||
>
|
||||
<Select placeholder="选择类型" options={PRODUCT_TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="points_cost"
|
||||
label="所需积分"
|
||||
rules={[{ required: true, message: '请输入所需积分' }]}
|
||||
>
|
||||
<InputNumber min={1} max={999999} style={{ width: '100%' }} placeholder="如:100" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="stock" label="库存数量" initialValue={-1}>
|
||||
<InputNumber min={-1} max={999999} style={{ width: '100%' }} placeholder="-1 表示无限" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="sort_order" label="排序" initialValue={0}>
|
||||
<InputNumber min={0} max={9999} style={{ width: '100%' }} placeholder="0" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item
|
||||
name="product_type"
|
||||
label="商品类型"
|
||||
rules={[{ required: true, message: '请选择商品类型' }]}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Select placeholder="选择类型" options={PRODUCT_TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="points_cost"
|
||||
label="所需积分"
|
||||
rules={[{ required: true, message: '请输入所需积分' }]}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<InputNumber min={1} max={999999} style={{ width: '100%' }} placeholder="如:100" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="stock" label="库存数量" initialValue={-1} style={{ flex: 1 }}>
|
||||
<InputNumber min={-1} max={999999} style={{ width: '100%' }} placeholder="-1 表示无限" />
|
||||
</Form.Item>
|
||||
<Form.Item name="sort_order" label="排序" initialValue={0} style={{ flex: 1 }}>
|
||||
<InputNumber min={0} max={9999} style={{ width: '100%' }} placeholder="0" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="image_url" label="图片链接">
|
||||
<Input placeholder="商品图片 URL" />
|
||||
</Form.Item>
|
||||
@@ -373,6 +389,6 @@ export default function PointsProductList() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
@@ -11,9 +11,6 @@ import {
|
||||
Tag,
|
||||
Badge,
|
||||
message,
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Switch,
|
||||
} from 'antd';
|
||||
import {
|
||||
@@ -21,13 +18,14 @@ import {
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { dayjs } from '../../utils/dayjs';
|
||||
import {
|
||||
pointsApi,
|
||||
type PointsRule,
|
||||
type CreatePointsRuleReq,
|
||||
} from '../../api/health/points';
|
||||
import { AuthButton } from '../../components/AuthButton';
|
||||
import { PageContainer } from '../../components/PageContainer';
|
||||
import { formatDateTime } from '../../utils/format';
|
||||
|
||||
/** 事件类型映射 */
|
||||
const EVENT_TYPES: Record<string, string> = {
|
||||
@@ -45,11 +43,20 @@ const EVENT_TYPE_OPTIONS = Object.entries(EVENT_TYPES).map(([value, label]) => (
|
||||
label,
|
||||
}));
|
||||
|
||||
interface RuleFilters {
|
||||
event_type: string | undefined;
|
||||
is_active: string | undefined;
|
||||
}
|
||||
|
||||
export default function PointsRuleList() {
|
||||
const [data, setData] = useState<PointsRule[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<PointsRule | null>(null);
|
||||
const [filters, setFilters] = useState<RuleFilters>({
|
||||
event_type: undefined,
|
||||
is_active: undefined,
|
||||
});
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// ---- 数据获取 ----
|
||||
@@ -57,17 +64,28 @@ export default function PointsRuleList() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await pointsApi.listRules();
|
||||
setData(result);
|
||||
let filtered = result;
|
||||
if (filters.event_type) {
|
||||
filtered = filtered.filter((r) => r.event_type === filters.event_type);
|
||||
}
|
||||
if (filters.is_active !== undefined) {
|
||||
const isActive = filters.is_active === 'true';
|
||||
filtered = filtered.filter((r) => r.is_active === isActive);
|
||||
}
|
||||
setData(filtered);
|
||||
} catch {
|
||||
message.error('加载积分规则失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
// Initial fetch
|
||||
const [hasFetched, setHasFetched] = useState(false);
|
||||
if (!hasFetched) {
|
||||
setHasFetched(true);
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
}
|
||||
|
||||
// ---- 新建 / 编辑 ----
|
||||
const openCreate = () => {
|
||||
@@ -161,6 +179,10 @@ export default function PointsRuleList() {
|
||||
});
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setFilters({ event_type: undefined, is_active: undefined });
|
||||
};
|
||||
|
||||
// ---- 列定义 ----
|
||||
const columns = [
|
||||
{
|
||||
@@ -227,7 +249,7 @@ export default function PointsRuleList() {
|
||||
dataIndex: 'updated_at',
|
||||
key: 'updated_at',
|
||||
width: 170,
|
||||
render: (val: string) => (val ? dayjs(val).format('YYYY-MM-DD HH:mm') : '-'),
|
||||
render: (val: string) => formatDateTime(val),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
@@ -267,24 +289,41 @@ export default function PointsRuleList() {
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{/* 筛选栏 */}
|
||||
<Row gutter={16} style={{ marginBottom: 16 }} align="middle">
|
||||
<Col flex="auto">
|
||||
<span style={{ color: '#64748b', fontSize: 13 }}>
|
||||
积分规则定义各类健康行为对应的积分奖励,含连续打卡额外奖励
|
||||
</span>
|
||||
</Col>
|
||||
<Col>
|
||||
<AuthButton code="health.points.manage">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新建规则
|
||||
</Button>
|
||||
</AuthButton>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 数据表格 */}
|
||||
<PageContainer
|
||||
title="积分规则"
|
||||
subtitle="积分规则定义各类健康行为对应的积分奖励,含连续打卡额外奖励"
|
||||
filters={
|
||||
<>
|
||||
<Select
|
||||
placeholder="筛选类型"
|
||||
value={filters.event_type}
|
||||
onChange={(val) => setFilters((f) => ({ ...f, event_type: val }))}
|
||||
options={EVENT_TYPE_OPTIONS}
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="筛选状态"
|
||||
value={filters.is_active}
|
||||
onChange={(val) => setFilters((f) => ({ ...f, is_active: val }))}
|
||||
options={[
|
||||
{ value: 'true', label: '启用' },
|
||||
{ value: 'false', label: '停用' },
|
||||
]}
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
onResetFilters={resetFilters}
|
||||
actions={
|
||||
<AuthButton code="health.points.manage">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新建规则
|
||||
</Button>
|
||||
</AuthButton>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
@@ -331,28 +370,22 @@ export default function PointsRuleList() {
|
||||
<Form.Item name="daily_cap" label="每日上限" initialValue={1}>
|
||||
<InputNumber min={-1} max={10000} style={{ width: '100%' }} placeholder="-1 表示无限" />
|
||||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Item name="streak_7d_bonus" label="7日连续奖励" initialValue={0}>
|
||||
<InputNumber min={0} max={10000} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="streak_14d_bonus" label="14日连续奖励" initialValue={0}>
|
||||
<InputNumber min={0} max={10000} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="streak_30d_bonus" label="30日连续奖励" initialValue={0}>
|
||||
<InputNumber min={0} max={10000} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="streak_7d_bonus" label="7日连续奖励" initialValue={0} style={{ flex: 1 }}>
|
||||
<InputNumber min={0} max={10000} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="streak_14d_bonus" label="14日连续奖励" initialValue={0} style={{ flex: 1 }}>
|
||||
<InputNumber min={0} max={10000} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="streak_30d_bonus" label="30日连续奖励" initialValue={0} style={{ flex: 1 }}>
|
||||
<InputNumber min={0} max={10000} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea rows={2} placeholder="规则说明" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user