Files
hms/apps/web/src/pages/health/PointsRuleList.tsx
iven e4e5ef04d4 feat(web): Web 前端功能完善 — API 扩展 + 组件优化
- 新增 AI 透析分析 API + 药物提醒 API
- MediaPicker/ThemeSwitcher/usePaginatedData 优化
- 健康管理页面组件增强(Banner/Consultation/Doctor/MediaLibrary 等)
- PluginCRUDPage 导入优化
2026-05-13 23:28:22 +08:00

392 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useCallback, useMemo } from 'react';
import {
Table,
Button,
Space,
Modal,
Form,
Input,
InputNumber,
Select,
Tag,
Badge,
message,
Switch,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
} from '@ant-design/icons';
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> = {
checkin: '每日打卡',
data_report: '数据上报',
lab_upload: '化验上传',
event_checkin: '活动签到',
consultation_complete: '咨询完成',
followup_complete: '随访完成',
};
/** 事件类型选项 */
const EVENT_TYPE_OPTIONS = Object.entries(EVENT_TYPES).map(([value, label]) => ({
value,
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();
// ---- 数据获取 ----
const fetchData = useCallback(async () => {
setLoading(true);
try {
const result = await pointsApi.listRules();
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]);
// Initial fetch
const [hasFetched, setHasFetched] = useState(false);
if (!hasFetched) {
setHasFetched(true);
fetchData();
}
// ---- 新建 / 编辑 ----
const openCreate = () => {
setEditing(null);
form.resetFields();
setModalOpen(true);
};
const openEdit = (record: PointsRule) => {
setEditing(record);
form.setFieldsValue({
event_type: record.event_type,
name: record.name,
description: record.description,
points_value: record.points_value,
daily_cap: record.daily_cap,
streak_7d_bonus: record.streak_7d_bonus,
streak_14d_bonus: record.streak_14d_bonus,
streak_30d_bonus: record.streak_30d_bonus,
});
setModalOpen(true);
};
const handleSubmit = async (values: {
event_type: string;
name: string;
description?: string;
points_value: number;
daily_cap?: number;
streak_7d_bonus?: number;
streak_14d_bonus?: number;
streak_30d_bonus?: number;
}) => {
try {
if (editing) {
await pointsApi.updateRule(editing.id, {
...values,
version: editing.version,
});
} else {
const req: CreatePointsRuleReq = {
event_type: values.event_type,
name: values.name,
description: values.description,
points_value: values.points_value,
daily_cap: values.daily_cap,
streak_7d_bonus: values.streak_7d_bonus,
streak_14d_bonus: values.streak_14d_bonus,
streak_30d_bonus: values.streak_30d_bonus,
};
await pointsApi.createRule(req);
}
message.success(editing ? '更新成功' : '创建成功');
setModalOpen(false);
form.resetFields();
fetchData();
} catch {
message.error(editing ? '更新失败' : '创建失败');
}
};
// ---- 切换启用状态 ----
const handleToggleActive = async (record: PointsRule) => {
try {
await pointsApi.updateRule(record.id, {
is_active: !record.is_active,
version: record.version,
});
message.success(record.is_active ? '已停用' : '已启用');
fetchData();
} catch {
message.error('操作失败');
}
};
// ---- 删除 ----
const handleDelete = (record: PointsRule) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除规则「${record.name}」吗?`,
okType: 'danger',
onOk: async () => {
try {
await pointsApi.deleteRule(record.id, record.version);
message.success('删除成功');
fetchData();
} catch {
message.error('删除失败');
}
},
});
};
const resetFilters = () => {
setFilters({ event_type: undefined, is_active: undefined });
};
// ---- 列定义 ----
const columns = useMemo(() => [
{
title: '规则名称',
dataIndex: 'name',
key: 'name',
width: 140,
},
{
title: '事件类型',
dataIndex: 'event_type',
key: 'event_type',
width: 120,
render: (val: string) => (
<Tag color="blue">{EVENT_TYPES[val] || val}</Tag>
),
},
{
title: '积分值',
dataIndex: 'points_value',
key: 'points_value',
width: 80,
render: (val: number) => <span style={{ fontWeight: 600, color: '#d97706' }}>+{val}</span>,
},
{
title: '每日上限',
dataIndex: 'daily_cap',
key: 'daily_cap',
width: 90,
render: (val: number) => (val === -1 ? '无限' : val),
},
{
title: '7日奖励',
dataIndex: 'streak_7d_bonus',
key: 'streak_7d_bonus',
width: 90,
render: (val: number) => (val > 0 ? <Tag color="green">+{val}</Tag> : '-'),
},
{
title: '14日奖励',
dataIndex: 'streak_14d_bonus',
key: 'streak_14d_bonus',
width: 90,
render: (val: number) => (val > 0 ? <Tag color="green">+{val}</Tag> : '-'),
},
{
title: '30日奖励',
dataIndex: 'streak_30d_bonus',
key: 'streak_30d_bonus',
width: 90,
render: (val: number) => (val > 0 ? <Tag color="green">+{val}</Tag> : '-'),
},
{
title: '状态',
dataIndex: 'is_active',
key: 'is_active',
width: 80,
render: (val: boolean) => (
<Badge status={val ? 'success' : 'default'} text={val ? '启用' : '停用'} />
),
},
{
title: '更新时间',
dataIndex: 'updated_at',
key: 'updated_at',
width: 170,
render: (val: string) => formatDateTime(val),
},
{
title: '操作',
key: 'action',
width: 200,
render: (_: unknown, record: PointsRule) => (
<AuthButton code="health.points.manage">
<Space size="small">
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => openEdit(record)}
>
</Button>
<Switch
size="small"
checked={record.is_active}
checkedChildren="启用"
unCheckedChildren="停用"
onChange={() => handleToggleActive(record)}
/>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => handleDelete(record)}
>
</Button>
</Space>
</AuthButton>
),
},
], [openEdit, handleToggleActive, handleDelete]);
return (
<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}
dataSource={data}
loading={loading}
scroll={{ x: 1100 }}
pagination={false}
/>
{/* 新建 / 编辑弹窗 */}
<Modal
title={editing ? '编辑积分规则' : '新建积分规则'}
open={modalOpen}
onCancel={() => {
setModalOpen(false);
form.resetFields();
}}
onOk={() => form.submit()}
destroyOnHidden
width={560}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item
name="name"
label="规则名称"
rules={[{ required: true, message: '请输入规则名称' }]}
>
<Input placeholder="如:每日打卡" />
</Form.Item>
<Form.Item
name="event_type"
label="事件类型"
rules={[{ required: true, message: '请选择事件类型' }]}
>
<Select placeholder="选择事件类型" options={EVENT_TYPE_OPTIONS} />
</Form.Item>
<Form.Item
name="points_value"
label="积分值"
rules={[{ required: true, message: '请输入积分值' }]}
>
<InputNumber min={1} max={10000} style={{ width: '100%' }} placeholder="如10" />
</Form.Item>
<Form.Item name="daily_cap" label="每日上限" initialValue={1}>
<InputNumber min={-1} max={10000} style={{ width: '100%' }} placeholder="-1 表示无限" />
</Form.Item>
<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>
</PageContainer>
);
}