- 新增 AI 透析分析 API + 药物提醒 API - MediaPicker/ThemeSwitcher/usePaginatedData 优化 - 健康管理页面组件增强(Banner/Consultation/Doctor/MediaLibrary 等) - PluginCRUDPage 导入优化
392 lines
11 KiB
TypeScript
392 lines
11 KiB
TypeScript
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>
|
||
);
|
||
}
|