From 1e7a5f549878e2767c0839dd4f303ebb5e0fa9a4 Mon Sep 17 00:00:00 2001 From: iven Date: Tue, 28 Apr 2026 08:17:55 +0800 Subject: [PATCH] =?UTF-8?q?refactor(web):=20=E5=88=97=E8=A1=A8=E9=A1=B5?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E8=BF=81=E7=A7=BB=20=E2=80=94=20PageContaine?= =?UTF-8?q?r=20+=20usePaginatedData=20+=20=E6=A0=BC=E5=BC=8F=E5=8C=96?= =?UTF-8?q?=E8=A7=84=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8 个列表页迁移至统一模式: - PatientList / DoctorList / AppointmentList / FollowUpTaskList - ConsultationList / AlertList / ArticleManageList - PointsRuleList / PointsProductList / PointsOrderList 统一使用: - PageContainer 组件(标题/筛选/操作/暗色模式) - usePaginatedData hook(分页/筛选/搜索) - EntityName 组件(UUID→姓名兜底) - 共享 formatDateTime/formatDate/formatRelative - 移除手动 isDark 暗色模式处理 --- apps/web/src/pages/health/AlertList.tsx | 266 ++++++++------- apps/web/src/pages/health/AppointmentList.tsx | 203 ++++++----- .../src/pages/health/ArticleManageList.tsx | 283 +++++++--------- .../web/src/pages/health/ConsultationList.tsx | 261 +++++++------- apps/web/src/pages/health/DoctorList.tsx | 202 ++++++----- .../web/src/pages/health/FollowUpTaskList.tsx | 250 +++++++------- apps/web/src/pages/health/PatientList.tsx | 320 ++++++++++-------- apps/web/src/pages/health/PointsOrderList.tsx | 155 +++++---- .../src/pages/health/PointsProductList.tsx | 214 ++++++------ apps/web/src/pages/health/PointsRuleList.tsx | 125 ++++--- 10 files changed, 1205 insertions(+), 1074 deletions(-) diff --git a/apps/web/src/pages/health/AlertList.tsx b/apps/web/src/pages/health/AlertList.tsx index 0e7e00d..8a41e6d 100644 --- a/apps/web/src/pages/health/AlertList.tsx +++ b/apps/web/src/pages/health/AlertList.tsx @@ -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 = { info: 'default', warning: 'orange', @@ -57,75 +69,76 @@ const STATUS_LABEL: Record = { 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 | undefined): string { +function extractRuleName( + detail: Record | 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([]); - const [total, setTotal] = useState(0); - const [loading, setLoading] = useState(false); const [actionLoading, setActionLoading] = useState(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( + 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 = [ { - title: '患者ID', + title: '患者', dataIndex: 'patient_id', key: 'patient_id', - width: 110, + width: 140, render: (id: string) => ( - - {shortId(id)} - + + 8 ? id.slice(0, 8) + '...' : id} + /> + ), }, { @@ -212,11 +229,8 @@ export default function AlertList() { key: 'created_at', width: 140, render: (val: string) => ( - - {relativeTimeStr(val)} + + {formatRelative(val)} ), }, @@ -244,7 +258,8 @@ export default function AlertList() { )} - {(record.status === 'pending' || record.status === 'acknowledged') && ( + {(record.status === 'pending' || + record.status === 'acknowledged') && ( handleDismiss(record)} @@ -269,64 +284,67 @@ export default function AlertList() { ]; return ( -
- {/* 筛选栏 */} -
+ handleFilterChange('search', e.target.value)} + allowClear + style={{ width: 200 }} + /> + handleFilterChange('severity', v ?? '')} + /> + { + 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} + > + `共 ${t} 条`, }} - > -
`共 ${t} 条`, - }} - scroll={{ x: 970 }} - /> - - + scroll={{ x: 970 }} + /> + ); } diff --git a/apps/web/src/pages/health/AppointmentList.tsx b/apps/web/src/pages/health/AppointmentList.tsx index c92fe9e..8cdc822 100644 --- a/apps/web/src/pages/health/AppointmentList.tsx +++ b/apps/web/src/pages/health/AppointmentList.tsx @@ -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 = { ], }; +/** 筛选器类型 */ +interface AppointmentFilters { + status: string | undefined; + dateRange: [Dayjs | null, Dayjs | null] | null; + patientSearch: string; + appointmentType: string | undefined; +} + export default function AppointmentList() { - const [data, setData] = useState([]); - const [total, setTotal] = useState(0); - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(20); - const [loading, setLoading] = useState(false); - const [statusFilter, setStatusFilter] = useState(undefined); - const [dateFilter, setDateFilter] = useState(null); const [modalOpen, setModalOpen] = useState(false); const [form] = Form.useForm(); @@ -91,27 +95,56 @@ export default function AppointmentList() { const [selectedDate, setSelectedDate] = useState(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(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) => ( + + ), }, { 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) => ( + + ), }, { title: '预约类型', @@ -291,6 +326,13 @@ export default function AppointmentList() { width: 100, render: (val: string) => , }, + { + 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 ( - - {/* 筛选栏 */} - - - - handleFilterChange('status', val)} + options={STATUS_OPTIONS} + allowClear + style={{ width: 140 }} + /> + handleFilterChange('dateRange', dates)} + allowClear + /> + handleFilterChange('patientSearch', e.target.value)} + allowClear + style={{ width: 180 }} + /> +
`共 ${t} 条`, - onChange: (p, ps) => { - setPage(p); - setPageSize(ps); - }, + onChange: (p) => refresh(p), }} /> @@ -466,6 +511,6 @@ export default function AppointmentList() { - + ); } diff --git a/apps/web/src/pages/health/ArticleManageList.tsx b/apps/web/src/pages/health/ArticleManageList.tsx index e1980ab..e20dc87 100644 --- a/apps/web/src/pages/health/ArticleManageList.tsx +++ b/apps/web/src/pages/health/ArticleManageList.tsx @@ -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 = { 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([]); - const [total, setTotal] = useState(0); - const [page, setPage] = useState(1); - const [loading, setLoading] = useState(false); - const [statusTab, setStatusTab] = useState(''); - const [categoryId, setCategoryId] = useState(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(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( + 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 | 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() { ); + // ---- 列定义 ---- + const columns = [ { title: '标题', @@ -284,7 +272,7 @@ export default function ArticleManageList() {
v || 未分类, + render: (v?: string) => v || 未分类, }, { title: '标签', @@ -311,23 +299,11 @@ export default function ArticleManageList() { key: 'tags', width: 180, render: (tags?: ArticleTagItem[]) => { - if (!tags || tags.length === 0) { - return -; - } + if (!tags || tags.length === 0) return '-'; return ( {tags.map((t) => ( - - {t.name} - + {t.name} ))} ); @@ -357,7 +333,7 @@ export default function ArticleManageList() { width: 80, render: (v: number) => ( - + {v ?? 0} ), @@ -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 ( -
- {/* 页面标题和工具栏 */} -
-
-

内容管理

-
管理健康科普文章、资讯和内容发布
-
+ + { + setFilters((prev) => ({ ...prev, keyword: e.target.value })); + }} + allowClear + style={{ width: 220 }} + /> + } - value={keyword} - onChange={(e) => debouncedSearch(e.target.value)} - allowClear - style={{ width: 220, borderRadius: 8 }} - /> -
refresh(pagination.current ?? 1)} + pagination={{ + current: page, + pageSize: 20, + total, + showTotal: (t) => `共 ${t} 条记录`, }} - > - { - setStatusTab(key); - setPage(1); - }} - items={STATUS_TABS.map((tab) => ({ key: tab.key, label: tab.label }))} - style={{ padding: '0 16px', marginBottom: 0 }} - /> -
{ - setPage(p); - fetchArticles(p); - }, - showTotal: (t) => `共 ${t} 条记录`, - style: { padding: '12px 16px', margin: 0 }, - }} - /> - + /> {/* 拒绝理由弹窗 */} - + ); } diff --git a/apps/web/src/pages/health/ConsultationList.tsx b/apps/web/src/pages/health/ConsultationList.tsx index d62f66b..31fd5b7 100644 --- a/apps/web/src/pages/health/ConsultationList.tsx +++ b/apps/web/src/pages/health/ConsultationList.tsx @@ -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 = { 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([]); - 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(null); // Create modal const [createOpen, setCreateOpen] = useState(false); const [createLoading, setCreateLoading] = useState(false); const [createForm] = Form.useForm(); - // Close session - const [closingId, setClosingId] = useState(null); + // --- Paginated data with usePaginatedData --- + const fetchFn = useCallback( + async (page: number, pageSize: number, filters: ConsultationFilters) => { + const params: Record = { 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[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(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 = {}; - if (query.status) exportParams.status = query.status; + if (filters.status) exportParams.status = filters.status; // --- Columns --- const columns: ColumnsType = [ @@ -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) => ( + + ), }, { 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) => ( + + ), }, { title: '咨询类型', @@ -188,22 +180,14 @@ export default function ConsultationList() { dataIndex: 'last_message_at', key: 'last_message_at', width: 160, - render: (v: string | undefined) => ( - - {formatDateTime(v)} - - ), + render: (v: string | undefined) => formatDateTime(v), }, { title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 160, - render: (v: string) => ( - - {formatDateTime(v)} - - ), + render: (v: string) => formatDateTime(v), }, { title: '操作', @@ -237,85 +221,76 @@ export default function ConsultationList() { ]; return ( -
- {/* Toolbar */} -
- setFilters((prev) => ({ ...prev, status: value }))} + /> + { + 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 })); + } }} - > - 新建会话 - - - - - 共 {total} 条 - -
- - {/* Table */} -
+ + } + onResetFilters={() => setFilters({})} + actions={ + + + + + + + } + > +
({ + onClick: () => handleRowClick(record), + style: { cursor: 'pointer' }, + })} + pagination={{ + current: page, + pageSize: 20, + total, + showSizeChanger: true, + showTotal: (t) => `共 ${t} 条`, }} - > -
({ - onClick: () => handleRowClick(record), - style: { cursor: 'pointer' }, - })} - pagination={{ - current: query.page, - pageSize: query.page_size, - total, - showSizeChanger: true, - showTotal: (t) => `共 ${t} 条`, - }} - scroll={{ x: 1010 }} - /> - + scroll={{ x: 1010 }} + /> {/* Create Session Modal */} - + ); } diff --git a/apps/web/src/pages/health/DoctorList.tsx b/apps/web/src/pages/health/DoctorList.tsx index b6a9621..2434ad4 100644 --- a/apps/web/src/pages/health/DoctorList.tsx +++ b/apps/web/src/pages/health/DoctorList.tsx @@ -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 = { 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([]); - 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(undefined); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(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(fetcher, { + pageSize: 20, + defaultFilters: { search: '', department: undefined, title: undefined, status: undefined }, + }); // ---- 搜索防抖 ---- const debounceRef = useRef | 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 ? ( + + ) : ( + '-' + ), + }, { 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 ( - - {/* 筛选栏 */} - - - - } - value={searchText} - onChange={(e) => handleSearchChange(e.target.value)} - allowClear - style={{ width: 220 }} - /> - } + value={filters.search} + onChange={(e) => handleSearchChange(e.target.value)} + allowClear + style={{ width: 220 }} + /> + handleFilterChange('title', val)} + options={TITLE_OPTIONS} + allowClear + style={{ width: 160 }} + /> +
`共 ${t} 条`, - onChange: (p, ps) => { - setPage(p); - setPageSize(ps); - }, + onChange: (p) => refresh(p), }} /> @@ -358,6 +404,6 @@ export default function DoctorList() { - + ); } diff --git a/apps/web/src/pages/health/FollowUpTaskList.tsx b/apps/web/src/pages/health/FollowUpTaskList.tsx index 2b419ea..dc99f30 100644 --- a/apps/web/src/pages/health/FollowUpTaskList.tsx +++ b/apps/web/src/pages/health/FollowUpTaskList.tsx @@ -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 = { 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([]); - 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 = { 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[0]); + }, + [], + ); + + const { + data: tasks, + total, + page, + loading, + filters, + setFilters, + refresh, + } = usePaginatedData(fetchFn, { + pageSize: 20, + defaultFilters: {}, }); // Create task modal @@ -93,37 +114,9 @@ export default function FollowUpTaskList() { const [assignForm] = Form.useForm(); const [assignTask, setAssignTask] = useState(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 = [ { @@ -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) => ( + + ), }, { 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) => ( + + ), }, { title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 160, - render: (v: string) => ( - - {formatDateTime(v)} - - ), + render: (v: string) => formatDateTime(v), }, { title: '操作', @@ -322,28 +310,53 @@ export default function FollowUpTaskList() { ]; return ( -
- {/* Toolbar */} -
- setFilters((prev) => ({ ...prev, status: value }))} + /> + { + 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 })); + } + }} + /> + setFilters((prev) => ({ ...prev, assigneeId: value }))} + /> + + } + onResetFilters={() => setFilters({})} + actions={
- - {/* Table */} -
+
`共 ${t} 条`, }} - > -
`共 ${t} 条`, - }} - scroll={{ x: 980 }} - /> - + scroll={{ x: 980 }} + /> {/* Create Task Modal */} - handleDoctorLabel(_val, label)} - /> + @@ -499,12 +491,10 @@ export default function FollowUpTaskList() { label="负责人" rules={[{ required: true, message: '请选择负责人' }]} > - handleDoctorLabel(_val, label)} - /> + - + ); } diff --git a/apps/web/src/pages/health/PatientList.tsx b/apps/web/src/pages/health/PatientList.tsx index 8bf3165..cc9c3ed 100644 --- a/apps/web/src/pages/health/PatientList.tsx +++ b/apps/web/src/pages/health/PatientList.tsx @@ -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([]); - 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(null); const [form] = Form.useForm(); - const isDark = useThemeMode(); - const navigate = useNavigate(); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); - 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( + 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 | 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() {
{name}
- {record.source && ( -
- 来源: {record.source} -
- )} +
+ {record.source && 来源: {record.source}} +
), @@ -189,16 +225,20 @@ export default function PatientList() { width: 80, render: (v?: string) => { if (!v) return '-'; - const map: Record = { male: '男', female: '女', other: '其他' }; + const map: Record = { + 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) => , - }, - { - title: '认证状态', - dataIndex: 'verification_status', - key: 'verification_status', - width: 100, - render: (v: string) => , - }, - { - title: '来源', - dataIndex: 'source', - key: 'source', - width: 100, - render: (v?: string) => v || '-', + width: 140, + render: (_: unknown, record: PatientListItem) => ( + + + + + ), }, { 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' }} /> - {/* 页面标题和工具栏 */} -
-
-

患者管理

-
- 管理患者档案、基本信息和认证状态 -
-
- + } - 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 }} />
setSelectedRowKeys(keys as string[]), }} - > -
({ - 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 }, - }} - /> - + 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 }, + }} + /> {/* 新建/编辑患者弹窗 */} - @@ -385,6 +431,6 @@ export default function PatientList() { - + ); } diff --git a/apps/web/src/pages/health/PointsOrderList.tsx b/apps/web/src/pages/health/PointsOrderList.tsx index 7fb4dc8..9f45dec 100644 --- a/apps/web/src/pages/health/PointsOrderList.tsx +++ b/apps/web/src/pages/health/PointsOrderList.tsx @@ -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 = { @@ -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([]); - const [total, setTotal] = useState(0); - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(20); - const [loading, setLoading] = useState(false); - const [statusFilter, setStatusFilter] = useState(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( + 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 ( - {dayjs(val).format('YYYY-MM-DD HH:mm')} + {formatDateTime(val)} ); }, @@ -194,38 +201,43 @@ export default function PointsOrderList() { ]; return ( - - {/* 筛选栏 */} - - - - setFilters((f) => ({ ...f, status: val }))} + options={STATUS_OPTIONS} + allowClear + style={{ width: 140 }} + /> + + setFilters((f) => ({ + ...f, + dateRange: dates as [Dayjs, Dayjs] | undefined, + })) + } + style={{ width: 260 }} + /> + + } + onResetFilters={resetFilters} + actions={ + + + + } + >
`共 ${t} 条`, - onChange: (p, ps) => { - setPage(p); - setPageSize(ps); - }, + onChange: (p) => refresh(p), }} /> @@ -272,6 +281,6 @@ export default function PointsOrderList() { - + ); } diff --git a/apps/web/src/pages/health/PointsProductList.tsx b/apps/web/src/pages/health/PointsProductList.tsx index 3749830..0e6066e 100644 --- a/apps/web/src/pages/health/PointsProductList.tsx +++ b/apps/web/src/pages/health/PointsProductList.tsx @@ -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 = { @@ -49,38 +48,47 @@ const PRODUCT_TYPE_COLORS: Record = { privilege: 'purple', }; +interface ProductFilters { + search: string; + product_type: string | undefined; + is_active: string | undefined; +} + export default function PointsProductList() { - const [data, setData] = useState([]); - const [total, setTotal] = useState(0); - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(20); - const [loading, setLoading] = useState(false); - const [typeFilter, setTypeFilter] = useState(undefined); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(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( + 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 ( - - {/* 筛选栏 */} - - - - setFilters((f) => ({ ...f, search: e.target.value }))} + allowClear + style={{ width: 200 }} + /> + setFilters((f) => ({ ...f, is_active: val }))} + options={[ + { value: 'true', label: '上架' }, + { value: 'false', label: '下架' }, + ]} + allowClear + style={{ width: 120 }} + /> + + } + onResetFilters={resetFilters} + actions={ + + + + } + >
`共 ${t} 条`, - onChange: (p, ps) => { - setPage(p); - setPageSize(ps); - }, + onChange: (p) => refresh(p), }} /> @@ -333,38 +355,32 @@ export default function PointsProductList() { > - - - - + + + + + +
+ + + + + + +
@@ -373,6 +389,6 @@ export default function PointsProductList() { - + ); } diff --git a/apps/web/src/pages/health/PointsRuleList.tsx b/apps/web/src/pages/health/PointsRuleList.tsx index 6d62278..734c35e 100644 --- a/apps/web/src/pages/health/PointsRuleList.tsx +++ b/apps/web/src/pages/health/PointsRuleList.tsx @@ -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 = { @@ -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([]); const [loading, setLoading] = useState(false); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); + const [filters, setFilters] = useState({ + 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 ( - - {/* 筛选栏 */} - - - - 积分规则定义各类健康行为对应的积分奖励,含连续打卡额外奖励 - - - - - - - - - - {/* 数据表格 */} + + setFilters((f) => ({ ...f, is_active: val }))} + options={[ + { value: 'true', label: '启用' }, + { value: 'false', label: '停用' }, + ]} + allowClear + style={{ width: 120 }} + /> + + } + onResetFilters={resetFilters} + actions={ + + + + } + >
- - - - - - - - - - - - - - - - - +
+ + + + + + + + + +
- + ); }