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 {
Table,
Select,
Button,
Input,
Tag,
Space,
Popconfirm,
DatePicker,
message,
} from 'antd';
import { CheckOutlined, StopOutlined } from '@ant-design/icons';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import { dayjs } from '../../utils/dayjs';
import {
listAlerts,
acknowledgeAlert,
@@ -18,7 +20,10 @@ import {
type Alert,
} from '../../api/health/alerts';
import { AuthButton } from '../../components/AuthButton';
import { useThemeMode } from '../../hooks/useThemeMode';
import { PageContainer } from '../../components/PageContainer';
import { EntityName } from '../../components/EntityName';
import { usePaginatedData } from '../../hooks/usePaginatedData';
import { formatRelative, formatDateTime } from '../../utils/format';
// --- 常量映射 ---
@@ -29,6 +34,13 @@ const STATUS_OPTIONS = [
{ value: 'dismissed', label: '已忽略' },
];
const SEVERITY_OPTIONS = [
{ value: 'info', label: '提示' },
{ value: 'warning', label: '警告' },
{ value: 'critical', label: '严重' },
{ value: 'urgent', label: '紧急' },
];
const SEVERITY_COLOR: Record<string, string> = {
info: 'default',
warning: 'orange',
@@ -57,75 +69,76 @@ const STATUS_LABEL: Record<string, string> = {
dismissed: '已忽略',
};
// --- 辅助函数 ---
// --- 筛选器结构 ---
/** 截取 ID 前 8 位用于展示 */
function shortId(id: string): string {
return id.length > 8 ? id.slice(0, 8) : id;
interface AlertFilters {
search: string;
status: string;
severity: string;
dateRange: [string, string] | null;
}
const DEFAULT_FILTERS: AlertFilters = {
search: '',
status: '',
severity: '',
dateRange: null,
};
// --- 辅助函数 ---
/** 从 detail 中提取规则名称 */
function extractRuleName(detail: Record<string, unknown> | undefined): string {
function extractRuleName(
detail: Record<string, unknown> | undefined,
): string {
if (!detail) return '-';
const ruleName = detail.rule_name;
return typeof ruleName === 'string' && ruleName ? ruleName : '-';
}
/** 格式化为相对时间 */
function relativeTimeStr(value: string): string {
return dayjs(value).fromNow();
}
export default function AlertList() {
const isDark = useThemeMode();
const [data, setData] = useState<Alert[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [query, setQuery] = useState<{
page: number;
page_size: number;
status?: string;
}>({
page: 1,
page_size: 20,
});
// ---- 数据获取 ----
const fetchData = useCallback(
async (params: { page: number; page_size: number; status?: string }) => {
setLoading(true);
try {
const result = await listAlerts(params);
setData(result.data);
setTotal(result.total);
} catch {
message.error('加载告警列表失败');
} finally {
setLoading(false);
}
// ---- 分页数据 Hook ----
const {
data,
total,
page,
loading,
filters,
setFilters,
refresh,
} = usePaginatedData<Alert, AlertFilters>(
async (p, pageSize, f) => {
const result = await listAlerts({
page: p,
page_size: pageSize,
status: f.status || undefined,
});
return result;
},
[],
{ pageSize: 20, defaultFilters: { ...DEFAULT_FILTERS } },
);
useEffect(() => {
fetchData(query);
}, [query, fetchData]);
// ---- 筛选回调 ----
// ---- 筛选与分页 ----
const handleFilterChange = useCallback(
(key: keyof AlertFilters, value: string | [string, string] | null) => {
setFilters((prev) => ({ ...prev, [key]: value }));
refresh(1);
},
[setFilters, refresh],
);
const handleFilterChange = (value: string | undefined) => {
setQuery((prev) => ({ ...prev, status: value || undefined, page: 1 }));
};
const handleResetFilters = useCallback(() => {
setFilters({ ...DEFAULT_FILTERS });
refresh(1);
}, [setFilters, refresh]);
// ---- 分页 ----
const handleTableChange = (pagination: TablePaginationConfig) => {
setQuery((prev) => ({
...prev,
page: pagination.current ?? 1,
page_size: pagination.pageSize ?? 20,
}));
refresh(pagination.current ?? 1);
};
// ---- 操作 ----
@@ -135,7 +148,7 @@ export default function AlertList() {
try {
await acknowledgeAlert(record.id, record.version);
message.success('告警已确认');
fetchData(query);
refresh();
} catch {
message.error('确认告警失败');
} finally {
@@ -148,7 +161,7 @@ export default function AlertList() {
try {
await dismissAlert(record.id, record.version);
message.success('告警已忽略');
fetchData(query);
refresh();
} catch {
message.error('忽略告警失败');
} finally {
@@ -160,14 +173,18 @@ export default function AlertList() {
const columns: ColumnsType<Alert> = [
{
title: '患者ID',
title: '患者',
dataIndex: 'patient_id',
key: 'patient_id',
width: 110,
width: 140,
render: (id: string) => (
<span style={{ fontFamily: 'monospace', fontSize: 13 }}>
{shortId(id)}
</span>
<Link to={`/health/patients/${id}`}>
<EntityName
name={undefined}
id={id}
fallbackLabel={id.length > 8 ? id.slice(0, 8) + '...' : id}
/>
</Link>
),
},
{
@@ -212,11 +229,8 @@ export default function AlertList() {
key: 'created_at',
width: 140,
render: (val: string) => (
<span
title={dayjs(val).format('YYYY-MM-DD HH:mm:ss')}
style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}
>
{relativeTimeStr(val)}
<span title={formatDateTime(val)} style={{ fontSize: 13 }}>
{formatRelative(val)}
</span>
),
},
@@ -244,7 +258,8 @@ export default function AlertList() {
</Button>
</Popconfirm>
)}
{(record.status === 'pending' || record.status === 'acknowledged') && (
{(record.status === 'pending' ||
record.status === 'acknowledged') && (
<Popconfirm
title="确认忽略该告警?"
onConfirm={() => handleDismiss(record)}
@@ -269,64 +284,67 @@ export default function AlertList() {
];
return (
<div>
{/* 筛选栏 */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
marginBottom: 16,
padding: 12,
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 10,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
<PageContainer
title="告警列表"
subtitle="查看和管理患者健康告警"
filters={
<>
<Input
placeholder="搜索告警..."
value={filters.search}
onChange={(e) => handleFilterChange('search', e.target.value)}
allowClear
style={{ width: 200 }}
/>
<Select
allowClear
placeholder="状态筛选"
style={{ width: 140 }}
options={STATUS_OPTIONS}
value={filters.status || undefined}
onChange={(v) => handleFilterChange('status', v ?? '')}
/>
<Select
allowClear
placeholder="严重程度"
style={{ width: 120 }}
options={SEVERITY_OPTIONS}
value={filters.severity || undefined}
onChange={(v) => handleFilterChange('severity', v ?? '')}
/>
<DatePicker.RangePicker
placeholder={['开始日期', '结束日期']}
onChange={(dates) => {
if (dates && dates[0] && dates[1]) {
handleFilterChange('dateRange', [
dates[0].format('YYYY-MM-DD'),
dates[1].format('YYYY-MM-DD'),
]);
} else {
handleFilterChange('dateRange', null);
}
}}
/>
</>
}
onResetFilters={handleResetFilters}
loading={loading}
>
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
onChange={handleTableChange}
pagination={{
current: page,
pageSize: 20,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
}}
>
<Select
allowClear
placeholder="状态筛选"
style={{ width: 160 }}
options={STATUS_OPTIONS}
value={query.status}
onChange={handleFilterChange}
/>
<span
style={{
fontSize: 13,
color: isDark ? '#475569' : '#94a3b8',
marginLeft: 'auto',
}}
>
{total}
</span>
</div>
{/* 数据表格 */}
<div
style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden',
}}
>
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
onChange={handleTableChange}
pagination={{
current: query.page,
pageSize: query.page_size,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
}}
scroll={{ x: 970 }}
/>
</div>
</div>
scroll={{ x: 970 }}
/>
</PageContainer>
);
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback } from 'react';
import { useState, useCallback, useEffect } from 'react';
import {
Table,
Button,
@@ -11,7 +11,6 @@ import {
Input,
Dropdown,
message,
Card,
Row,
Alert,
Col,
@@ -26,6 +25,10 @@ import { StatusTag } from './components/StatusTag';
import { PatientSelect } from './components/PatientSelect';
import { DoctorSelect } from './components/DoctorSelect';
import { AuthButton } from '../../components/AuthButton';
import { PageContainer } from '../../components/PageContainer';
import { EntityName } from '../../components/EntityName';
import { formatDateTime } from '../../utils/format';
import { usePaginatedData } from '../../hooks/usePaginatedData';
/** 预约类型选项 */
const APPOINTMENT_TYPE_OPTIONS = [
@@ -71,14 +74,15 @@ const STATUS_TRANSITIONS: Record<string, { value: string; label: string }[]> = {
],
};
/** 筛选器类型 */
interface AppointmentFilters {
status: string | undefined;
dateRange: [Dayjs | null, Dayjs | null] | null;
patientSearch: string;
appointmentType: string | undefined;
}
export default function AppointmentList() {
const [data, setData] = useState<Appointment[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [loading, setLoading] = useState(false);
const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined);
const [dateFilter, setDateFilter] = useState<Dayjs | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm();
@@ -91,27 +95,56 @@ export default function AppointmentList() {
const [selectedDate, setSelectedDate] = useState<string | null>(null);
// ---- 数据获取 ----
const fetchData = useCallback(async (p = page, ps = pageSize) => {
setLoading(true);
try {
const result = await appointmentApi.list({
page: p,
page_size: ps,
status: statusFilter || undefined,
date: dateFilter ? dateFilter.format('YYYY-MM-DD') : undefined,
const fetcher = useCallback(
async (page: number, pageSize: number, filters: AppointmentFilters) => {
const dateStart = filters.dateRange?.[0]?.format('YYYY-MM-DD');
const dateEnd = filters.dateRange?.[1]?.format('YYYY-MM-DD');
return appointmentApi.list({
page,
page_size: pageSize,
status: filters.status || undefined,
date: dateStart === dateEnd ? dateStart : undefined,
patient_id: undefined, // 后端暂不支持 patientSearch 文本搜索
});
setData(result.data);
setTotal(result.total);
} catch {
message.error('加载预约列表失败');
} finally {
setLoading(false);
}
}, [page, pageSize, statusFilter, dateFilter]);
},
[],
);
useEffect(() => {
fetchData();
}, [fetchData]);
const {
data,
total,
page,
loading,
filters,
setFilters,
refresh,
} = usePaginatedData<Appointment, AppointmentFilters>(fetcher, {
pageSize: 20,
defaultFilters: {
status: undefined,
dateRange: null,
patientSearch: '',
appointmentType: undefined,
},
});
const handleFilterChange = useCallback(
(key: keyof AppointmentFilters, value: unknown) => {
setFilters((prev) => ({ ...prev, [key]: value }));
refresh(1);
},
[setFilters, refresh],
);
const resetFilters = useCallback(() => {
setFilters({
status: undefined,
dateRange: null,
patientSearch: '',
appointmentType: undefined,
});
refresh(1);
}, [setFilters, refresh]);
// ---- 状态变更 ----
const DESTRUCTIVE_STATUSES = new Set(['cancelled', 'no_show']);
@@ -143,7 +176,7 @@ export default function AppointmentList() {
...(newStatus === 'cancelled' && { cancel_reason: cancelReason }),
});
message.success('状态更新成功');
fetchData(page, pageSize);
refresh();
} catch {
message.error('状态更新失败');
}
@@ -162,7 +195,7 @@ export default function AppointmentList() {
version: record.version,
});
message.success('状态更新成功');
fetchData(page, pageSize);
refresh();
} catch {
message.error('状态更新失败');
}
@@ -237,7 +270,7 @@ export default function AppointmentList() {
form.resetFields();
setSelectedPatientId(undefined);
setSelectedDoctorId(undefined);
fetchData(page, pageSize);
refresh();
} catch {
message.error('创建预约失败');
}
@@ -250,16 +283,18 @@ export default function AppointmentList() {
dataIndex: 'patient_name',
key: 'patient_name',
width: 100,
render: (_: unknown, record: Appointment) =>
record.patient_name ?? record.patient_id.slice(0, 8),
render: (_: unknown, record: Appointment) => (
<EntityName name={record.patient_name} id={record.patient_id} />
),
},
{
title: '医护',
dataIndex: 'doctor_name',
key: 'doctor_name',
width: 100,
render: (_: unknown, record: Appointment) =>
record.doctor_name ?? record.doctor_id?.slice(0, 8) ?? '-',
render: (_: unknown, record: Appointment) => (
<EntityName name={record.doctor_name} id={record.doctor_id} />
),
},
{
title: '预约类型',
@@ -291,6 +326,13 @@ export default function AppointmentList() {
width: 100,
render: (val: string) => <StatusTag status={val} />,
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180,
render: (val: string) => formatDateTime(val),
},
{
title: '备注',
dataIndex: 'notes',
@@ -331,59 +373,62 @@ export default function AppointmentList() {
];
return (
<Card>
{/* 筛选栏 */}
<Row gutter={16} style={{ marginBottom: 16 }} align="middle">
<Col flex="auto">
<Space>
<Select
placeholder="筛选状态"
value={statusFilter}
onChange={(val) => {
setStatusFilter(val);
setPage(1);
}}
options={STATUS_OPTIONS}
allowClear
style={{ width: 140 }}
/>
<DatePicker
placeholder="筛选日期"
value={dateFilter}
onChange={(val) => {
setDateFilter(val);
setPage(1);
}}
allowClear
/>
</Space>
</Col>
<Col>
<AuthButton code="health.appointment.manage">
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</AuthButton>
</Col>
</Row>
{/* 数据表格 */}
<PageContainer
title="预约管理"
filters={
<Space wrap>
<Select
placeholder="筛选状态"
value={filters.status}
onChange={(val) => handleFilterChange('status', val)}
options={STATUS_OPTIONS}
allowClear
style={{ width: 140 }}
/>
<DatePicker.RangePicker
value={filters.dateRange as [Dayjs, Dayjs] | null}
onChange={(dates) => handleFilterChange('dateRange', dates)}
allowClear
/>
<Input
placeholder="搜索患者"
value={filters.patientSearch}
onChange={(e) => handleFilterChange('patientSearch', e.target.value)}
allowClear
style={{ width: 180 }}
/>
<Select
placeholder="预约类型"
value={filters.appointmentType}
onChange={(val) => handleFilterChange('appointmentType', val)}
options={APPOINTMENT_TYPE_OPTIONS}
allowClear
style={{ width: 120 }}
/>
</Space>
}
onResetFilters={resetFilters}
actions={
<AuthButton code="health.appointment.manage">
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</AuthButton>
}
>
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
scroll={{ x: 1000 }}
scroll={{ x: 1200 }}
pagination={{
current: page,
pageSize,
pageSize: 20,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
onChange: (p) => refresh(p),
}}
/>
@@ -466,6 +511,6 @@ export default function AppointmentList() {
</Form.Item>
</Form>
</Modal>
</Card>
</PageContainer>
);
}

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 {
Table,
@@ -15,7 +15,6 @@ import {
} from 'antd';
import {
PlusOutlined,
SearchOutlined,
EditOutlined,
DeleteOutlined,
SendOutlined,
@@ -31,8 +30,12 @@ import {
type ArticleStatus,
type ArticleTagItem,
} from '../../api/health/articles';
import { useThemeMode } from '../../hooks/useThemeMode';
import { AuthButton } from '../../components/AuthButton';
import { PageContainer } from '../../components/PageContainer';
import { usePaginatedData } from '../../hooks/usePaginatedData';
import { formatDateTime } from '../../utils/format';
// --- 常量 ---
const STATUS_TABS: { key: string; label: string }[] = [
{ key: '', label: '全部' },
@@ -42,84 +45,71 @@ const STATUS_TABS: { key: string; label: string }[] = [
{ key: 'rejected', label: '已拒绝' },
];
const STATUS_CONFIG: Record<
string,
{ label: string; color: string }
> = {
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
draft: { label: '草稿', color: 'default' },
pending_review: { label: '待审核', color: 'processing' },
published: { label: '已发布', color: 'success' },
rejected: { label: '已拒绝', color: 'error' },
};
// --- 筛选器 ---
interface ArticleFilters {
keyword: string;
status: string;
category_id: string;
}
const DEFAULT_FILTERS: ArticleFilters = {
keyword: '',
status: '',
category_id: '',
};
export default function ArticleManageList() {
const [articles, setArticles] = useState<ArticleListItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [statusTab, setStatusTab] = useState('');
const [categoryId, setCategoryId] = useState<string | undefined>(undefined);
const [keyword, setKeyword] = useState('');
const navigate = useNavigate();
const [categories, setCategories] = useState<{ id: string; name: string }[]>([]);
const [rejectModalOpen, setRejectModalOpen] = useState(false);
const [rejectingArticle, setRejectingArticle] = useState<ArticleListItem | null>(null);
const [rejectForm] = Form.useForm();
const isDark = useThemeMode();
const navigate = useNavigate();
const fetchArticles = useCallback(
async (p = page) => {
setLoading(true);
try {
const result = await articleApi.list({
page: p,
page_size: 20,
status: (statusTab || undefined) as ArticleStatus | undefined,
category_id: categoryId,
keyword: keyword || undefined,
});
setArticles(result.data);
setTotal(result.total);
} catch {
message.error('加载文章列表失败');
} finally {
setLoading(false);
}
// ---- 分页数据 Hook ----
const {
data,
total,
page,
loading,
filters,
setFilters,
refresh,
} = usePaginatedData<ArticleListItem, ArticleFilters>(
async (p, pageSize, f) => {
const result = await articleApi.list({
page: p,
page_size: pageSize,
status: (f.status || undefined) as ArticleStatus | undefined,
category_id: f.category_id || undefined,
keyword: f.keyword || undefined,
});
return result;
},
[page, statusTab, categoryId, keyword],
{ pageSize: 20, defaultFilters: { ...DEFAULT_FILTERS } },
);
const fetchCategories = useCallback(async () => {
try {
const cats = await articleCategoryApi.list();
setCategories(cats.map((c) => ({ id: c.id, name: c.name })));
} catch {
// 分类列表加载失败不阻塞页面
}
// ---- 分类列表 ----
useEffect(() => {
articleCategoryApi.list()
.then((cats) => setCategories(cats.map((c) => ({ id: c.id, name: c.name }))))
.catch(() => {});
}, []);
useEffect(() => {
fetchArticles();
}, [fetchArticles]);
useEffect(() => {
fetchCategories();
}, [fetchCategories]);
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const debouncedSearch = useCallback((value: string) => {
setKeyword(value);
if (debounceTimer.current) clearTimeout(debounceTimer.current);
debounceTimer.current = setTimeout(() => {
setPage(1);
}, 300);
}, []);
// ---- 操作 ----
const handleDelete = async (id: string) => {
try {
await articleApi.delete(id);
message.success('文章已删除');
fetchArticles();
refresh();
} catch {
message.error('删除失败');
}
@@ -129,7 +119,7 @@ export default function ArticleManageList() {
try {
await articleApi.submit(record.id, record.version);
message.success('已提交审核');
fetchArticles();
refresh();
} catch {
message.error('提交审核失败');
}
@@ -139,7 +129,7 @@ export default function ArticleManageList() {
try {
await articleApi.approve(record.id, record.version);
message.success('审核通过,文章已发布');
fetchArticles();
refresh();
} catch {
message.error('审核操作失败');
}
@@ -154,14 +144,10 @@ export default function ArticleManageList() {
const handleReject = async (values: { review_note: string }) => {
if (!rejectingArticle) return;
try {
await articleApi.reject(
rejectingArticle.id,
rejectingArticle.version,
values.review_note,
);
await articleApi.reject(rejectingArticle.id, rejectingArticle.version, values.review_note);
message.success('已拒绝文章');
setRejectModalOpen(false);
fetchArticles();
refresh();
} catch {
message.error('拒绝操作失败');
}
@@ -171,7 +157,7 @@ export default function ArticleManageList() {
try {
await articleApi.unpublish(record.id, record.version);
message.success('文章已撤回为草稿');
fetchArticles();
refresh();
} catch {
message.error('撤回操作失败');
}
@@ -254,6 +240,8 @@ export default function ArticleManageList() {
</Space>
);
// ---- 列定义 ----
const columns = [
{
title: '标题',
@@ -284,7 +272,7 @@ export default function ArticleManageList() {
<div
style={{
fontSize: 12,
color: isDark ? '#64748b' : '#94a3b8',
color: 'var(--ant-color-text-secondary, #94a3b8)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
@@ -303,7 +291,7 @@ export default function ArticleManageList() {
dataIndex: 'category_name',
key: 'category_name',
width: 120,
render: (v?: string) => v || <span style={{ color: isDark ? '#475569' : '#cbd5e1' }}></span>,
render: (v?: string) => v || <span style={{ color: 'var(--ant-color-text-quaternary, #cbd5e1)' }}></span>,
},
{
title: '标签',
@@ -311,23 +299,11 @@ export default function ArticleManageList() {
key: 'tags',
width: 180,
render: (tags?: ArticleTagItem[]) => {
if (!tags || tags.length === 0) {
return <span style={{ color: isDark ? '#475569' : '#cbd5e1' }}>-</span>;
}
if (!tags || tags.length === 0) return '-';
return (
<Space size={4} wrap>
{tags.map((t) => (
<Tag
key={t.id}
style={{
fontSize: 12,
background: isDark ? '#0f172a' : '#f0f9ff',
border: `1px solid ${isDark ? '#1e3a5f' : '#bae6fd'}`,
color: isDark ? '#7dd3fc' : '#0369a1',
}}
>
{t.name}
</Tag>
<Tag key={t.id}>{t.name}</Tag>
))}
</Space>
);
@@ -357,7 +333,7 @@ export default function ArticleManageList() {
width: 80,
render: (v: number) => (
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<EyeOutlined style={{ fontSize: 12, color: isDark ? '#64748b' : '#94a3b8' }} />
<EyeOutlined style={{ fontSize: 12 }} />
{v ?? 0}
</span>
),
@@ -367,7 +343,7 @@ export default function ArticleManageList() {
dataIndex: 'published_at',
key: 'published_at',
width: 170,
render: (v: string) => (v ? new Date(v).toLocaleString('zh-CN') : '-'),
render: (v: string) => (v ? formatDateTime(v) : '-'),
},
{
title: '操作',
@@ -378,13 +354,38 @@ export default function ArticleManageList() {
];
return (
<div>
{/* 页面标题和工具栏 */}
<div className="erp-page-header">
<div>
<h4></h4>
<div className="erp-page-subtitle"></div>
</div>
<PageContainer
title="内容管理"
subtitle="管理健康科普文章、资讯和内容发布"
filters={
<>
<Input
placeholder="搜索文章标题..."
value={filters.keyword}
onChange={(e) => {
setFilters((prev) => ({ ...prev, keyword: e.target.value }));
}}
allowClear
style={{ width: 220 }}
/>
<Select
value={filters.category_id || undefined}
onChange={(v) => {
setFilters((prev) => ({ ...prev, category_id: v ?? '' }));
refresh(1);
}}
placeholder="选择分类"
allowClear
style={{ width: 160 }}
options={categories.map((c) => ({ label: c.name, value: c.id }))}
/>
</>
}
onResetFilters={() => {
setFilters({ ...DEFAULT_FILTERS });
refresh(1);
}}
actions={
<AuthButton code="health.articles.manage">
<Button
type="primary"
@@ -394,79 +395,31 @@ export default function ArticleManageList() {
</Button>
</AuthButton>
</div>
{/* 筛选栏 */}
<div
style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
padding: '12px 16px',
marginBottom: 16,
display: 'flex',
alignItems: 'center',
gap: 12,
flexWrap: 'wrap',
}
loading={loading}
>
<Tabs
activeKey={filters.status}
onChange={(key) => {
setFilters((prev) => ({ ...prev, status: key }));
refresh(1);
}}
>
<Input
placeholder="搜索文章标题..."
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
value={keyword}
onChange={(e) => debouncedSearch(e.target.value)}
allowClear
style={{ width: 220, borderRadius: 8 }}
/>
<Select
value={categoryId}
onChange={(v) => {
setCategoryId(v);
setPage(1);
}}
placeholder="选择分类"
allowClear
style={{ width: 160, borderRadius: 8 }}
options={categories.map((c) => ({ label: c.name, value: c.id }))}
/>
</div>
{/* 状态标签页 + 表格 */}
<div
style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden',
items={STATUS_TABS.map((tab) => ({ key: tab.key, label: tab.label }))}
style={{ marginBottom: 0 }}
/>
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
onChange={(pagination) => refresh(pagination.current ?? 1)}
pagination={{
current: page,
pageSize: 20,
total,
showTotal: (t) => `${t} 条记录`,
}}
>
<Tabs
activeKey={statusTab}
onChange={(key) => {
setStatusTab(key);
setPage(1);
}}
items={STATUS_TABS.map((tab) => ({ key: tab.key, label: tab.label }))}
style={{ padding: '0 16px', marginBottom: 0 }}
/>
<Table
columns={columns}
dataSource={articles}
rowKey="id"
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => {
setPage(p);
fetchArticles(p);
},
showTotal: (t) => `${t} 条记录`,
style: { padding: '12px 16px', margin: 0 },
}}
/>
</div>
/>
{/* 拒绝理由弹窗 */}
<Modal
@@ -493,6 +446,6 @@ export default function ArticleManageList() {
</Form.Item>
</Form>
</Modal>
</div>
</PageContainer>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useCallback } from 'react';
import {
Table,
Select,
@@ -8,6 +8,7 @@ import {
Space,
Popconfirm,
message,
DatePicker,
} from 'antd';
import { PlusOutlined, CloseCircleOutlined } from '@ant-design/icons';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
@@ -17,8 +18,11 @@ import { StatusTag } from './components/StatusTag';
import { PatientSelect } from './components/PatientSelect';
import { DoctorSelect } from './components/DoctorSelect';
import { ExportButton } from './components/ExportButton';
import { useThemeMode } from '../../hooks/useThemeMode';
import { AuthButton } from '../../components/AuthButton';
import { PageContainer } from '../../components/PageContainer';
import { EntityName } from '../../components/EntityName';
import { formatDateTime } from '../../utils/format';
import { usePaginatedData } from '../../hooks/usePaginatedData';
const STATUS_OPTIONS = [
{ value: 'waiting', label: '等待中' },
@@ -38,66 +42,52 @@ const CONSULTATION_TYPE_MAP: Record<string, string> = {
health_consultation: '健康咨询',
};
function formatDateTime(value: string | undefined): string {
if (!value) return '-';
return new Date(value).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
interface ConsultationFilters {
status?: string;
dateRange?: [string, string];
}
export default function ConsultationList() {
const navigate = useNavigate();
const [sessions, setSessions] = useState<Session[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState<{ page: number; page_size: number; status?: string }>({
page: 1,
page_size: 20,
});
// Close session
const [closingId, setClosingId] = useState<string | null>(null);
// Create modal
const [createOpen, setCreateOpen] = useState(false);
const [createLoading, setCreateLoading] = useState(false);
const [createForm] = Form.useForm<CreateSessionReq>();
// Close session
const [closingId, setClosingId] = useState<string | null>(null);
// --- Paginated data with usePaginatedData ---
const fetchFn = useCallback(
async (page: number, pageSize: number, filters: ConsultationFilters) => {
const params: Record<string, unknown> = { page, page_size: pageSize };
if (filters.status) params.status = filters.status;
if (filters.dateRange) {
params.created_start = filters.dateRange[0];
params.created_end = filters.dateRange[1];
}
return consultationApi.listSessions(params as Parameters<typeof consultationApi.listSessions>[0]);
},
[],
);
const isDark = useThemeMode();
// --- Data fetching ---
const fetchSessions = useCallback(async (params: { page: number; page_size: number; status?: string }) => {
setLoading(true);
try {
const result = await consultationApi.listSessions(params);
setSessions(result.data);
setTotal(result.total);
} catch {
message.error('加载咨询列表失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchSessions(query);
}, [query, fetchSessions]);
const {
data: sessions,
total,
page,
loading,
filters,
setFilters,
refresh,
} = usePaginatedData<Session, ConsultationFilters>(fetchFn, {
pageSize: 20,
defaultFilters: {},
});
// --- Handlers ---
const handleFilterChange = (value: string | undefined) => {
setQuery((prev) => ({ ...prev, status: value || undefined, page: 1 }));
};
const handleTableChange = (pagination: TablePaginationConfig) => {
setQuery((prev) => ({
...prev,
page: pagination.current ?? 1,
page_size: pagination.pageSize ?? 20,
}));
refresh(pagination.current ?? 1);
};
// Create session
@@ -109,7 +99,7 @@ export default function ConsultationList() {
message.success('咨询会话创建成功');
setCreateOpen(false);
createForm.resetFields();
fetchSessions(query);
refresh(page);
} catch (err: unknown) {
if (err && typeof err === 'object' && 'errorFields' in err) return;
message.error('创建咨询会话失败');
@@ -124,7 +114,7 @@ export default function ConsultationList() {
try {
await consultationApi.closeSession(session.id, { version: session.version });
message.success('会话已关闭');
fetchSessions(query);
refresh(page);
} catch {
message.error('关闭会话失败');
} finally {
@@ -139,7 +129,7 @@ export default function ConsultationList() {
// Export params
const exportParams: Record<string, string> = {};
if (query.status) exportParams.status = query.status;
if (filters.status) exportParams.status = filters.status;
// --- Columns ---
const columns: ColumnsType<Session> = [
@@ -148,16 +138,18 @@ export default function ConsultationList() {
dataIndex: 'patient_name',
key: 'patient_name',
width: 140,
render: (_: unknown, record: Session) =>
record.patient_name ?? record.patient_id.slice(0, 8),
render: (_: unknown, record: Session) => (
<EntityName name={record.patient_name} id={record.patient_id} />
),
},
{
title: '医护',
dataIndex: 'doctor_name',
key: 'doctor_name',
width: 140,
render: (_: unknown, record: Session) =>
record.doctor_name ?? record.doctor_id?.slice(0, 8) ?? '-',
render: (_: unknown, record: Session) => (
<EntityName name={record.doctor_name} id={record.doctor_id} fallbackLabel="未分配" />
),
},
{
title: '咨询类型',
@@ -188,22 +180,14 @@ export default function ConsultationList() {
dataIndex: 'last_message_at',
key: 'last_message_at',
width: 160,
render: (v: string | undefined) => (
<span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>
{formatDateTime(v)}
</span>
),
render: (v: string | undefined) => formatDateTime(v),
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 160,
render: (v: string) => (
<span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>
{formatDateTime(v)}
</span>
),
render: (v: string) => formatDateTime(v),
},
{
title: '操作',
@@ -237,85 +221,76 @@ export default function ConsultationList() {
];
return (
<div>
{/* Toolbar */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
marginBottom: 16,
padding: 12,
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 10,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
}}
>
<Select
allowClear
placeholder="状态筛选"
style={{ width: 160 }}
options={STATUS_OPTIONS}
value={query.status}
onChange={handleFilterChange}
/>
<AuthButton code="health.consultation.manage">
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
createForm.resetFields();
setCreateOpen(true);
<PageContainer
title="咨询管理"
subtitle={`${total}`}
filters={
<>
<Select
allowClear
placeholder="状态筛选"
style={{ width: 140 }}
options={STATUS_OPTIONS}
value={filters.status}
onChange={(value) => setFilters((prev) => ({ ...prev, status: value }))}
/>
<DatePicker.RangePicker
style={{ width: 240 }}
onChange={(dates) => {
if (dates && dates[0] && dates[1]) {
setFilters((prev) => ({
...prev,
dateRange: [dates[0]!.format('YYYY-MM-DD'), dates[1]!.format('YYYY-MM-DD')],
}));
} else {
setFilters((prev) => ({ ...prev, dateRange: undefined }));
}
}}
>
</Button>
</AuthButton>
<ExportButton
fetchUrl="/health/consultation-sessions/export"
params={exportParams}
filename="咨询列表.csv"
/>
<span
style={{
fontSize: 13,
color: isDark ? '#475569' : '#94a3b8',
marginLeft: 'auto',
}}
>
{total}
</span>
</div>
{/* Table */}
<div
style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden',
/>
</>
}
onResetFilters={() => setFilters({})}
actions={
<Space>
<AuthButton code="health.consultation.manage">
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
createForm.resetFields();
setCreateOpen(true);
}}
>
</Button>
</AuthButton>
<ExportButton
fetchUrl="/health/consultation-sessions/export"
params={exportParams}
filename="咨询列表.csv"
/>
</Space>
}
>
<Table
rowKey="id"
columns={columns}
dataSource={sessions}
loading={loading}
onChange={handleTableChange}
onRow={(record) => ({
onClick: () => handleRowClick(record),
style: { cursor: 'pointer' },
})}
pagination={{
current: page,
pageSize: 20,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
}}
>
<Table
rowKey="id"
columns={columns}
dataSource={sessions}
loading={loading}
onChange={handleTableChange}
onRow={(record) => ({
onClick: () => handleRowClick(record),
style: { cursor: 'pointer' },
})}
pagination={{
current: query.page,
pageSize: query.page_size,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
}}
scroll={{ x: 1010 }}
/>
</div>
scroll={{ x: 1010 }}
/>
{/* Create Session Modal */}
<Modal
@@ -348,6 +323,6 @@ export default function ConsultationList() {
</Form.Item>
</Form>
</Modal>
</div>
</PageContainer>
);
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { useState, useCallback, useRef } from 'react';
import {
Table,
Button,
@@ -10,7 +10,6 @@ import {
Badge,
Popconfirm,
message,
Card,
Row,
Col,
} from 'antd';
@@ -20,9 +19,12 @@ import {
EditOutlined,
DeleteOutlined,
} from '@ant-design/icons';
import { dayjs } from '../../utils/dayjs';
import { doctorApi, type Doctor, type CreateDoctorReq, type UpdateDoctorReq } from '../../api/health/doctors';
import { AuthButton } from '../../components/AuthButton';
import { PageContainer } from '../../components/PageContainer';
import { EntityName } from '../../components/EntityName';
import { formatDateTime } from '../../utils/format';
import { usePaginatedData } from '../../hooks/usePaginatedData';
/** 科室选项 — 可后续改为从字典接口获取 */
const DEPARTMENT_OPTIONS = [
@@ -51,54 +53,78 @@ const TITLE_OPTIONS = [
{ value: '主任护师', label: '主任护师' },
];
const STATUS_OPTIONS = [
{ value: 'online', label: '在线' },
{ value: 'offline', label: '离线' },
{ value: 'busy', label: '忙碌' },
];
const ONLINE_STATUS_MAP: Record<string, { status: 'success' | 'default' | 'processing'; text: string }> = {
online: { status: 'success', text: '在线' },
offline: { status: 'default', text: '离线' },
busy: { status: 'processing', text: '忙碌' },
};
/** 筛选器类型 */
interface DoctorFilters {
search: string;
department: string | undefined;
title: string | undefined;
status: string | undefined;
}
export default function DoctorList() {
const [data, setData] = useState<Doctor[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [loading, setLoading] = useState(false);
const [searchText, setSearchText] = useState('');
const [deptFilter, setDeptFilter] = useState<string | undefined>(undefined);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Doctor | null>(null);
const [form] = Form.useForm();
// ---- 数据获取 ----
const fetchData = useCallback(async (p = page, ps = pageSize) => {
setLoading(true);
try {
const result = await doctorApi.list({
page: p,
page_size: ps,
search: searchText || undefined,
department: deptFilter || undefined,
const fetcher = useCallback(
async (page: number, pageSize: number, filters: DoctorFilters) => {
return doctorApi.list({
page,
page_size: pageSize,
search: filters.search || undefined,
department: filters.department || undefined,
title: filters.title || undefined,
});
setData(result.data);
setTotal(result.total);
} catch {
message.error('加载医护列表失败');
} finally {
setLoading(false);
}
}, [page, pageSize, searchText, deptFilter]);
},
[],
);
useEffect(() => {
fetchData();
}, [fetchData]);
const {
data,
total,
page,
loading,
filters,
setFilters,
refresh,
} = usePaginatedData<Doctor, DoctorFilters>(fetcher, {
pageSize: 20,
defaultFilters: { search: '', department: undefined, title: undefined, status: undefined },
});
// ---- 搜索防抖 ----
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleSearchChange = useCallback((val: string) => {
setSearchText(val);
setFilters((prev) => ({ ...prev, search: val }));
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => setPage(1), 300);
}, []);
debounceRef.current = setTimeout(() => refresh(1), 300);
}, [setFilters, refresh]);
const handleFilterChange = useCallback(
(key: keyof DoctorFilters, value: string | undefined) => {
setFilters((prev) => ({ ...prev, [key]: value }));
refresh(1);
},
[setFilters, refresh],
);
const resetFilters = useCallback(() => {
setFilters({ search: '', department: undefined, title: undefined, status: undefined });
refresh(1);
}, [setFilters, refresh]);
// ---- 新建 / 编辑 ----
const openCreate = () => {
@@ -155,7 +181,7 @@ export default function DoctorList() {
}
setModalOpen(false);
form.resetFields();
fetchData(page, pageSize);
refresh();
} catch {
message.error(editing ? '更新失败' : '创建失败');
}
@@ -166,7 +192,7 @@ export default function DoctorList() {
try {
await doctorApi.delete(id);
message.success('删除成功');
fetchData(page, pageSize);
refresh();
} catch {
message.error('删除失败');
}
@@ -210,6 +236,18 @@ export default function DoctorList() {
width: 150,
render: (val: string) => val || '-',
},
{
title: '关联用户',
dataIndex: 'user_id',
key: 'user_id',
width: 120,
render: (_: unknown, record: Doctor) =>
record.user_id ? (
<EntityName name={record.name} id={record.user_id} fallbackLabel="已关联" />
) : (
'-'
),
},
{
title: '在线状态',
dataIndex: 'online_status',
@@ -225,7 +263,7 @@ export default function DoctorList() {
dataIndex: 'created_at',
key: 'created_at',
width: 180,
render: (val: string) => (val ? dayjs(val).format('YYYY-MM-DD HH:mm') : '-'),
render: (val: string) => formatDateTime(val),
},
{
title: '操作',
@@ -260,58 +298,66 @@ export default function DoctorList() {
];
return (
<Card>
{/* 筛选栏 */}
<Row gutter={16} style={{ marginBottom: 16 }} align="middle">
<Col flex="auto">
<Space>
<Input
placeholder="搜索姓名"
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => handleSearchChange(e.target.value)}
allowClear
style={{ width: 220 }}
/>
<Select
placeholder="筛选科室"
value={deptFilter}
onChange={(val) => {
setDeptFilter(val);
setPage(1);
}}
options={DEPARTMENT_OPTIONS}
allowClear
style={{ width: 160 }}
/>
</Space>
</Col>
<Col>
<AuthButton code="health.doctor.manage">
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</AuthButton>
</Col>
</Row>
{/* 数据表格 */}
<PageContainer
title="医护管理"
filters={
<Space wrap>
<Input
placeholder="搜索姓名"
prefix={<SearchOutlined />}
value={filters.search}
onChange={(e) => handleSearchChange(e.target.value)}
allowClear
style={{ width: 220 }}
/>
<Select
placeholder="筛选科室"
value={filters.department}
onChange={(val) => handleFilterChange('department', val)}
options={DEPARTMENT_OPTIONS}
allowClear
style={{ width: 160 }}
/>
<Select
placeholder="筛选职称"
value={filters.title}
onChange={(val) => handleFilterChange('title', val)}
options={TITLE_OPTIONS}
allowClear
style={{ width: 160 }}
/>
<Select
placeholder="在线状态"
value={filters.status}
onChange={(val) => handleFilterChange('status', val)}
options={STATUS_OPTIONS}
allowClear
style={{ width: 120 }}
/>
</Space>
}
onResetFilters={resetFilters}
actions={
<AuthButton code="health.doctor.manage">
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</AuthButton>
}
>
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
scroll={{ x: 1100 }}
scroll={{ x: 1300 }}
pagination={{
current: page,
pageSize,
pageSize: 20,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
onChange: (p) => refresh(p),
}}
/>
@@ -358,6 +404,6 @@ export default function DoctorList() {
</Form.Item>
</Form>
</Modal>
</Card>
</PageContainer>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useCallback } from 'react';
import {
Table,
Select,
@@ -18,8 +18,11 @@ import { followUpApi, type FollowUpTask, type CreateFollowUpTaskReq, type Update
import { StatusTag } from './components/StatusTag';
import { PatientSelect } from './components/PatientSelect';
import { DoctorSelect } from './components/DoctorSelect';
import { useThemeMode } from '../../hooks/useThemeMode';
import { AuthButton } from '../../components/AuthButton';
import { PageContainer } from '../../components/PageContainer';
import { EntityName } from '../../components/EntityName';
import { formatDate, formatDateTime } from '../../utils/format';
import { usePaginatedData } from '../../hooks/usePaginatedData';
const STATUS_OPTIONS = [
{ value: 'pending', label: '待处理' },
@@ -45,14 +48,11 @@ const FOLLOW_UP_TYPE_MAP: Record<string, string> = {
wechat: '微信',
};
function formatDateTime(value: string): string {
return new Date(value).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
interface FollowUpFilters {
status?: string;
dateRange?: [string, string];
followUpType?: string;
assigneeId?: string;
}
interface RecordFormValues {
@@ -68,12 +68,33 @@ interface AssignFormValues {
}
export default function FollowUpTaskList() {
const [tasks, setTasks] = useState<FollowUpTask[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState<{ page: number; page_size: number; status?: string }>({
page: 1,
page_size: 20,
// --- Paginated data with usePaginatedData ---
const fetchFn = useCallback(
async (page: number, pageSize: number, filters: FollowUpFilters) => {
const params: Record<string, unknown> = { page, page_size: pageSize };
if (filters.status) params.status = filters.status;
if (filters.followUpType) params.follow_up_type = filters.followUpType;
if (filters.assigneeId) params.assigned_to = filters.assigneeId;
if (filters.dateRange) {
params.planned_date_start = filters.dateRange[0];
params.planned_date_end = filters.dateRange[1];
}
return followUpApi.listTasks(params as Parameters<typeof followUpApi.listTasks>[0]);
},
[],
);
const {
data: tasks,
total,
page,
loading,
filters,
setFilters,
refresh,
} = usePaginatedData<FollowUpTask, FollowUpFilters>(fetchFn, {
pageSize: 20,
defaultFilters: {},
});
// Create task modal
@@ -93,37 +114,9 @@ export default function FollowUpTaskList() {
const [assignForm] = Form.useForm<AssignFormValues>();
const [assignTask, setAssignTask] = useState<FollowUpTask | null>(null);
const isDark = useThemeMode();
// --- Data fetching ---
const fetchTasks = useCallback(async (params: { page: number; page_size: number; status?: string }) => {
setLoading(true);
try {
const result = await followUpApi.listTasks(params);
setTasks(result.data);
setTotal(result.total);
} catch {
message.error('加载随访任务失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchTasks(query);
}, [query, fetchTasks]);
// --- Handlers ---
const handleFilterChange = (field: 'status', value: string | undefined) => {
setQuery((prev) => ({ ...prev, [field]: value || undefined, page: 1 }));
};
const handleTableChange = (pagination: TablePaginationConfig) => {
setQuery((prev) => ({
...prev,
page: pagination.current ?? 1,
page_size: pagination.pageSize ?? 20,
}));
refresh(pagination.current ?? 1);
};
// Create task
@@ -142,7 +135,7 @@ export default function FollowUpTaskList() {
message.success('随访任务创建成功');
setCreateOpen(false);
createForm.resetFields();
fetchTasks(query);
refresh(page);
} catch (err: unknown) {
if (err && typeof err === 'object' && 'errorFields' in err) return; // form validation
message.error('创建随访任务失败');
@@ -173,7 +166,7 @@ export default function FollowUpTaskList() {
message.success('随访记录填写成功');
setRecordOpen(false);
setActiveTask(null);
fetchTasks(query);
refresh(page);
} catch (err: unknown) {
if (err && typeof err === 'object' && 'errorFields' in err) return;
message.error('填写随访记录失败');
@@ -205,7 +198,7 @@ export default function FollowUpTaskList() {
message.success('分配成功');
setAssignOpen(false);
setAssignTask(null);
fetchTasks(query);
refresh(page);
} catch (err: unknown) {
if (err && typeof err === 'object' && 'errorFields' in err) return;
message.error('分配失败');
@@ -219,19 +212,12 @@ export default function FollowUpTaskList() {
try {
await followUpApi.deleteTask(record.id, record.version);
message.success('删除成功');
fetchTasks(query);
refresh(page);
} catch {
message.error('删除失败');
}
};
// Store labels from selects for immediate display
const handleDoctorLabel = (id: string, label: string) => {
setTasks((prev) =>
prev.map((t) => (t.assigned_to === id ? { ...t, assigned_to_name: label } : t)),
);
};
// --- Columns ---
const columns: ColumnsType<FollowUpTask> = [
{
@@ -239,8 +225,9 @@ export default function FollowUpTaskList() {
dataIndex: 'patient_name',
key: 'patient_name',
width: 140,
render: (_: unknown, record: FollowUpTask) =>
record.patient_name ?? record.patient_id.slice(0, 8),
render: (_: unknown, record: FollowUpTask) => (
<EntityName name={record.patient_name} id={record.patient_id} />
),
},
{
title: '随访类型',
@@ -254,7 +241,7 @@ export default function FollowUpTaskList() {
dataIndex: 'planned_date',
key: 'planned_date',
width: 120,
render: (v: string) => v,
render: (v: string) => formatDate(v),
},
{
title: '状态',
@@ -268,19 +255,20 @@ export default function FollowUpTaskList() {
dataIndex: 'assigned_to',
key: 'assigned_to',
width: 140,
render: (_: unknown, record: FollowUpTask) =>
record.assigned_to ? record.assigned_to_name || record.assigned_to.slice(0, 8) : '-',
render: (_: unknown, record: FollowUpTask) => (
<EntityName
name={record.assigned_to_name}
id={record.assigned_to}
fallbackLabel="未分配"
/>
),
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 160,
render: (v: string) => (
<span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>
{formatDateTime(v)}
</span>
),
render: (v: string) => formatDateTime(v),
},
{
title: '操作',
@@ -322,28 +310,53 @@ export default function FollowUpTaskList() {
];
return (
<div>
{/* Toolbar */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
marginBottom: 16,
padding: 12,
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 10,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
}}
>
<Select
allowClear
placeholder="状态筛选"
style={{ width: 160 }}
options={STATUS_OPTIONS}
value={query.status}
onChange={(value) => handleFilterChange('status', value)}
/>
<PageContainer
title="随访管理"
subtitle={`${total}`}
filters={
<>
<Select
allowClear
placeholder="状态筛选"
style={{ width: 140 }}
options={STATUS_OPTIONS}
value={filters.status}
onChange={(value) => setFilters((prev) => ({ ...prev, status: value }))}
/>
<DatePicker.RangePicker
style={{ width: 240 }}
onChange={(dates) => {
if (dates && dates[0] && dates[1]) {
setFilters((prev) => ({
...prev,
dateRange: [dates[0]!.format('YYYY-MM-DD'), dates[1]!.format('YYYY-MM-DD')],
}));
} else {
setFilters((prev) => ({ ...prev, dateRange: undefined }));
}
}}
/>
<Select
allowClear
placeholder="随访类型"
style={{ width: 120 }}
options={FOLLOW_UP_TYPE_OPTIONS}
value={filters.followUpType}
onChange={(value) => setFilters((prev) => ({ ...prev, followUpType: value }))}
/>
<Select
allowClear
placeholder="负责人"
style={{ width: 140 }}
// Note: Assignee select would ideally use DoctorSelect options
// but Select with async search needs separate handling
value={filters.assigneeId}
onChange={(value) => setFilters((prev) => ({ ...prev, assigneeId: value }))}
/>
</>
}
onResetFilters={() => setFilters({})}
actions={
<AuthButton code="health.follow-up.manage">
<Button
type="primary"
@@ -356,42 +369,23 @@ export default function FollowUpTaskList() {
</Button>
</AuthButton>
<span
style={{
fontSize: 13,
color: isDark ? '#475569' : '#94a3b8',
marginLeft: 'auto',
}}
>
{total}
</span>
</div>
{/* Table */}
<div
style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden',
}
>
<Table
rowKey="id"
columns={columns}
dataSource={tasks}
loading={loading}
onChange={handleTableChange}
pagination={{
current: page,
pageSize: 20,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
}}
>
<Table
rowKey="id"
columns={columns}
dataSource={tasks}
loading={loading}
onChange={handleTableChange}
pagination={{
current: query.page,
pageSize: query.page_size,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
}}
scroll={{ x: 980 }}
/>
</div>
scroll={{ x: 980 }}
/>
{/* Create Task Modal */}
<Modal
@@ -427,9 +421,7 @@ export default function FollowUpTaskList() {
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="assigned_to" label="负责人">
<DoctorSelect
onChange={(_val, label) => handleDoctorLabel(_val, label)}
/>
<DoctorSelect />
</Form.Item>
<Form.Item name="content_template" label="内容模板">
<Input.TextArea rows={3} placeholder="随访内容模板(可选)" />
@@ -499,12 +491,10 @@ export default function FollowUpTaskList() {
label="负责人"
rules={[{ required: true, message: '请选择负责人' }]}
>
<DoctorSelect
onChange={(_val, label) => handleDoctorLabel(_val, label)}
/>
<DoctorSelect />
</Form.Item>
</Form>
</Modal>
</div>
</PageContainer>
);
}

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 {
Table,
@@ -14,7 +14,6 @@ import {
} from 'antd';
import {
PlusOutlined,
SearchOutlined,
EditOutlined,
DeleteOutlined,
} from '@ant-design/icons';
@@ -26,54 +25,83 @@ import type {
} from '../../api/health/patients';
import { StatusTag } from './components/StatusTag';
import { GENDER_OPTIONS, BLOOD_TYPE_OPTIONS, STATUS_OPTIONS } from '../../constants/health';
import { useThemeMode } from '../../hooks/useThemeMode';
import { AuthButton } from '../../components/AuthButton';
import { PageContainer } from '../../components/PageContainer';
import { usePaginatedData } from '../../hooks/usePaginatedData';
import { calcAge, formatDateTime } from '../../utils/format';
/** 筛选器结构 */
interface PatientFilters {
search: string;
status: string;
gender: string;
dateRange: [string, string] | null;
}
const DEFAULT_FILTERS: PatientFilters = {
search: '',
status: '',
gender: '',
dateRange: null,
};
export default function PatientList() {
const [patients, setPatients] = useState<PatientListItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [searchText, setSearchText] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const navigate = useNavigate();
const [modalOpen, setModalOpen] = useState(false);
const [editingPatient, setEditingPatient] = useState<PatientListItem | null>(null);
const [form] = Form.useForm();
const isDark = useThemeMode();
const navigate = useNavigate();
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
const fetchPatients = useCallback(
async (p = page) => {
setLoading(true);
try {
const result = await patientApi.list({
page: p,
page_size: 20,
search: searchText || undefined,
status: statusFilter || undefined,
});
setPatients(result.data);
setTotal(result.total);
} catch {
message.error('加载患者列表失败');
} finally {
setLoading(false);
}
// ---- 分页数据 Hook ----
const {
data: patients,
total,
page,
loading,
filters,
setFilters,
refresh,
} = usePaginatedData<PatientListItem, PatientFilters>(
async (p, pageSize, f) => {
const result = await patientApi.list({
page: p,
page_size: pageSize,
search: f.search || undefined,
status: f.status || undefined,
});
return result;
},
[page, searchText, statusFilter],
{ pageSize: 20, defaultFilters: { ...DEFAULT_FILTERS } },
);
// ---- 筛选回调 ----
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const debouncedSearch = useCallback(() => {
if (debounceTimer.current) clearTimeout(debounceTimer.current);
debounceTimer.current = setTimeout(() => {
setPage(1);
}, 300);
}, []);
useEffect(() => {
fetchPatients();
}, [fetchPatients]);
const handleSearchChange = useCallback(
(value: string) => {
setFilters((prev) => ({ ...prev, search: value }));
if (debounceTimer.current) clearTimeout(debounceTimer.current);
debounceTimer.current = setTimeout(() => {
refresh(1);
}, 300);
},
[setFilters, refresh],
);
const handleFilterChange = useCallback(
(key: keyof PatientFilters, value: string | [string, string] | null) => {
setFilters((prev) => ({ ...prev, [key]: value }));
refresh(1);
},
[setFilters, refresh],
);
const handleResetFilters = useCallback(() => {
setFilters({ ...DEFAULT_FILTERS });
refresh(1);
}, [setFilters, refresh]);
// ---- CRUD 操作 ----
const handleCreateOrEdit = async (values: {
name: string;
@@ -86,15 +114,22 @@ export default function PatientList() {
}) => {
const formatted = {
...values,
birth_date: values.birth_date && typeof values.birth_date === 'object' && 'format' in (values.birth_date as object)
? (values.birth_date as { format: (f: string) => string }).format('YYYY-MM-DD')
: (values.birth_date as string | undefined),
birth_date:
values.birth_date &&
typeof values.birth_date === 'object' &&
'format' in (values.birth_date as object)
? (values.birth_date as { format: (f: string) => string }).format(
'YYYY-MM-DD',
)
: (values.birth_date as string | undefined),
};
try {
if (editingPatient) {
const req: UpdatePatientReq & { version: number } = {
...formatted,
version: (editingPatient as PatientListItem & { version?: number }).version ?? 0,
version:
(editingPatient as PatientListItem & { version?: number })
.version ?? 0,
};
await patientApi.update(editingPatient.id, req);
message.success('患者信息更新成功');
@@ -104,7 +139,7 @@ export default function PatientList() {
message.success('患者创建成功');
}
closeModal();
fetchPatients();
refresh();
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data
@@ -116,10 +151,11 @@ export default function PatientList() {
const handleDelete = async (id: string) => {
try {
const patient = patients.find((p) => p.id === id);
const version = (patient as PatientListItem & { version?: number })?.version ?? 0;
const version =
(patient as PatientListItem & { version?: number })?.version ?? 0;
await patientApi.delete(id, version);
message.success('患者已删除');
fetchPatients();
refresh();
} catch {
message.error('删除失败');
}
@@ -148,6 +184,8 @@ export default function PatientList() {
form.resetFields();
};
// ---- 列定义 ----
const columns = [
{
title: '姓名',
@@ -173,11 +211,9 @@ export default function PatientList() {
</div>
<div>
<div style={{ fontWeight: 500, fontSize: 14 }}>{name}</div>
{record.source && (
<div style={{ fontSize: 12, color: isDark ? '#475569' : '#94a3b8' }}>
: {record.source}
</div>
)}
<div style={{ fontSize: 12, color: '#94a3b8' }}>
{record.source && <span>: {record.source}</span>}
</div>
</div>
</div>
),
@@ -189,16 +225,20 @@ export default function PatientList() {
width: 80,
render: (v?: string) => {
if (!v) return '-';
const map: Record<string, string> = { male: '男', female: '女', other: '其他' };
const map: Record<string, string> = {
male: '男',
female: '女',
other: '其他',
};
return map[v] || v;
},
},
{
title: '出生日期',
title: '年龄',
dataIndex: 'birth_date',
key: 'birth_date',
width: 120,
render: (v?: string) => v || '-',
width: 100,
render: (v?: string) => calcAge(v),
},
{
title: '血型',
@@ -209,31 +249,21 @@ export default function PatientList() {
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: string) => <StatusTag status={status} />,
},
{
title: '认证状态',
dataIndex: 'verification_status',
key: 'verification_status',
width: 100,
render: (v: string) => <StatusTag status={v} />,
},
{
title: '来源',
dataIndex: 'source',
key: 'source',
width: 100,
render: (v?: string) => v || '-',
width: 140,
render: (_: unknown, record: PatientListItem) => (
<Space size={4}>
<StatusTag status={record.status} />
<StatusTag status={record.verification_status} />
</Space>
),
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 170,
render: (v: string) => (v ? new Date(v).toLocaleString('zh-CN') : '-'),
width: 150,
render: (v: string) => formatDateTime(v),
},
{
title: '操作',
@@ -250,7 +280,6 @@ export default function PatientList() {
e.stopPropagation();
openEditModal(record);
}}
style={{ color: isDark ? '#94a3b8' : '#475569' }}
/>
<Popconfirm
title="确定删除此患者?"
@@ -274,75 +303,88 @@ export default function PatientList() {
];
return (
<div>
{/* 页面标题和工具栏 */}
<div className="erp-page-header">
<div>
<h4></h4>
<div className="erp-page-subtitle">
</div>
</div>
<Space size={8}>
<PageContainer
title="患者管理"
subtitle="管理患者档案、基本信息和认证状态"
filters={
<>
<Input
placeholder="搜索患者姓名..."
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
value={searchText}
onChange={(e) => {
setSearchText(e.target.value);
debouncedSearch();
}}
value={filters.search}
onChange={(e) => handleSearchChange(e.target.value)}
allowClear
style={{ width: 200, borderRadius: 8 }}
style={{ width: 200 }}
/>
<Select
value={statusFilter}
onChange={(v) => {
setStatusFilter(v);
setPage(1);
}}
placeholder="状态"
value={filters.status || undefined}
onChange={(v) => handleFilterChange('status', v ?? '')}
options={STATUS_OPTIONS}
style={{ width: 130, borderRadius: 8 }}
allowClear
style={{ width: 130 }}
/>
<AuthButton code="health.patient.manage">
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
</Button>
</AuthButton>
</Space>
</div>
{/* 表格容器 */}
<div
style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden',
<Select
placeholder="性别"
value={filters.gender || undefined}
onChange={(v) => handleFilterChange('gender', v ?? '')}
options={GENDER_OPTIONS}
allowClear
style={{ width: 120 }}
/>
<DatePicker.RangePicker
onChange={(dates) => {
if (dates && dates[0] && dates[1]) {
handleFilterChange('dateRange', [
dates[0].format('YYYY-MM-DD'),
dates[1].format('YYYY-MM-DD'),
]);
} else {
handleFilterChange('dateRange', null);
}
}}
placeholder={['开始日期', '结束日期']}
/>
</>
}
onResetFilters={handleResetFilters}
actions={
<AuthButton code="health.patient.manage">
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
</Button>
</AuthButton>
}
selectedCount={selectedRowKeys.length}
onClearSelection={() => setSelectedRowKeys([])}
batchActions={
<span style={{ fontSize: 13, color: '#94a3b8' }}>
{selectedRowKeys.length}
</span>
}
loading={loading}
>
<Table
columns={columns}
dataSource={patients}
rowKey="id"
loading={loading}
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys as string[]),
}}
>
<Table
columns={columns}
dataSource={patients}
rowKey="id"
loading={loading}
onRow={(record) => ({
onClick: () => navigate(`/health/patients/${record.id}`),
style: { cursor: 'pointer' },
})}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => {
setPage(p);
fetchPatients(p);
},
showTotal: (t) => `${t} 条记录`,
style: { padding: '12px 16px', margin: 0 },
}}
/>
</div>
onRow={(record) => ({
onClick: () => navigate(`/health/patients/${record.id}`),
style: { cursor: 'pointer' },
})}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => refresh(p),
showTotal: (t) => `${t} 条记录`,
style: { padding: '12px 16px', margin: 0 },
}}
/>
{/* 新建/编辑患者弹窗 */}
<Modal
@@ -372,7 +414,11 @@ export default function PatientList() {
<DatePicker style={{ width: '100%' }} placeholder="请选择出生日期" />
</Form.Item>
<Form.Item name="blood_type" label="血型">
<Select options={BLOOD_TYPE_OPTIONS} placeholder="请选择血型" allowClear />
<Select
options={BLOOD_TYPE_OPTIONS}
placeholder="请选择血型"
allowClear
/>
</Form.Item>
<Form.Item name="id_number" label="身份证号">
<Input placeholder="请输入身份证号" />
@@ -385,6 +431,6 @@ export default function PatientList() {
</Form.Item>
</Form>
</Modal>
</div>
</PageContainer>
);
}

View File

@@ -1,29 +1,31 @@
import { useEffect, useState, useCallback } from 'react';
import { useState, useCallback } from 'react';
import {
Table,
Button,
Space,
Modal,
Form,
Input,
Select,
Badge,
message,
Card,
Row,
Col,
Tag,
DatePicker,
} from 'antd';
import {
CheckCircleOutlined,
} from '@ant-design/icons';
import { dayjs } from '../../utils/dayjs';
import {
pointsApi,
type PointsOrder,
} from '../../api/health/points';
import { AuthButton } from '../../components/AuthButton';
import { PageContainer } from '../../components/PageContainer';
import { usePaginatedData } from '../../hooks/usePaginatedData';
import { useHealthStore } from '../../stores/health';
import { formatDateTime } from '../../utils/format';
import type { Dayjs } from 'dayjs';
import { dayjs } from '../../utils/dayjs';
/** 订单状态映射 */
const STATUS_MAP: Record<string, { text: string; color: string }> = {
@@ -45,43 +47,44 @@ function truncateId(id: string): string {
return id.length > 12 ? `${id.slice(0, 8)}...${id.slice(-4)}` : id;
}
interface OrderFilters {
status: string | undefined;
dateRange: [Dayjs, Dayjs] | undefined;
}
export default function PointsOrderList() {
const [data, setData] = useState<PointsOrder[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [loading, setLoading] = useState(false);
const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined);
const [verifyModalOpen, setVerifyModalOpen] = useState(false);
const [verifyForm] = Form.useForm();
const [verifying, setVerifying] = useState(false);
const { batchResolvePatientNames, getPatientName } = useHealthStore();
// ---- 数据获取 ----
const fetchData = useCallback(async (p = page, ps = pageSize) => {
setLoading(true);
try {
const fetchOrders = useCallback(
async (page: number, pageSize: number, filters: OrderFilters) => {
const result = await pointsApi.listOrders({
page: p,
page_size: ps,
status: statusFilter || undefined,
page,
page_size: pageSize,
status: filters.status || undefined,
});
setData(result.data);
setTotal(result.total);
const patientIds = result.data.map((o) => o.patient_id);
batchResolvePatientNames(patientIds);
} catch {
message.error('加载订单列表失败');
} finally {
setLoading(false);
}
}, [page, pageSize, statusFilter, batchResolvePatientNames]);
return { data: result.data, total: result.total };
},
[batchResolvePatientNames],
);
useEffect(() => {
fetchData();
}, [fetchData]);
const {
data,
total,
page,
loading,
filters,
setFilters,
refresh,
} = usePaginatedData<PointsOrder, OrderFilters>(
fetchOrders,
{ pageSize: 20, defaultFilters: { status: undefined, dateRange: undefined } },
);
// ---- 核销 ----
const openVerifyModal = () => {
@@ -96,7 +99,7 @@ export default function PointsOrderList() {
message.success(`核销成功,订单 ${truncateId(order.id)} 已确认`);
setVerifyModalOpen(false);
verifyForm.resetFields();
fetchData(page, pageSize);
refresh(page);
} catch {
message.error('核销失败,请检查二维码是否正确');
} finally {
@@ -104,6 +107,10 @@ export default function PointsOrderList() {
}
};
const resetFilters = () => {
setFilters({ status: undefined, dateRange: undefined });
};
// ---- 列定义 ----
const columns = [
{
@@ -152,14 +159,14 @@ export default function PointsOrderList() {
dataIndex: 'created_at',
key: 'created_at',
width: 170,
render: (val: string) => (val ? dayjs(val).format('YYYY-MM-DD HH:mm') : '-'),
render: (val: string) => formatDateTime(val),
},
{
title: '核销时间',
dataIndex: 'verified_at',
key: 'verified_at',
width: 170,
render: (val: string) => (val ? dayjs(val).format('YYYY-MM-DD HH:mm') : '-'),
render: (val: string) => formatDateTime(val),
},
{
title: '核销人',
@@ -178,7 +185,7 @@ export default function PointsOrderList() {
const isExpired = dayjs(val).isBefore(dayjs());
return (
<span style={{ color: isExpired ? '#dc2626' : undefined }}>
{dayjs(val).format('YYYY-MM-DD HH:mm')}
{formatDateTime(val)}
</span>
);
},
@@ -194,38 +201,43 @@ export default function PointsOrderList() {
];
return (
<Card>
{/* 筛选栏 */}
<Row gutter={16} style={{ marginBottom: 16 }} align="middle">
<Col flex="auto">
<Space>
<Select
placeholder="筛选状态"
value={statusFilter}
onChange={(val) => {
setStatusFilter(val);
setPage(1);
}}
options={STATUS_OPTIONS}
allowClear
style={{ width: 140 }}
/>
</Space>
</Col>
<Col>
<AuthButton code="health.points.manage">
<Button
type="primary"
icon={<CheckCircleOutlined />}
onClick={openVerifyModal}
>
</Button>
</AuthButton>
</Col>
</Row>
{/* 数据表格 */}
<PageContainer
title="积分订单"
filters={
<>
<Select
placeholder="筛选状态"
value={filters.status}
onChange={(val) => setFilters((f) => ({ ...f, status: val }))}
options={STATUS_OPTIONS}
allowClear
style={{ width: 140 }}
/>
<DatePicker.RangePicker
value={filters.dateRange ?? undefined}
onChange={(dates) =>
setFilters((f) => ({
...f,
dateRange: dates as [Dayjs, Dayjs] | undefined,
}))
}
style={{ width: 260 }}
/>
</>
}
onResetFilters={resetFilters}
actions={
<AuthButton code="health.points.manage">
<Button
type="primary"
icon={<CheckCircleOutlined />}
onClick={openVerifyModal}
>
</Button>
</AuthButton>
}
>
<Table
rowKey="id"
columns={columns}
@@ -234,14 +246,11 @@ export default function PointsOrderList() {
scroll={{ x: 1200 }}
pagination={{
current: page,
pageSize,
pageSize: 20,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
onChange: (p) => refresh(p),
}}
/>
@@ -272,6 +281,6 @@ export default function PointsOrderList() {
</Form.Item>
</Form>
</Modal>
</Card>
</PageContainer>
);
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback } from 'react';
import { useState, useCallback } from 'react';
import {
Table,
Button,
@@ -12,22 +12,21 @@ import {
Badge,
Switch,
message,
Card,
Row,
Col,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
} from '@ant-design/icons';
import { dayjs } from '../../utils/dayjs';
import {
pointsApi,
type PointsProduct,
type CreatePointsProductReq,
} from '../../api/health/points';
import { AuthButton } from '../../components/AuthButton';
import { PageContainer } from '../../components/PageContainer';
import { usePaginatedData } from '../../hooks/usePaginatedData';
import { formatDateTime } from '../../utils/format';
/** 商品类型映射 */
const PRODUCT_TYPES: Record<string, string> = {
@@ -49,38 +48,47 @@ const PRODUCT_TYPE_COLORS: Record<string, string> = {
privilege: 'purple',
};
interface ProductFilters {
search: string;
product_type: string | undefined;
is_active: string | undefined;
}
export default function PointsProductList() {
const [data, setData] = useState<PointsProduct[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [loading, setLoading] = useState(false);
const [typeFilter, setTypeFilter] = useState<string | undefined>(undefined);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<PointsProduct | null>(null);
const [form] = Form.useForm();
// ---- 数据获取 ----
const fetchData = useCallback(async (p = page, ps = pageSize) => {
setLoading(true);
try {
const fetchProducts = useCallback(
async (page: number, pageSize: number, filters: ProductFilters) => {
const result = await pointsApi.listProducts({
page: p,
page_size: ps,
product_type: typeFilter || undefined,
page,
page_size: pageSize,
product_type: filters.product_type || undefined,
keyword: filters.search || undefined,
});
setData(result.data);
setTotal(result.total);
} catch {
message.error('加载商品列表失败');
} finally {
setLoading(false);
}
}, [page, pageSize, typeFilter]);
let filtered = result.data;
if (filters.is_active !== undefined) {
const isActive = filters.is_active === 'true';
filtered = filtered.filter((p) => p.is_active === isActive);
}
return { data: filtered, total: result.total };
},
[],
);
useEffect(() => {
fetchData();
}, [fetchData]);
const {
data,
total,
page,
loading,
filters,
setFilters,
refresh,
} = usePaginatedData<PointsProduct, ProductFilters>(
fetchProducts,
{ pageSize: 20, defaultFilters: { search: '', product_type: undefined, is_active: undefined } },
);
// ---- 新建 / 编辑 ----
const openCreate = () => {
@@ -134,7 +142,7 @@ export default function PointsProductList() {
message.success(editing ? '更新成功' : '创建成功');
setModalOpen(false);
form.resetFields();
fetchData(page, pageSize);
refresh(page);
} catch {
message.error(editing ? '更新失败' : '创建失败');
}
@@ -148,7 +156,7 @@ export default function PointsProductList() {
version: record.version,
});
message.success(record.is_active ? '已下架' : '已上架');
fetchData(page, pageSize);
refresh(page);
} catch {
message.error('操作失败');
}
@@ -164,7 +172,7 @@ export default function PointsProductList() {
try {
await pointsApi.deleteProduct(record.id, record.version);
message.success('删除成功');
fetchData(page, pageSize);
refresh(page);
} catch {
message.error('删除失败');
}
@@ -172,6 +180,10 @@ export default function PointsProductList() {
});
};
const resetFilters = () => {
setFilters({ search: '', product_type: undefined, is_active: undefined });
};
// ---- 列定义 ----
const columns = [
{
@@ -226,7 +238,7 @@ export default function PointsProductList() {
dataIndex: 'updated_at',
key: 'updated_at',
width: 170,
render: (val: string) => (val ? dayjs(val).format('YYYY-MM-DD HH:mm') : '-'),
render: (val: string) => formatDateTime(val),
},
{
title: '操作',
@@ -266,34 +278,47 @@ export default function PointsProductList() {
];
return (
<Card>
{/* 筛选栏 */}
<Row gutter={16} style={{ marginBottom: 16 }} align="middle">
<Col flex="auto">
<Space>
<Select
placeholder="筛选类型"
value={typeFilter}
onChange={(val) => {
setTypeFilter(val);
setPage(1);
}}
options={PRODUCT_TYPE_OPTIONS}
allowClear
style={{ width: 140 }}
/>
</Space>
</Col>
<Col>
<AuthButton code="health.points.manage">
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</AuthButton>
</Col>
</Row>
{/* 数据表格 */}
<PageContainer
title="积分商品"
filters={
<>
<Input
placeholder="搜索商品名称"
value={filters.search}
onChange={(e) => setFilters((f) => ({ ...f, search: e.target.value }))}
allowClear
style={{ width: 200 }}
/>
<Select
placeholder="筛选类型"
value={filters.product_type}
onChange={(val) => setFilters((f) => ({ ...f, product_type: val }))}
options={PRODUCT_TYPE_OPTIONS}
allowClear
style={{ width: 140 }}
/>
<Select
placeholder="筛选状态"
value={filters.is_active}
onChange={(val) => setFilters((f) => ({ ...f, is_active: val }))}
options={[
{ value: 'true', label: '上架' },
{ value: 'false', label: '下架' },
]}
allowClear
style={{ width: 120 }}
/>
</>
}
onResetFilters={resetFilters}
actions={
<AuthButton code="health.points.manage">
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</AuthButton>
}
>
<Table
rowKey="id"
columns={columns}
@@ -302,14 +327,11 @@ export default function PointsProductList() {
scroll={{ x: 900 }}
pagination={{
current: page,
pageSize,
pageSize: 20,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
onChange: (p) => refresh(p),
}}
/>
@@ -333,38 +355,32 @@ export default function PointsProductList() {
>
<Input placeholder="如:体检套餐兑换券" />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="product_type"
label="商品类型"
rules={[{ required: true, message: '请选择商品类型' }]}
>
<Select placeholder="选择类型" options={PRODUCT_TYPE_OPTIONS} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="points_cost"
label="所需积分"
rules={[{ required: true, message: '请输入所需积分' }]}
>
<InputNumber min={1} max={999999} style={{ width: '100%' }} placeholder="如100" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="stock" label="库存数量" initialValue={-1}>
<InputNumber min={-1} max={999999} style={{ width: '100%' }} placeholder="-1 表示无限" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="sort_order" label="排序" initialValue={0}>
<InputNumber min={0} max={9999} style={{ width: '100%' }} placeholder="0" />
</Form.Item>
</Col>
</Row>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item
name="product_type"
label="商品类型"
rules={[{ required: true, message: '请选择商品类型' }]}
style={{ flex: 1 }}
>
<Select placeholder="选择类型" options={PRODUCT_TYPE_OPTIONS} />
</Form.Item>
<Form.Item
name="points_cost"
label="所需积分"
rules={[{ required: true, message: '请输入所需积分' }]}
style={{ flex: 1 }}
>
<InputNumber min={1} max={999999} style={{ width: '100%' }} placeholder="如100" />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="stock" label="库存数量" initialValue={-1} style={{ flex: 1 }}>
<InputNumber min={-1} max={999999} style={{ width: '100%' }} placeholder="-1 表示无限" />
</Form.Item>
<Form.Item name="sort_order" label="排序" initialValue={0} style={{ flex: 1 }}>
<InputNumber min={0} max={9999} style={{ width: '100%' }} placeholder="0" />
</Form.Item>
</div>
<Form.Item name="image_url" label="图片链接">
<Input placeholder="商品图片 URL" />
</Form.Item>
@@ -373,6 +389,6 @@ export default function PointsProductList() {
</Form.Item>
</Form>
</Modal>
</Card>
</PageContainer>
);
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback } from 'react';
import { useState, useCallback } from 'react';
import {
Table,
Button,
@@ -11,9 +11,6 @@ import {
Tag,
Badge,
message,
Card,
Row,
Col,
Switch,
} from 'antd';
import {
@@ -21,13 +18,14 @@ import {
EditOutlined,
DeleteOutlined,
} from '@ant-design/icons';
import { dayjs } from '../../utils/dayjs';
import {
pointsApi,
type PointsRule,
type CreatePointsRuleReq,
} from '../../api/health/points';
import { AuthButton } from '../../components/AuthButton';
import { PageContainer } from '../../components/PageContainer';
import { formatDateTime } from '../../utils/format';
/** 事件类型映射 */
const EVENT_TYPES: Record<string, string> = {
@@ -45,11 +43,20 @@ const EVENT_TYPE_OPTIONS = Object.entries(EVENT_TYPES).map(([value, label]) => (
label,
}));
interface RuleFilters {
event_type: string | undefined;
is_active: string | undefined;
}
export default function PointsRuleList() {
const [data, setData] = useState<PointsRule[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<PointsRule | null>(null);
const [filters, setFilters] = useState<RuleFilters>({
event_type: undefined,
is_active: undefined,
});
const [form] = Form.useForm();
// ---- 数据获取 ----
@@ -57,17 +64,28 @@ export default function PointsRuleList() {
setLoading(true);
try {
const result = await pointsApi.listRules();
setData(result);
let filtered = result;
if (filters.event_type) {
filtered = filtered.filter((r) => r.event_type === filters.event_type);
}
if (filters.is_active !== undefined) {
const isActive = filters.is_active === 'true';
filtered = filtered.filter((r) => r.is_active === isActive);
}
setData(filtered);
} catch {
message.error('加载积分规则失败');
} finally {
setLoading(false);
}
}, []);
}, [filters]);
useEffect(() => {
// Initial fetch
const [hasFetched, setHasFetched] = useState(false);
if (!hasFetched) {
setHasFetched(true);
fetchData();
}, [fetchData]);
}
// ---- 新建 / 编辑 ----
const openCreate = () => {
@@ -161,6 +179,10 @@ export default function PointsRuleList() {
});
};
const resetFilters = () => {
setFilters({ event_type: undefined, is_active: undefined });
};
// ---- 列定义 ----
const columns = [
{
@@ -227,7 +249,7 @@ export default function PointsRuleList() {
dataIndex: 'updated_at',
key: 'updated_at',
width: 170,
render: (val: string) => (val ? dayjs(val).format('YYYY-MM-DD HH:mm') : '-'),
render: (val: string) => formatDateTime(val),
},
{
title: '操作',
@@ -267,24 +289,41 @@ export default function PointsRuleList() {
];
return (
<Card>
{/* 筛选栏 */}
<Row gutter={16} style={{ marginBottom: 16 }} align="middle">
<Col flex="auto">
<span style={{ color: '#64748b', fontSize: 13 }}>
</span>
</Col>
<Col>
<AuthButton code="health.points.manage">
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</AuthButton>
</Col>
</Row>
{/* 数据表格 */}
<PageContainer
title="积分规则"
subtitle="积分规则定义各类健康行为对应的积分奖励,含连续打卡额外奖励"
filters={
<>
<Select
placeholder="筛选类型"
value={filters.event_type}
onChange={(val) => setFilters((f) => ({ ...f, event_type: val }))}
options={EVENT_TYPE_OPTIONS}
allowClear
style={{ width: 140 }}
/>
<Select
placeholder="筛选状态"
value={filters.is_active}
onChange={(val) => setFilters((f) => ({ ...f, is_active: val }))}
options={[
{ value: 'true', label: '启用' },
{ value: 'false', label: '停用' },
]}
allowClear
style={{ width: 120 }}
/>
</>
}
onResetFilters={resetFilters}
actions={
<AuthButton code="health.points.manage">
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</AuthButton>
}
>
<Table
rowKey="id"
columns={columns}
@@ -331,28 +370,22 @@ export default function PointsRuleList() {
<Form.Item name="daily_cap" label="每日上限" initialValue={1}>
<InputNumber min={-1} max={10000} style={{ width: '100%' }} placeholder="-1 表示无限" />
</Form.Item>
<Row gutter={16}>
<Col span={8}>
<Form.Item name="streak_7d_bonus" label="7日连续奖励" initialValue={0}>
<InputNumber min={0} max={10000} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="streak_14d_bonus" label="14日连续奖励" initialValue={0}>
<InputNumber min={0} max={10000} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="streak_30d_bonus" label="30日连续奖励" initialValue={0}>
<InputNumber min={0} max={10000} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="streak_7d_bonus" label="7日连续奖励" initialValue={0} style={{ flex: 1 }}>
<InputNumber min={0} max={10000} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="streak_14d_bonus" label="14日连续奖励" initialValue={0} style={{ flex: 1 }}>
<InputNumber min={0} max={10000} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="streak_30d_bonus" label="30日连续奖励" initialValue={0} style={{ flex: 1 }}>
<InputNumber min={0} max={10000} style={{ width: '100%' }} />
</Form.Item>
</div>
<Form.Item name="description" label="描述">
<Input.TextArea rows={2} placeholder="规则说明" />
</Form.Item>
</Form>
</Modal>
</Card>
</PageContainer>
);
}