Giveaways.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555
  1. import {
  2. Button,
  3. DatePicker,
  4. Descriptions,
  5. Divider,
  6. Drawer,
  7. Form,
  8. Input,
  9. InputNumber,
  10. Modal,
  11. Popconfirm,
  12. Select,
  13. Space,
  14. Table,
  15. Tag,
  16. Typography,
  17. message,
  18. } from 'antd';
  19. import type { ColumnsType } from 'antd/es/table';
  20. import dayjs from 'dayjs';
  21. import { Download, Plus, RefreshCw, RotateCcw, Trash2, Trophy, UserX } from 'lucide-react';
  22. import { useLocation } from '@umijs/max';
  23. import { useEffect, useState } from 'react';
  24. import { PageHeader, Surface } from '@/components/Page';
  25. import { StatusTag } from '@/components/StatusTag';
  26. import { apiRequest, downloadFile, jsonOptions } from '@/services/api';
  27. import type { ChatSummary, Giveaway, Paged } from '@/types';
  28. import { userDisplayName } from '@/utils/users';
  29. import { giveawayDisplayStatus } from '@/utils/giveaways';
  30. interface Participant {
  31. giveaway_id: string;
  32. chat_id: string;
  33. user_id: string;
  34. username?: string | null;
  35. first_name?: string | null;
  36. last_name?: string | null;
  37. display_name?: string | null;
  38. active?: boolean;
  39. entry_cost?: number;
  40. joined_at: string;
  41. removed_at?: string | null;
  42. refunded_at?: string | null;
  43. }
  44. interface GiveawayBan {
  45. chat_id: string;
  46. user_id: string;
  47. reason?: string | null;
  48. moderator_id?: string | null;
  49. created_at: string;
  50. }
  51. export default function GiveawaysPage() {
  52. const location = useLocation();
  53. const initialChat = new URLSearchParams(location.search).get('chat_id') || undefined;
  54. const [data, setData] = useState<Paged<Giveaway>>({ items: [], total: 0, page: 1, page_size: 20 });
  55. const [chats, setChats] = useState<ChatSummary[]>([]);
  56. const [chatId, setChatId] = useState<string | undefined>();
  57. const [status, setStatus] = useState<string | undefined>();
  58. const [loading, setLoading] = useState(false);
  59. const [createOpen, setCreateOpen] = useState(false);
  60. const [createForm] = Form.useForm();
  61. const [selected, setSelected] = useState<Giveaway | null>(null);
  62. const [participants, setParticipants] = useState<Participant[]>([]);
  63. const [banOpen, setBanOpen] = useState(false);
  64. const [bans, setBans] = useState<Paged<GiveawayBan>>({ items: [], total: 0, page: 1, page_size: 20 });
  65. const [banLoading, setBanLoading] = useState(false);
  66. const [banForm] = Form.useForm();
  67. const load = async (page = 1) => {
  68. setLoading(true);
  69. try {
  70. const params = new URLSearchParams({ page: String(page), page_size: '20' });
  71. if (chatId) params.set('chat_id', chatId);
  72. if (status) params.set('status', status);
  73. setData(await apiRequest<Paged<Giveaway>>(`/giveaways?${params}`));
  74. } catch (error) {
  75. message.error(error instanceof Error ? error.message : '抽奖加载失败');
  76. } finally {
  77. setLoading(false);
  78. }
  79. };
  80. const loadParticipants = async (giveaway: Giveaway) => {
  81. const result = await apiRequest<Paged<Participant>>(
  82. `/giveaways/${giveaway.giveaway_id}/participants?page_size=100`,
  83. );
  84. setParticipants(result.items);
  85. setSelected(giveaway);
  86. };
  87. const loadBans = async (page = 1) => {
  88. if (!chatId) return;
  89. setBanLoading(true);
  90. try {
  91. setBans(
  92. await apiRequest<Paged<GiveawayBan>>(
  93. `/chats/${chatId}/giveaway-bans?page=${page}&page_size=20`,
  94. ),
  95. );
  96. } finally {
  97. setBanLoading(false);
  98. }
  99. };
  100. const removeParticipant = async (item: Participant, refund: boolean) => {
  101. await apiRequest(
  102. `/giveaways/${selected?.giveaway_id}/participants/${item.user_id}`,
  103. jsonOptions('DELETE', {
  104. confirm: true,
  105. refund,
  106. reason: refund ? '管理员面板移除' : '违规移除,不退还报名积分',
  107. }),
  108. );
  109. if (selected) await loadParticipants(selected);
  110. };
  111. useEffect(() => {
  112. apiRequest<Paged<ChatSummary>>('/chats?page_size=100').then((result) => {
  113. const accessibleChats = result.items.filter((chat) => chat.accessible);
  114. setChats(accessibleChats);
  115. setChatId(
  116. initialChat && accessibleChats.some((chat) => chat.chat_id === initialChat)
  117. ? initialChat
  118. : undefined,
  119. );
  120. });
  121. }, []);
  122. useEffect(() => {
  123. void load(1);
  124. }, [chatId, status]);
  125. const runDanger = (title: string, action: () => Promise<unknown>) => {
  126. Modal.confirm({
  127. title,
  128. content: '提交后服务端会重新校验机器人 Telegram 权限和抽奖状态。',
  129. okText: '确认执行',
  130. okButtonProps: { danger: true },
  131. onOk: async () => {
  132. await action();
  133. message.success('操作成功');
  134. await load(data.page);
  135. },
  136. });
  137. };
  138. const columns: ColumnsType<Giveaway> = [
  139. {
  140. title: '抽奖',
  141. key: 'giveaway',
  142. minWidth: 220,
  143. render: (_, record) => (
  144. <Space direction="vertical" size={0}>
  145. <Typography.Text strong>{record.title}</Typography.Text>
  146. <Typography.Text code>#{record.giveaway_id}</Typography.Text>
  147. </Space>
  148. ),
  149. },
  150. { title: '群 ID', dataIndex: 'chat_id', width: 150, responsive: ['md'], className: 'numeric' },
  151. { title: '状态', key: 'status', width: 100, render: (_, record) => <StatusTag status={giveawayDisplayStatus(record)} /> },
  152. {
  153. title: '积分条件',
  154. key: 'points',
  155. width: 190,
  156. render: (_, record) => (
  157. <Space size={[4, 4]} wrap>
  158. <Tag>门槛 {record.minimum_points}</Tag>
  159. <Tag color={record.entry_cost ? 'gold' : 'default'}>消耗 {record.entry_cost}</Tag>
  160. <Tag color={record.participation_reward ? 'green' : 'default'}>参与 +{record.participation_reward}</Tag>
  161. </Space>
  162. ),
  163. },
  164. {
  165. title: '报名 / 开奖时间',
  166. key: 'schedule',
  167. width: 190,
  168. render: (_, record) => (
  169. <Space direction="vertical" size={0}>
  170. <Typography.Text>{dayjs(record.starts_at || record.created_at).format('YYYY-MM-DD HH:mm')}</Typography.Text>
  171. <Typography.Text type="secondary">至 {dayjs(record.ends_at).format('YYYY-MM-DD HH:mm')}</Typography.Text>
  172. </Space>
  173. ),
  174. },
  175. {
  176. title: '操作',
  177. key: 'actions',
  178. fixed: 'right',
  179. width: 270,
  180. render: (_, record) => (
  181. <Space size={4} wrap>
  182. <Button type="link" onClick={() => void loadParticipants(record)}>参与者</Button>
  183. {record.status === 'running' ? (
  184. <>
  185. <Button
  186. type="link"
  187. icon={<Trophy size={15} />}
  188. onClick={() =>
  189. runDanger('确认立即开奖?', () =>
  190. apiRequest(`/giveaways/${record.giveaway_id}/finish`, jsonOptions('POST', { confirm: true })),
  191. )
  192. }
  193. >
  194. 开奖
  195. </Button>
  196. <Button
  197. danger
  198. type="link"
  199. onClick={() =>
  200. runDanger('确认取消并退还报名积分?', () =>
  201. apiRequest(`/giveaways/${record.giveaway_id}/cancel`, jsonOptions('POST', { confirm: true })),
  202. )
  203. }
  204. >
  205. 取消
  206. </Button>
  207. </>
  208. ) : null}
  209. {record.status === 'finished' ? (
  210. <Button
  211. type="link"
  212. icon={<RotateCcw size={15} />}
  213. onClick={() =>
  214. runDanger('确认重抽全部奖项?', () =>
  215. apiRequest(`/giveaways/${record.giveaway_id}/reroll`, jsonOptions('POST', { confirm: true })),
  216. )
  217. }
  218. >
  219. 重抽
  220. </Button>
  221. ) : null}
  222. </Space>
  223. ),
  224. },
  225. ];
  226. const participantColumns: ColumnsType<Participant> = [
  227. {
  228. title: '参与者',
  229. key: 'user',
  230. render: (_, item) => userDisplayName(item),
  231. },
  232. { title: '用户 ID', dataIndex: 'user_id', width: 140, className: 'numeric' },
  233. { title: '报名消耗', dataIndex: 'entry_cost', width: 100 },
  234. { title: '状态', key: 'state', width: 100, render: (_, item) => <Tag color={item.active === false ? 'default' : 'success'}>{item.active === false ? '已移除' : '有效'}</Tag> },
  235. {
  236. title: '操作',
  237. width: 220,
  238. render: (_, item) => (
  239. <Space size={4}>
  240. <Popconfirm
  241. title="移除参与者并退款?"
  242. description="确认后退还该参与者的报名积分。"
  243. disabled={item.active === false || selected?.status !== 'running'}
  244. onConfirm={() => removeParticipant(item, true)}
  245. >
  246. <Button danger type="text" icon={<Trash2 size={16} />} disabled={item.active === false || selected?.status !== 'running'}>
  247. 退款移除
  248. </Button>
  249. </Popconfirm>
  250. <Popconfirm
  251. title="违规移除且不退款?"
  252. description="该参与者的报名积分不会退还。"
  253. disabled={item.active === false || selected?.status !== 'running'}
  254. onConfirm={() => removeParticipant(item, false)}
  255. >
  256. <Button danger type="text" disabled={item.active === false || selected?.status !== 'running'}>
  257. 不退款
  258. </Button>
  259. </Popconfirm>
  260. </Space>
  261. ),
  262. },
  263. ];
  264. const banColumns: ColumnsType<GiveawayBan> = [
  265. { title: '用户 ID', dataIndex: 'user_id', width: 150, className: 'numeric' },
  266. { title: '原因', dataIndex: 'reason', render: (value) => value || '-' },
  267. { title: '封禁时间', dataIndex: 'created_at', width: 170, render: (value) => dayjs(value).format('YYYY-MM-DD HH:mm') },
  268. {
  269. title: '操作',
  270. width: 100,
  271. render: (_, item) => (
  272. <Popconfirm
  273. title="解除该用户的抽奖资格封禁?"
  274. onConfirm={async () => {
  275. await apiRequest(
  276. `/chats/${chatId}/giveaway-bans/${item.user_id}`,
  277. jsonOptions('DELETE', { confirm: true }),
  278. );
  279. await loadBans(bans.page);
  280. }}
  281. >
  282. <Button type="link">解除</Button>
  283. </Popconfirm>
  284. ),
  285. },
  286. ];
  287. return (
  288. <>
  289. <PageHeader
  290. title="抽奖管理"
  291. actions={
  292. <Space wrap>
  293. <Button icon={<RefreshCw size={16} />} loading={loading} onClick={() => void load(data.page)}>刷新</Button>
  294. <Button
  295. icon={<UserX size={16} />}
  296. disabled={!chatId}
  297. title={chatId ? undefined : '请先选择群组'}
  298. onClick={() => {
  299. setBanOpen(true);
  300. void loadBans(1);
  301. }}
  302. >
  303. 资格封禁
  304. </Button>
  305. <Button type="primary" icon={<Plus size={16} />} onClick={() => setCreateOpen(true)}>创建抽奖</Button>
  306. </Space>
  307. }
  308. />
  309. <Surface
  310. actions={
  311. <Space wrap>
  312. <Select
  313. allowClear
  314. showSearch
  315. value={chatId}
  316. onChange={setChatId}
  317. placeholder="全部群组"
  318. optionFilterProp="label"
  319. style={{ width: 230 }}
  320. options={chats.map((chat) => ({ value: chat.chat_id, label: chat.title }))}
  321. />
  322. <Select
  323. allowClear
  324. value={status}
  325. onChange={setStatus}
  326. placeholder="全部状态"
  327. style={{ width: 140 }}
  328. options={[
  329. { value: 'running', label: '进行中' },
  330. { value: 'drawing', label: '开奖中' },
  331. { value: 'finished', label: '已开奖' },
  332. { value: 'canceled', label: '已取消' },
  333. ]}
  334. />
  335. </Space>
  336. }
  337. >
  338. <Table
  339. rowKey="giveaway_id"
  340. columns={columns}
  341. dataSource={data.items}
  342. loading={loading}
  343. pagination={{ current: data.page, pageSize: data.page_size, total: data.total, showSizeChanger: false }}
  344. onChange={(pagination) => void load(pagination.current || 1)}
  345. scroll={{ x: 1100 }}
  346. />
  347. </Surface>
  348. <Modal
  349. title="创建抽奖"
  350. open={createOpen}
  351. width={720}
  352. okText="创建并发布"
  353. onCancel={() => setCreateOpen(false)}
  354. onOk={() => createForm.submit()}
  355. destroyOnHidden
  356. >
  357. <Form
  358. form={createForm}
  359. layout="vertical"
  360. initialValues={{
  361. chat_id: chatId,
  362. starts_at: dayjs().add(1, 'day').startOf('day'),
  363. ends_at: dayjs().add(1, 'day').hour(23).minute(59).second(0).millisecond(0),
  364. minimum_points: 0,
  365. entry_cost: 0,
  366. participation_reward: 0,
  367. prizes: [{ name: '一等奖', count: 1, points_reward: 0 }],
  368. }}
  369. onFinish={(values) => {
  370. const startsAt = dayjs(values.starts_at);
  371. const endsAt = dayjs(values.ends_at);
  372. Modal.confirm({
  373. title: '确认创建并发布抽奖?',
  374. content: `抽奖消息会立即发送到所选群组。报名时间:${startsAt.format('YYYY-MM-DD HH:mm')} 至 ${endsAt.format('YYYY-MM-DD HH:mm')}。`,
  375. okText: '确认发布',
  376. onOk: async () => {
  377. await apiRequest(
  378. '/giveaways',
  379. jsonOptions('POST', {
  380. ...values,
  381. starts_at: startsAt.toISOString(),
  382. ends_at: endsAt.toISOString(),
  383. confirm: true,
  384. }),
  385. );
  386. setCreateOpen(false);
  387. createForm.resetFields();
  388. message.success('抽奖已创建并发布到群内');
  389. await load(1);
  390. },
  391. });
  392. }}
  393. >
  394. <Form.Item name="chat_id" label="群组" rules={[{ required: true }]}>
  395. <Select showSearch optionFilterProp="label" options={chats.map((chat) => ({ value: chat.chat_id, label: chat.title }))} />
  396. </Form.Item>
  397. <Form.Item name="title" label="标题" rules={[{ required: true }]}><Input maxLength={200} /></Form.Item>
  398. <Form.Item name="description" label="说明"><Input.TextArea rows={3} maxLength={4000} /></Form.Item>
  399. <Space wrap align="start" size={16} style={{ display: 'flex' }}>
  400. <Form.Item
  401. name="starts_at"
  402. label="报名开始时间"
  403. extra="按浏览器当前时区输入"
  404. rules={[{ required: true, message: '请选择报名开始时间' }]}
  405. style={{ flex: 1, minWidth: 240 }}
  406. >
  407. <DatePicker showTime={{ format: 'HH:mm' }} format="YYYY-MM-DD HH:mm" style={{ width: '100%' }} />
  408. </Form.Item>
  409. <Form.Item
  410. name="ends_at"
  411. label="开奖时间"
  412. dependencies={['starts_at']}
  413. extra="到时自动公布抽奖结果"
  414. rules={[
  415. { required: true, message: '请选择开奖时间' },
  416. ({ getFieldValue }) => ({
  417. validator(_, value) {
  418. if (!value || !getFieldValue('starts_at')) return Promise.resolve();
  419. if (!dayjs(value).isAfter(dayjs())) {
  420. return Promise.reject(new Error('开奖时间必须晚于当前时间'));
  421. }
  422. if (!dayjs(value).isAfter(dayjs(getFieldValue('starts_at')))) {
  423. return Promise.reject(new Error('开奖时间必须晚于报名开始时间'));
  424. }
  425. return Promise.resolve();
  426. },
  427. }),
  428. ]}
  429. style={{ flex: 1, minWidth: 240 }}
  430. >
  431. <DatePicker showTime={{ format: 'HH:mm' }} format="YYYY-MM-DD HH:mm" style={{ width: '100%' }} />
  432. </Form.Item>
  433. </Space>
  434. <Form.List name="prizes">
  435. {(fields, { add, remove }) => (
  436. <>
  437. <Divider orientation="left">分级奖项</Divider>
  438. {fields.map(({ key, ...field }) => (
  439. <Space key={key} align="baseline" wrap style={{ display: 'flex', marginBottom: 8 }}>
  440. <Form.Item {...field} name={[field.name, 'name']} rules={[{ required: true }]}><Input placeholder="奖项名称" /></Form.Item>
  441. <Form.Item {...field} name={[field.name, 'count']} rules={[{ required: true }]}><InputNumber min={1} max={100} placeholder="人数" /></Form.Item>
  442. <Form.Item {...field} name={[field.name, 'points_reward']} rules={[{ required: true }]}><InputNumber min={0} placeholder="中奖积分" /></Form.Item>
  443. <Button danger type="text" icon={<Trash2 size={16} />} aria-label="删除奖项" onClick={() => remove(field.name)} />
  444. </Space>
  445. ))}
  446. <Button icon={<Plus size={16} />} onClick={() => add({ name: '', count: 1, points_reward: 0 })}>添加奖项</Button>
  447. </>
  448. )}
  449. </Form.List>
  450. <Divider orientation="left">积分联动</Divider>
  451. <Space wrap align="start">
  452. <Form.Item name="minimum_points" label="最低积分"><InputNumber min={0} /></Form.Item>
  453. <Form.Item name="entry_cost" label="报名消耗"><InputNumber min={0} /></Form.Item>
  454. <Form.Item name="participation_reward" label="参与奖励"><InputNumber min={0} /></Form.Item>
  455. </Space>
  456. </Form>
  457. </Modal>
  458. <Drawer
  459. title={selected ? `参与者 · #${selected.giveaway_id}` : '参与者'}
  460. open={Boolean(selected)}
  461. width="min(760px, 100vw)"
  462. onClose={() => setSelected(null)}
  463. extra={
  464. selected ? (
  465. <Button
  466. icon={<Download size={16} />}
  467. onClick={() => void downloadFile(`/giveaways/${selected.giveaway_id}/export`, `giveaway-${selected.giveaway_id}.csv`)}
  468. >
  469. CSV
  470. </Button>
  471. ) : null
  472. }
  473. >
  474. {selected ? (
  475. <>
  476. <Descriptions size="small" column={2} style={{ marginBottom: 16 }}>
  477. <Descriptions.Item label="标题">{selected.title}</Descriptions.Item>
  478. <Descriptions.Item label="状态"><StatusTag status={giveawayDisplayStatus(selected)} /></Descriptions.Item>
  479. <Descriptions.Item label="报名开始">{dayjs(selected.starts_at || selected.created_at).format('YYYY-MM-DD HH:mm')}</Descriptions.Item>
  480. <Descriptions.Item label="开奖时间">{dayjs(selected.ends_at).format('YYYY-MM-DD HH:mm')}</Descriptions.Item>
  481. <Descriptions.Item label="报名消耗">{selected.entry_cost}</Descriptions.Item>
  482. <Descriptions.Item label="参与奖励">{selected.participation_reward}</Descriptions.Item>
  483. </Descriptions>
  484. <Table rowKey="user_id" columns={participantColumns} dataSource={participants} pagination={false} scroll={{ x: 820 }} size="small" />
  485. </>
  486. ) : null}
  487. </Drawer>
  488. <Drawer
  489. title={`抽奖资格封禁${chats.find((chat) => chat.chat_id === chatId)?.title ? ` · ${chats.find((chat) => chat.chat_id === chatId)?.title}` : ''}`}
  490. open={banOpen}
  491. width="min(760px, 100vw)"
  492. destroyOnHidden
  493. onClose={() => setBanOpen(false)}
  494. >
  495. <Form
  496. form={banForm}
  497. layout="vertical"
  498. onFinish={(values) => {
  499. Modal.confirm({
  500. title: '确认封禁该用户的抽奖资格?',
  501. content: '该用户将无法报名此群后续抽奖。',
  502. okText: '确认封禁',
  503. okButtonProps: { danger: true },
  504. onOk: async () => {
  505. await apiRequest(
  506. `/chats/${chatId}/giveaway-bans`,
  507. jsonOptions('POST', { ...values, confirm: true }),
  508. );
  509. banForm.resetFields();
  510. message.success('抽奖资格已封禁');
  511. await loadBans(1);
  512. },
  513. });
  514. }}
  515. >
  516. <Form.Item name="user_id" label="用户 ID" rules={[{ required: true }, { pattern: /^\d+$/, message: '请输入十进制用户 ID' }]}>
  517. <Input inputMode="numeric" />
  518. </Form.Item>
  519. <Form.Item name="reason" label="原因" rules={[{ required: true, message: '请填写封禁原因' }]}>
  520. <Input.TextArea rows={3} maxLength={500} showCount />
  521. </Form.Item>
  522. <Button type="primary" danger htmlType="submit" icon={<UserX size={16} />}>
  523. 添加封禁
  524. </Button>
  525. </Form>
  526. <Divider />
  527. <Table
  528. rowKey="user_id"
  529. columns={banColumns}
  530. dataSource={bans.items}
  531. loading={banLoading}
  532. pagination={{ current: bans.page, pageSize: bans.page_size, total: bans.total, showSizeChanger: false }}
  533. onChange={(pagination) => void loadBans(pagination.current || 1)}
  534. scroll={{ x: 620 }}
  535. size="small"
  536. />
  537. </Drawer>
  538. </>
  539. );
  540. }