refactor(web): 列表页统一迁移 — PageContainer + usePaginatedData + 格式化规范
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

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:
iven
2026-04-28 08:17:55 +08:00
parent 7dcb324abe
commit 1e7a5f5498
10 changed files with 1205 additions and 1074 deletions

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }