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