| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361 |
- import {
- Alert,
- Button,
- Form,
- Input,
- InputNumber,
- Modal,
- Select,
- Space,
- Switch,
- Table,
- Tabs,
- Tag,
- Typography,
- message,
- } from 'antd';
- import type { ColumnsType } from 'antd/es/table';
- import dayjs from 'dayjs';
- import { Download, Plus, RefreshCw, Search, SlidersHorizontal } from 'lucide-react';
- import { useEffect, useState } from 'react';
- import { PageHeader, Surface } from '@/components/Page';
- import { apiRequest, downloadFile, jsonOptions } from '@/services/api';
- import type {
- ChatSummary,
- Paged,
- PointAccount,
- PointRules,
- PointTransaction,
- } from '@/types';
- import { pointSourceLabel, pointSourceOptions } from '@/utils/labels';
- import { userDisplayName } from '@/utils/users';
- export default function PointsPage() {
- const [chats, setChats] = useState<ChatSummary[]>([]);
- const [chatId, setChatId] = useState<string>();
- const [accounts, setAccounts] = useState<Paged<PointAccount>>({ items: [], total: 0, page: 1, page_size: 20 });
- const [transactions, setTransactions] = useState<Paged<PointTransaction>>({ items: [], total: 0, page: 1, page_size: 20 });
- const [query, setQuery] = useState('');
- const [source, setSource] = useState<string>();
- const [loading, setLoading] = useState(false);
- const [adjustOpen, setAdjustOpen] = useState(false);
- const [adjustForm] = Form.useForm();
- useEffect(() => {
- apiRequest<Paged<ChatSummary>>('/chats?page_size=100').then((result) => {
- setChats(result.items);
- if (result.items.length) setChatId(result.items[0].chat_id);
- });
- }, []);
- const loadAccounts = async (page = 1) => {
- if (!chatId) return;
- setLoading(true);
- try {
- const params = new URLSearchParams({ chat_id: chatId, page: String(page), page_size: '20', query });
- setAccounts(await apiRequest<Paged<PointAccount>>(`/points/accounts?${params}`));
- } finally {
- setLoading(false);
- }
- };
- const loadTransactions = async (page = 1) => {
- if (!chatId) return;
- setLoading(true);
- try {
- const params = new URLSearchParams({ chat_id: chatId, page: String(page), page_size: '20' });
- if (source) params.set('source', source);
- setTransactions(await apiRequest<Paged<PointTransaction>>(`/points/transactions?${params}`));
- } finally {
- setLoading(false);
- }
- };
- useEffect(() => {
- void loadAccounts(1);
- void loadTransactions(1);
- }, [chatId, source]);
- const accountColumns: ColumnsType<PointAccount> = [
- {
- title: '成员',
- key: 'member',
- minWidth: 190,
- render: (_, item) => (
- <Space direction="vertical" size={0}>
- <Typography.Text strong>{userDisplayName(item)}</Typography.Text>
- <Typography.Text type="secondary" className="numeric">{item.user_id}</Typography.Text>
- </Space>
- ),
- },
- { title: '余额', dataIndex: 'balance', width: 110, sorter: (a, b) => a.balance - b.balance, render: (value) => <Typography.Text strong className="numeric">{value}</Typography.Text> },
- { title: '累计获得', dataIndex: 'lifetime_earned', width: 120, responsive: ['md'], className: 'numeric' },
- { title: '累计消耗', dataIndex: 'lifetime_spent', width: 120, responsive: ['md'], className: 'numeric' },
- { title: '更新时间', dataIndex: 'updated_at', width: 160, responsive: ['lg'], render: (value) => (value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-') },
- {
- title: '操作',
- width: 90,
- fixed: 'right',
- render: (_, item) => (
- <Button
- type="link"
- onClick={() => {
- adjustForm.setFieldsValue({ user_id: item.user_id, operation: 'add', amount: 0, reason: '' });
- setAdjustOpen(true);
- }}
- >
- 调整
- </Button>
- ),
- },
- ];
- const transactionColumns: ColumnsType<PointTransaction> = [
- { title: '时间', dataIndex: 'created_at', width: 165, render: (value) => dayjs(value).format('YYYY-MM-DD HH:mm:ss') },
- {
- title: '成员',
- key: 'member',
- width: 190,
- render: (_, item) => (
- <Space direction="vertical" size={0}>
- <Typography.Text strong>{userDisplayName(item)}</Typography.Text>
- <Typography.Text type="secondary" className="numeric">{item.user_id}</Typography.Text>
- </Space>
- ),
- },
- {
- title: '变化',
- dataIndex: 'delta',
- width: 90,
- render: (value: number) => (
- <Typography.Text strong type={value < 0 ? 'danger' : 'success'}>
- {value > 0 ? '+' : ''}{value}
- </Typography.Text>
- ),
- },
- { title: '余额', dataIndex: 'balance_after', width: 90, className: 'numeric' },
- { title: '来源', dataIndex: 'source', width: 180, render: (value) => <Tag>{pointSourceLabel(value)}</Tag> },
- { title: '原因', dataIndex: 'reason', minWidth: 220, ellipsis: true },
- { title: '关联对象', dataIndex: 'reference_id', width: 150, responsive: ['lg'], render: (value) => value || '-' },
- ];
- const selectedChat = chats.find((chat) => chat.chat_id === chatId);
- return (
- <>
- <PageHeader
- title="积分管理"
- actions={
- <Space wrap>
- <Button icon={<RefreshCw size={16} />} loading={loading} onClick={() => { void loadAccounts(accounts.page); void loadTransactions(transactions.page); }}>
- 刷新
- </Button>
- <Button type="primary" icon={<Plus size={16} />} disabled={!chatId} onClick={() => setAdjustOpen(true)}>
- 手工调整
- </Button>
- </Space>
- }
- />
- <Surface>
- <div className="page-toolbar">
- <Select
- showSearch
- optionFilterProp="label"
- value={chatId}
- onChange={setChatId}
- placeholder="选择群组"
- style={{ width: 'min(320px, 100%)' }}
- options={chats.map((chat) => ({ value: chat.chat_id, label: chat.title }))}
- />
- {selectedChat ? <Typography.Text type="secondary">群 ID:{selectedChat.chat_id}</Typography.Text> : null}
- </div>
- {!chatId ? (
- <Alert showIcon type="info" message="请选择群组后查看积分数据。" />
- ) : (
- <Tabs
- items={[
- {
- key: 'accounts',
- label: `余额与排行 (${accounts.total})`,
- children: (
- <>
- <div className="page-toolbar">
- <Input
- allowClear
- value={query}
- onChange={(event) => setQuery(event.target.value)}
- onPressEnter={() => void loadAccounts(1)}
- prefix={<Search size={16} />}
- placeholder="用户 ID、@username 或名称"
- style={{ width: 'min(340px, 100%)' }}
- />
- <Button icon={<Download size={16} />} onClick={() => void downloadFile(`/points/export?chat_id=${chatId}&kind=accounts`, `points-${chatId}-accounts.csv`)}>
- 导出账户
- </Button>
- </div>
- <Table
- rowKey="user_id"
- columns={accountColumns}
- dataSource={accounts.items}
- loading={loading}
- pagination={{ current: accounts.page, pageSize: accounts.page_size, total: accounts.total, showSizeChanger: false }}
- onChange={(pagination) => void loadAccounts(pagination.current || 1)}
- scroll={{ x: 850 }}
- />
- </>
- ),
- },
- {
- key: 'transactions',
- label: `流水 (${transactions.total})`,
- children: (
- <>
- <div className="page-toolbar">
- <Select allowClear value={source} onChange={setSource} placeholder="全部来源" options={pointSourceOptions} style={{ width: 220 }} />
- <Button icon={<Download size={16} />} onClick={() => void downloadFile(`/points/export?chat_id=${chatId}&kind=transactions`, `points-${chatId}-transactions.csv`)}>
- 导出流水
- </Button>
- </div>
- <Table
- rowKey="transaction_id"
- columns={transactionColumns}
- dataSource={transactions.items}
- loading={loading}
- pagination={{ current: transactions.page, pageSize: transactions.page_size, total: transactions.total, showSizeChanger: false }}
- onChange={(pagination) => void loadTransactions(pagination.current || 1)}
- scroll={{ x: 1050 }}
- />
- </>
- ),
- },
- {
- key: 'rules',
- label: '自动积分规则',
- children: <RulesEditor chatId={chatId} />,
- },
- ]}
- />
- )}
- </Surface>
- <Modal
- title="手工调整积分"
- open={adjustOpen}
- okText="继续确认"
- onCancel={() => setAdjustOpen(false)}
- onOk={() => adjustForm.submit()}
- destroyOnHidden
- >
- <Form
- form={adjustForm}
- layout="vertical"
- initialValues={{ operation: 'add', amount: 0 }}
- onFinish={(values) => {
- Modal.confirm({
- title: '确认写入积分流水?',
- content: '历史流水不可修改或删除。',
- okText: '确认写入',
- okButtonProps: { danger: values.operation === 'deduct' || values.operation === 'set' },
- onOk: async () => {
- await apiRequest(
- '/points/adjustments',
- jsonOptions('POST', {
- ...values,
- chat_id: chatId,
- request_id: crypto.randomUUID(),
- confirm: true,
- }),
- );
- message.success('积分已调整并写入流水');
- setAdjustOpen(false);
- adjustForm.resetFields();
- await loadAccounts(1);
- await loadTransactions(1);
- },
- });
- }}
- >
- <Form.Item name="user_id" label="用户 ID" rules={[{ required: true }, { pattern: /^\d+$/, message: '请输入十进制用户 ID' }]}>
- <Input inputMode="numeric" />
- </Form.Item>
- <Form.Item name="operation" label="操作" rules={[{ required: true }]}>
- <Select options={[{ value: 'add', label: '增加' }, { value: 'deduct', label: '扣减' }, { value: 'set', label: '设置余额' }]} />
- </Form.Item>
- <Form.Item name="amount" label="积分数值" rules={[{ required: true }]}>
- <InputNumber min={0} precision={0} style={{ width: '100%' }} />
- </Form.Item>
- <Form.Item name="reason" label="原因" rules={[{ required: true, message: '必须填写调整原因' }]}>
- <Input.TextArea rows={3} maxLength={500} showCount />
- </Form.Item>
- </Form>
- </Modal>
- </>
- );
- }
- function RulesEditor({ chatId }: { chatId: string }) {
- const [form] = Form.useForm<PointRules>();
- const [loading, setLoading] = useState(true);
- useEffect(() => {
- setLoading(true);
- apiRequest<PointRules>(`/chats/${chatId}/points/settings`)
- .then((rules) => form.setFieldsValue(rules))
- .finally(() => setLoading(false));
- }, [chatId]);
- return (
- <Form
- form={form}
- layout="vertical"
- disabled={loading}
- onFinish={(values) => {
- Modal.confirm({
- title: '确认更新积分规则?',
- content: '新规则会影响后续积分奖励;底部按钮状态变化时会向群内发送提示。',
- okText: '确认保存',
- onOk: async () => {
- const saved = await apiRequest<PointRules>(
- `/chats/${chatId}/points/settings`,
- jsonOptions('PUT', { ...values, confirm: true }),
- );
- form.setFieldsValue(saved);
- message.success('积分规则已保存');
- },
- });
- }}
- >
- <div className="page-toolbar">
- <Space size="large" wrap>
- <Form.Item name="enabled" label="积分系统" valuePropName="checked" style={{ marginBottom: 0 }}><Switch /></Form.Item>
- <Form.Item name="timezone" label="群时区" style={{ marginBottom: 0 }}>
- <Select style={{ width: 200 }} options={[{ value: 'Asia/Shanghai' }, { value: 'UTC' }, { value: 'Asia/Tokyo' }, { value: 'America/Los_Angeles' }]} />
- </Form.Item>
- <Form.Item name="checkin_button_enabled" label="底部签到按钮" valuePropName="checked" style={{ marginBottom: 0 }}>
- <Switch />
- </Form.Item>
- </Space>
- <Button type="primary" htmlType="submit" icon={<SlidersHorizontal size={16} />}>保存规则</Button>
- </div>
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(210px, 1fr))', gap: 16 }}>
- <RuleGroup title="签到" enabled="checkin_enabled" fields={[['checkin_points', '每日奖励']]} />
- <RuleGroup title="活跃消息" enabled="activity_enabled" fields={[['activity_points', '单次奖励'], ['activity_cooldown_seconds', '冷却秒数'], ['activity_daily_cap', '每日上限']]} />
- <RuleGroup title="有效点赞" enabled="upvote_enabled" fields={[['upvote_points', '单次奖励'], ['upvote_pair_cooldown_seconds', '双方冷却秒数'], ['upvote_daily_cap', '每日上限']]} />
- </div>
- </Form>
- );
- }
- function RuleGroup({ title, enabled, fields }: { title: string; enabled: string; fields: string[][] }) {
- return (
- <section className="rule-group">
- <h3 className="rule-group-title">{title}</h3>
- <Form.Item name={enabled} label="启用" valuePropName="checked"><Switch /></Form.Item>
- {fields.map(([name, label]) => (
- <Form.Item key={name} name={name} label={label} rules={[{ required: true }]}>
- <InputNumber min={0} precision={0} style={{ width: '100%' }} />
- </Form.Item>
- ))}
- </section>
- );
- }
|