Points.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. import {
  2. Alert,
  3. Button,
  4. Form,
  5. Input,
  6. InputNumber,
  7. Modal,
  8. Select,
  9. Space,
  10. Switch,
  11. Table,
  12. Tabs,
  13. Tag,
  14. Typography,
  15. message,
  16. } from 'antd';
  17. import type { ColumnsType } from 'antd/es/table';
  18. import dayjs from 'dayjs';
  19. import { Download, Plus, RefreshCw, Search, SlidersHorizontal } from 'lucide-react';
  20. import { useEffect, useState } from 'react';
  21. import { PageHeader, Surface } from '@/components/Page';
  22. import { apiRequest, downloadFile, jsonOptions } from '@/services/api';
  23. import type {
  24. ChatSummary,
  25. Paged,
  26. PointAccount,
  27. PointRules,
  28. PointTransaction,
  29. } from '@/types';
  30. import { pointSourceLabel, pointSourceOptions } from '@/utils/labels';
  31. import { userDisplayName } from '@/utils/users';
  32. export default function PointsPage() {
  33. const [chats, setChats] = useState<ChatSummary[]>([]);
  34. const [chatId, setChatId] = useState<string>();
  35. const [accounts, setAccounts] = useState<Paged<PointAccount>>({ items: [], total: 0, page: 1, page_size: 20 });
  36. const [transactions, setTransactions] = useState<Paged<PointTransaction>>({ items: [], total: 0, page: 1, page_size: 20 });
  37. const [query, setQuery] = useState('');
  38. const [source, setSource] = useState<string>();
  39. const [loading, setLoading] = useState(false);
  40. const [adjustOpen, setAdjustOpen] = useState(false);
  41. const [adjustForm] = Form.useForm();
  42. useEffect(() => {
  43. apiRequest<Paged<ChatSummary>>('/chats?page_size=100').then((result) => {
  44. setChats(result.items);
  45. if (result.items.length) setChatId(result.items[0].chat_id);
  46. });
  47. }, []);
  48. const loadAccounts = async (page = 1) => {
  49. if (!chatId) return;
  50. setLoading(true);
  51. try {
  52. const params = new URLSearchParams({ chat_id: chatId, page: String(page), page_size: '20', query });
  53. setAccounts(await apiRequest<Paged<PointAccount>>(`/points/accounts?${params}`));
  54. } finally {
  55. setLoading(false);
  56. }
  57. };
  58. const loadTransactions = async (page = 1) => {
  59. if (!chatId) return;
  60. setLoading(true);
  61. try {
  62. const params = new URLSearchParams({ chat_id: chatId, page: String(page), page_size: '20' });
  63. if (source) params.set('source', source);
  64. setTransactions(await apiRequest<Paged<PointTransaction>>(`/points/transactions?${params}`));
  65. } finally {
  66. setLoading(false);
  67. }
  68. };
  69. useEffect(() => {
  70. void loadAccounts(1);
  71. void loadTransactions(1);
  72. }, [chatId, source]);
  73. const accountColumns: ColumnsType<PointAccount> = [
  74. {
  75. title: '成员',
  76. key: 'member',
  77. minWidth: 190,
  78. render: (_, item) => (
  79. <Space direction="vertical" size={0}>
  80. <Typography.Text strong>{userDisplayName(item)}</Typography.Text>
  81. <Typography.Text type="secondary" className="numeric">{item.user_id}</Typography.Text>
  82. </Space>
  83. ),
  84. },
  85. { title: '余额', dataIndex: 'balance', width: 110, sorter: (a, b) => a.balance - b.balance, render: (value) => <Typography.Text strong className="numeric">{value}</Typography.Text> },
  86. { title: '累计获得', dataIndex: 'lifetime_earned', width: 120, responsive: ['md'], className: 'numeric' },
  87. { title: '累计消耗', dataIndex: 'lifetime_spent', width: 120, responsive: ['md'], className: 'numeric' },
  88. { title: '更新时间', dataIndex: 'updated_at', width: 160, responsive: ['lg'], render: (value) => (value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-') },
  89. {
  90. title: '操作',
  91. width: 90,
  92. fixed: 'right',
  93. render: (_, item) => (
  94. <Button
  95. type="link"
  96. onClick={() => {
  97. adjustForm.setFieldsValue({ user_id: item.user_id, operation: 'add', amount: 0, reason: '' });
  98. setAdjustOpen(true);
  99. }}
  100. >
  101. 调整
  102. </Button>
  103. ),
  104. },
  105. ];
  106. const transactionColumns: ColumnsType<PointTransaction> = [
  107. { title: '时间', dataIndex: 'created_at', width: 165, render: (value) => dayjs(value).format('YYYY-MM-DD HH:mm:ss') },
  108. {
  109. title: '成员',
  110. key: 'member',
  111. width: 190,
  112. render: (_, item) => (
  113. <Space direction="vertical" size={0}>
  114. <Typography.Text strong>{userDisplayName(item)}</Typography.Text>
  115. <Typography.Text type="secondary" className="numeric">{item.user_id}</Typography.Text>
  116. </Space>
  117. ),
  118. },
  119. {
  120. title: '变化',
  121. dataIndex: 'delta',
  122. width: 90,
  123. render: (value: number) => (
  124. <Typography.Text strong type={value < 0 ? 'danger' : 'success'}>
  125. {value > 0 ? '+' : ''}{value}
  126. </Typography.Text>
  127. ),
  128. },
  129. { title: '余额', dataIndex: 'balance_after', width: 90, className: 'numeric' },
  130. { title: '来源', dataIndex: 'source', width: 180, render: (value) => <Tag>{pointSourceLabel(value)}</Tag> },
  131. { title: '原因', dataIndex: 'reason', minWidth: 220, ellipsis: true },
  132. { title: '关联对象', dataIndex: 'reference_id', width: 150, responsive: ['lg'], render: (value) => value || '-' },
  133. ];
  134. const selectedChat = chats.find((chat) => chat.chat_id === chatId);
  135. return (
  136. <>
  137. <PageHeader
  138. title="积分管理"
  139. actions={
  140. <Space wrap>
  141. <Button icon={<RefreshCw size={16} />} loading={loading} onClick={() => { void loadAccounts(accounts.page); void loadTransactions(transactions.page); }}>
  142. 刷新
  143. </Button>
  144. <Button type="primary" icon={<Plus size={16} />} disabled={!chatId} onClick={() => setAdjustOpen(true)}>
  145. 手工调整
  146. </Button>
  147. </Space>
  148. }
  149. />
  150. <Surface>
  151. <div className="page-toolbar">
  152. <Select
  153. showSearch
  154. optionFilterProp="label"
  155. value={chatId}
  156. onChange={setChatId}
  157. placeholder="选择群组"
  158. style={{ width: 'min(320px, 100%)' }}
  159. options={chats.map((chat) => ({ value: chat.chat_id, label: chat.title }))}
  160. />
  161. {selectedChat ? <Typography.Text type="secondary">群 ID:{selectedChat.chat_id}</Typography.Text> : null}
  162. </div>
  163. {!chatId ? (
  164. <Alert showIcon type="info" message="请选择群组后查看积分数据。" />
  165. ) : (
  166. <Tabs
  167. items={[
  168. {
  169. key: 'accounts',
  170. label: `余额与排行 (${accounts.total})`,
  171. children: (
  172. <>
  173. <div className="page-toolbar">
  174. <Input
  175. allowClear
  176. value={query}
  177. onChange={(event) => setQuery(event.target.value)}
  178. onPressEnter={() => void loadAccounts(1)}
  179. prefix={<Search size={16} />}
  180. placeholder="用户 ID、@username 或名称"
  181. style={{ width: 'min(340px, 100%)' }}
  182. />
  183. <Button icon={<Download size={16} />} onClick={() => void downloadFile(`/points/export?chat_id=${chatId}&kind=accounts`, `points-${chatId}-accounts.csv`)}>
  184. 导出账户
  185. </Button>
  186. </div>
  187. <Table
  188. rowKey="user_id"
  189. columns={accountColumns}
  190. dataSource={accounts.items}
  191. loading={loading}
  192. pagination={{ current: accounts.page, pageSize: accounts.page_size, total: accounts.total, showSizeChanger: false }}
  193. onChange={(pagination) => void loadAccounts(pagination.current || 1)}
  194. scroll={{ x: 850 }}
  195. />
  196. </>
  197. ),
  198. },
  199. {
  200. key: 'transactions',
  201. label: `流水 (${transactions.total})`,
  202. children: (
  203. <>
  204. <div className="page-toolbar">
  205. <Select allowClear value={source} onChange={setSource} placeholder="全部来源" options={pointSourceOptions} style={{ width: 220 }} />
  206. <Button icon={<Download size={16} />} onClick={() => void downloadFile(`/points/export?chat_id=${chatId}&kind=transactions`, `points-${chatId}-transactions.csv`)}>
  207. 导出流水
  208. </Button>
  209. </div>
  210. <Table
  211. rowKey="transaction_id"
  212. columns={transactionColumns}
  213. dataSource={transactions.items}
  214. loading={loading}
  215. pagination={{ current: transactions.page, pageSize: transactions.page_size, total: transactions.total, showSizeChanger: false }}
  216. onChange={(pagination) => void loadTransactions(pagination.current || 1)}
  217. scroll={{ x: 1050 }}
  218. />
  219. </>
  220. ),
  221. },
  222. {
  223. key: 'rules',
  224. label: '自动积分规则',
  225. children: <RulesEditor chatId={chatId} />,
  226. },
  227. ]}
  228. />
  229. )}
  230. </Surface>
  231. <Modal
  232. title="手工调整积分"
  233. open={adjustOpen}
  234. okText="继续确认"
  235. onCancel={() => setAdjustOpen(false)}
  236. onOk={() => adjustForm.submit()}
  237. destroyOnHidden
  238. >
  239. <Form
  240. form={adjustForm}
  241. layout="vertical"
  242. initialValues={{ operation: 'add', amount: 0 }}
  243. onFinish={(values) => {
  244. Modal.confirm({
  245. title: '确认写入积分流水?',
  246. content: '历史流水不可修改或删除。',
  247. okText: '确认写入',
  248. okButtonProps: { danger: values.operation === 'deduct' || values.operation === 'set' },
  249. onOk: async () => {
  250. await apiRequest(
  251. '/points/adjustments',
  252. jsonOptions('POST', {
  253. ...values,
  254. chat_id: chatId,
  255. request_id: crypto.randomUUID(),
  256. confirm: true,
  257. }),
  258. );
  259. message.success('积分已调整并写入流水');
  260. setAdjustOpen(false);
  261. adjustForm.resetFields();
  262. await loadAccounts(1);
  263. await loadTransactions(1);
  264. },
  265. });
  266. }}
  267. >
  268. <Form.Item name="user_id" label="用户 ID" rules={[{ required: true }, { pattern: /^\d+$/, message: '请输入十进制用户 ID' }]}>
  269. <Input inputMode="numeric" />
  270. </Form.Item>
  271. <Form.Item name="operation" label="操作" rules={[{ required: true }]}>
  272. <Select options={[{ value: 'add', label: '增加' }, { value: 'deduct', label: '扣减' }, { value: 'set', label: '设置余额' }]} />
  273. </Form.Item>
  274. <Form.Item name="amount" label="积分数值" rules={[{ required: true }]}>
  275. <InputNumber min={0} precision={0} style={{ width: '100%' }} />
  276. </Form.Item>
  277. <Form.Item name="reason" label="原因" rules={[{ required: true, message: '必须填写调整原因' }]}>
  278. <Input.TextArea rows={3} maxLength={500} showCount />
  279. </Form.Item>
  280. </Form>
  281. </Modal>
  282. </>
  283. );
  284. }
  285. function RulesEditor({ chatId }: { chatId: string }) {
  286. const [form] = Form.useForm<PointRules>();
  287. const [loading, setLoading] = useState(true);
  288. useEffect(() => {
  289. setLoading(true);
  290. apiRequest<PointRules>(`/chats/${chatId}/points/settings`)
  291. .then((rules) => form.setFieldsValue(rules))
  292. .finally(() => setLoading(false));
  293. }, [chatId]);
  294. return (
  295. <Form
  296. form={form}
  297. layout="vertical"
  298. disabled={loading}
  299. onFinish={(values) => {
  300. Modal.confirm({
  301. title: '确认更新积分规则?',
  302. content: '新规则会影响后续积分奖励;底部按钮状态变化时会向群内发送提示。',
  303. okText: '确认保存',
  304. onOk: async () => {
  305. const saved = await apiRequest<PointRules>(
  306. `/chats/${chatId}/points/settings`,
  307. jsonOptions('PUT', { ...values, confirm: true }),
  308. );
  309. form.setFieldsValue(saved);
  310. message.success('积分规则已保存');
  311. },
  312. });
  313. }}
  314. >
  315. <div className="page-toolbar">
  316. <Space size="large" wrap>
  317. <Form.Item name="enabled" label="积分系统" valuePropName="checked" style={{ marginBottom: 0 }}><Switch /></Form.Item>
  318. <Form.Item name="timezone" label="群时区" style={{ marginBottom: 0 }}>
  319. <Select style={{ width: 200 }} options={[{ value: 'Asia/Shanghai' }, { value: 'UTC' }, { value: 'Asia/Tokyo' }, { value: 'America/Los_Angeles' }]} />
  320. </Form.Item>
  321. <Form.Item name="checkin_button_enabled" label="底部签到按钮" valuePropName="checked" style={{ marginBottom: 0 }}>
  322. <Switch />
  323. </Form.Item>
  324. </Space>
  325. <Button type="primary" htmlType="submit" icon={<SlidersHorizontal size={16} />}>保存规则</Button>
  326. </div>
  327. <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(210px, 1fr))', gap: 16 }}>
  328. <RuleGroup title="签到" enabled="checkin_enabled" fields={[['checkin_points', '每日奖励']]} />
  329. <RuleGroup title="活跃消息" enabled="activity_enabled" fields={[['activity_points', '单次奖励'], ['activity_cooldown_seconds', '冷却秒数'], ['activity_daily_cap', '每日上限']]} />
  330. <RuleGroup title="有效点赞" enabled="upvote_enabled" fields={[['upvote_points', '单次奖励'], ['upvote_pair_cooldown_seconds', '双方冷却秒数'], ['upvote_daily_cap', '每日上限']]} />
  331. </div>
  332. </Form>
  333. );
  334. }
  335. function RuleGroup({ title, enabled, fields }: { title: string; enabled: string; fields: string[][] }) {
  336. return (
  337. <section className="rule-group">
  338. <h3 className="rule-group-title">{title}</h3>
  339. <Form.Item name={enabled} label="启用" valuePropName="checked"><Switch /></Form.Item>
  340. {fields.map(([name, label]) => (
  341. <Form.Item key={name} name={name} label={label} rules={[{ required: true }]}>
  342. <InputNumber min={0} precision={0} style={{ width: '100%' }} />
  343. </Form.Item>
  344. ))}
  345. </section>
  346. );
  347. }