Giveaways.tsx 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690
  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, Pencil, 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 [editing, setEditing] = useState<Giveaway | null>(null);
  62. const [editSaving, setEditSaving] = useState(false);
  63. const [editForm] = Form.useForm();
  64. const [selected, setSelected] = useState<Giveaway | null>(null);
  65. const [participants, setParticipants] = useState<Participant[]>([]);
  66. const [banOpen, setBanOpen] = useState(false);
  67. const [bans, setBans] = useState<Paged<GiveawayBan>>({ items: [], total: 0, page: 1, page_size: 20 });
  68. const [banLoading, setBanLoading] = useState(false);
  69. const [banForm] = Form.useForm();
  70. const load = async (page = 1) => {
  71. setLoading(true);
  72. try {
  73. const params = new URLSearchParams({ page: String(page), page_size: '20' });
  74. if (chatId) params.set('chat_id', chatId);
  75. if (status) params.set('status', status);
  76. setData(await apiRequest<Paged<Giveaway>>(`/giveaways?${params}`));
  77. } catch (error) {
  78. message.error(error instanceof Error ? error.message : '抽奖加载失败');
  79. } finally {
  80. setLoading(false);
  81. }
  82. };
  83. const loadParticipants = async (giveaway: Giveaway) => {
  84. const result = await apiRequest<Paged<Participant>>(
  85. `/giveaways/${giveaway.giveaway_id}/participants?page_size=100`,
  86. );
  87. setParticipants(result.items);
  88. setSelected(giveaway);
  89. };
  90. const loadBans = async (page = 1) => {
  91. if (!chatId) return;
  92. setBanLoading(true);
  93. try {
  94. setBans(
  95. await apiRequest<Paged<GiveawayBan>>(
  96. `/chats/${chatId}/giveaway-bans?page=${page}&page_size=20`,
  97. ),
  98. );
  99. } finally {
  100. setBanLoading(false);
  101. }
  102. };
  103. const removeParticipant = async (item: Participant, refund: boolean) => {
  104. await apiRequest(
  105. `/giveaways/${selected?.giveaway_id}/participants/${item.user_id}`,
  106. jsonOptions('DELETE', {
  107. confirm: true,
  108. refund,
  109. reason: refund ? '管理员面板移除' : '违规移除,不退还报名积分',
  110. }),
  111. );
  112. if (selected) await loadParticipants(selected);
  113. };
  114. const openEdit = (giveaway: Giveaway) => {
  115. editForm.setFieldsValue({
  116. title: giveaway.title,
  117. description: giveaway.description || '',
  118. starts_at: dayjs(giveaway.starts_at || giveaway.created_at),
  119. ends_at: dayjs(giveaway.ends_at),
  120. });
  121. setEditing(giveaway);
  122. };
  123. useEffect(() => {
  124. apiRequest<Paged<ChatSummary>>('/chats?page_size=100').then((result) => {
  125. const accessibleChats = result.items.filter((chat) => chat.accessible);
  126. setChats(accessibleChats);
  127. setChatId(
  128. initialChat && accessibleChats.some((chat) => chat.chat_id === initialChat)
  129. ? initialChat
  130. : undefined,
  131. );
  132. });
  133. }, []);
  134. useEffect(() => {
  135. void load(1);
  136. }, [chatId, status]);
  137. const runDanger = (title: string, action: () => Promise<unknown>) => {
  138. Modal.confirm({
  139. title,
  140. content: '提交后服务端会重新校验机器人 Telegram 权限和抽奖状态。',
  141. okText: '确认执行',
  142. okButtonProps: { danger: true },
  143. onOk: async () => {
  144. await action();
  145. message.success('操作成功');
  146. await load(data.page);
  147. },
  148. });
  149. };
  150. const columns: ColumnsType<Giveaway> = [
  151. {
  152. title: '抽奖',
  153. key: 'giveaway',
  154. minWidth: 220,
  155. render: (_, record) => (
  156. <Space direction="vertical" size={0}>
  157. <Typography.Text strong>{record.title}</Typography.Text>
  158. <Typography.Text code>#{record.giveaway_id}</Typography.Text>
  159. </Space>
  160. ),
  161. },
  162. { title: '群 ID', dataIndex: 'chat_id', width: 150, responsive: ['md'], className: 'numeric' },
  163. { title: '状态', key: 'status', width: 100, render: (_, record) => <StatusTag status={giveawayDisplayStatus(record)} /> },
  164. {
  165. title: '积分条件',
  166. key: 'points',
  167. width: 190,
  168. render: (_, record) => (
  169. <Space size={[4, 4]} wrap>
  170. <Tag>门槛 {record.minimum_points}</Tag>
  171. <Tag color={record.entry_cost ? 'gold' : 'default'}>消耗 {record.entry_cost}</Tag>
  172. <Tag color={record.participation_reward ? 'green' : 'default'}>参与 +{record.participation_reward}</Tag>
  173. </Space>
  174. ),
  175. },
  176. {
  177. title: '报名 / 开奖时间',
  178. key: 'schedule',
  179. width: 190,
  180. render: (_, record) => (
  181. <Space direction="vertical" size={0}>
  182. <Typography.Text>{dayjs(record.starts_at || record.created_at).format('YYYY-MM-DD HH:mm')}</Typography.Text>
  183. <Typography.Text type="secondary">至 {dayjs(record.ends_at).format('YYYY-MM-DD HH:mm')}</Typography.Text>
  184. </Space>
  185. ),
  186. },
  187. {
  188. title: '操作',
  189. key: 'actions',
  190. fixed: 'right',
  191. width: 330,
  192. render: (_, record) => (
  193. <Space size={4} wrap>
  194. <Button type="link" onClick={() => void loadParticipants(record)}>参与者</Button>
  195. {record.status === 'running' ? (
  196. <>
  197. <Button
  198. type="link"
  199. icon={<Pencil size={15} />}
  200. onClick={() => openEdit(record)}
  201. >
  202. 编辑
  203. </Button>
  204. <Button
  205. type="link"
  206. icon={<Trophy size={15} />}
  207. onClick={() =>
  208. runDanger('确认立即开奖?', () =>
  209. apiRequest(`/giveaways/${record.giveaway_id}/finish`, jsonOptions('POST', { confirm: true })),
  210. )
  211. }
  212. >
  213. 开奖
  214. </Button>
  215. <Button
  216. danger
  217. type="link"
  218. onClick={() =>
  219. runDanger('确认取消并退还报名积分?', () =>
  220. apiRequest(`/giveaways/${record.giveaway_id}/cancel`, jsonOptions('POST', { confirm: true })),
  221. )
  222. }
  223. >
  224. 取消
  225. </Button>
  226. </>
  227. ) : null}
  228. {record.status === 'finished' ? (
  229. <Button
  230. type="link"
  231. icon={<RotateCcw size={15} />}
  232. onClick={() =>
  233. runDanger('确认重抽全部奖项?', () =>
  234. apiRequest(`/giveaways/${record.giveaway_id}/reroll`, jsonOptions('POST', { confirm: true })),
  235. )
  236. }
  237. >
  238. 重抽
  239. </Button>
  240. ) : null}
  241. </Space>
  242. ),
  243. },
  244. ];
  245. const participantColumns: ColumnsType<Participant> = [
  246. {
  247. title: '参与者',
  248. key: 'user',
  249. render: (_, item) => userDisplayName(item),
  250. },
  251. { title: '用户 ID', dataIndex: 'user_id', width: 140, className: 'numeric' },
  252. { title: '报名消耗', dataIndex: 'entry_cost', width: 100 },
  253. { title: '状态', key: 'state', width: 100, render: (_, item) => <Tag color={item.active === false ? 'default' : 'success'}>{item.active === false ? '已移除' : '有效'}</Tag> },
  254. {
  255. title: '操作',
  256. width: 220,
  257. render: (_, item) => (
  258. <Space size={4}>
  259. <Popconfirm
  260. title="移除参与者并退款?"
  261. description="确认后退还该参与者的报名积分。"
  262. disabled={item.active === false || selected?.status !== 'running'}
  263. onConfirm={() => removeParticipant(item, true)}
  264. >
  265. <Button danger type="text" icon={<Trash2 size={16} />} disabled={item.active === false || selected?.status !== 'running'}>
  266. 退款移除
  267. </Button>
  268. </Popconfirm>
  269. <Popconfirm
  270. title="违规移除且不退款?"
  271. description="该参与者的报名积分不会退还。"
  272. disabled={item.active === false || selected?.status !== 'running'}
  273. onConfirm={() => removeParticipant(item, false)}
  274. >
  275. <Button danger type="text" disabled={item.active === false || selected?.status !== 'running'}>
  276. 不退款
  277. </Button>
  278. </Popconfirm>
  279. </Space>
  280. ),
  281. },
  282. ];
  283. const banColumns: ColumnsType<GiveawayBan> = [
  284. { title: '用户 ID', dataIndex: 'user_id', width: 150, className: 'numeric' },
  285. { title: '原因', dataIndex: 'reason', render: (value) => value || '-' },
  286. { title: '封禁时间', dataIndex: 'created_at', width: 170, render: (value) => dayjs(value).format('YYYY-MM-DD HH:mm') },
  287. {
  288. title: '操作',
  289. width: 100,
  290. render: (_, item) => (
  291. <Popconfirm
  292. title="解除该用户的抽奖资格封禁?"
  293. onConfirm={async () => {
  294. await apiRequest(
  295. `/chats/${chatId}/giveaway-bans/${item.user_id}`,
  296. jsonOptions('DELETE', { confirm: true }),
  297. );
  298. await loadBans(bans.page);
  299. }}
  300. >
  301. <Button type="link">解除</Button>
  302. </Popconfirm>
  303. ),
  304. },
  305. ];
  306. return (
  307. <>
  308. <PageHeader
  309. title="抽奖管理"
  310. actions={
  311. <Space wrap>
  312. <Button icon={<RefreshCw size={16} />} loading={loading} onClick={() => void load(data.page)}>刷新</Button>
  313. <Button
  314. icon={<UserX size={16} />}
  315. disabled={!chatId}
  316. title={chatId ? undefined : '请先选择群组'}
  317. onClick={() => {
  318. setBanOpen(true);
  319. void loadBans(1);
  320. }}
  321. >
  322. 资格封禁
  323. </Button>
  324. <Button type="primary" icon={<Plus size={16} />} onClick={() => setCreateOpen(true)}>创建抽奖</Button>
  325. </Space>
  326. }
  327. />
  328. <Surface
  329. actions={
  330. <Space wrap>
  331. <Select
  332. allowClear
  333. showSearch
  334. value={chatId}
  335. onChange={setChatId}
  336. placeholder="全部群组"
  337. optionFilterProp="label"
  338. style={{ width: 230 }}
  339. options={chats.map((chat) => ({ value: chat.chat_id, label: chat.title }))}
  340. />
  341. <Select
  342. allowClear
  343. value={status}
  344. onChange={setStatus}
  345. placeholder="全部状态"
  346. style={{ width: 140 }}
  347. options={[
  348. { value: 'running', label: '进行中' },
  349. { value: 'drawing', label: '开奖中' },
  350. { value: 'finished', label: '已开奖' },
  351. { value: 'canceled', label: '已取消' },
  352. ]}
  353. />
  354. </Space>
  355. }
  356. >
  357. <Table
  358. rowKey="giveaway_id"
  359. columns={columns}
  360. dataSource={data.items}
  361. loading={loading}
  362. pagination={{ current: data.page, pageSize: data.page_size, total: data.total, showSizeChanger: false }}
  363. onChange={(pagination) => void load(pagination.current || 1)}
  364. scroll={{ x: 1180 }}
  365. />
  366. </Surface>
  367. <Modal
  368. title="创建抽奖"
  369. open={createOpen}
  370. width={720}
  371. okText="创建并发布"
  372. onCancel={() => setCreateOpen(false)}
  373. onOk={() => createForm.submit()}
  374. destroyOnHidden
  375. >
  376. <Form
  377. form={createForm}
  378. layout="vertical"
  379. initialValues={{
  380. chat_id: chatId,
  381. starts_at: dayjs().add(1, 'day').startOf('day'),
  382. ends_at: dayjs().add(1, 'day').hour(23).minute(59).second(0).millisecond(0),
  383. minimum_points: 0,
  384. entry_cost: 0,
  385. participation_reward: 0,
  386. prizes: [{ name: '一等奖', count: 1, points_reward: 0 }],
  387. }}
  388. onFinish={(values) => {
  389. const startsAt = dayjs(values.starts_at);
  390. const endsAt = dayjs(values.ends_at);
  391. Modal.confirm({
  392. title: '确认创建并发布抽奖?',
  393. content: `抽奖消息会立即发送到所选群组。报名时间:${startsAt.format('YYYY-MM-DD HH:mm')} 至 ${endsAt.format('YYYY-MM-DD HH:mm')}。`,
  394. okText: '确认发布',
  395. onOk: async () => {
  396. await apiRequest(
  397. '/giveaways',
  398. jsonOptions('POST', {
  399. ...values,
  400. starts_at: startsAt.toISOString(),
  401. ends_at: endsAt.toISOString(),
  402. confirm: true,
  403. }),
  404. );
  405. setCreateOpen(false);
  406. createForm.resetFields();
  407. message.success('抽奖已创建并发布到群内');
  408. await load(1);
  409. },
  410. });
  411. }}
  412. >
  413. <Form.Item name="chat_id" label="群组" rules={[{ required: true }]}>
  414. <Select showSearch optionFilterProp="label" options={chats.map((chat) => ({ value: chat.chat_id, label: chat.title }))} />
  415. </Form.Item>
  416. <Form.Item name="title" label="标题" rules={[{ required: true }]}><Input maxLength={200} /></Form.Item>
  417. <Form.Item name="description" label="说明"><Input.TextArea rows={3} maxLength={4000} /></Form.Item>
  418. <Space wrap align="start" size={16} style={{ display: 'flex' }}>
  419. <Form.Item
  420. name="starts_at"
  421. label="报名开始时间"
  422. extra="按浏览器当前时区输入"
  423. rules={[{ required: true, message: '请选择报名开始时间' }]}
  424. style={{ flex: 1, minWidth: 240 }}
  425. >
  426. <DatePicker showTime={{ format: 'HH:mm' }} format="YYYY-MM-DD HH:mm" style={{ width: '100%' }} />
  427. </Form.Item>
  428. <Form.Item
  429. name="ends_at"
  430. label="开奖时间"
  431. dependencies={['starts_at']}
  432. extra="到时自动公布抽奖结果"
  433. rules={[
  434. { required: true, message: '请选择开奖时间' },
  435. ({ getFieldValue }) => ({
  436. validator(_, value) {
  437. if (!value || !getFieldValue('starts_at')) return Promise.resolve();
  438. if (!dayjs(value).isAfter(dayjs())) {
  439. return Promise.reject(new Error('开奖时间必须晚于当前时间'));
  440. }
  441. if (!dayjs(value).isAfter(dayjs(getFieldValue('starts_at')))) {
  442. return Promise.reject(new Error('开奖时间必须晚于报名开始时间'));
  443. }
  444. return Promise.resolve();
  445. },
  446. }),
  447. ]}
  448. style={{ flex: 1, minWidth: 240 }}
  449. >
  450. <DatePicker showTime={{ format: 'HH:mm' }} format="YYYY-MM-DD HH:mm" style={{ width: '100%' }} />
  451. </Form.Item>
  452. </Space>
  453. <Form.List name="prizes">
  454. {(fields, { add, remove }) => (
  455. <>
  456. <Divider orientation="left">分级奖项</Divider>
  457. {fields.map(({ key, ...field }) => (
  458. <Space key={key} align="baseline" wrap style={{ display: 'flex', marginBottom: 8 }}>
  459. <Form.Item {...field} name={[field.name, 'name']} rules={[{ required: true }]}><Input placeholder="奖项名称" /></Form.Item>
  460. <Form.Item {...field} name={[field.name, 'count']} rules={[{ required: true }]}><InputNumber min={1} max={100} placeholder="人数" /></Form.Item>
  461. <Form.Item {...field} name={[field.name, 'points_reward']} rules={[{ required: true }]}><InputNumber min={0} placeholder="中奖积分" /></Form.Item>
  462. <Button danger type="text" icon={<Trash2 size={16} />} aria-label="删除奖项" onClick={() => remove(field.name)} />
  463. </Space>
  464. ))}
  465. <Button icon={<Plus size={16} />} onClick={() => add({ name: '', count: 1, points_reward: 0 })}>添加奖项</Button>
  466. </>
  467. )}
  468. </Form.List>
  469. <Divider orientation="left">积分联动</Divider>
  470. <Space wrap align="start">
  471. <Form.Item name="minimum_points" label="最低积分"><InputNumber min={0} /></Form.Item>
  472. <Form.Item name="entry_cost" label="报名消耗"><InputNumber min={0} /></Form.Item>
  473. <Form.Item name="participation_reward" label="参与奖励"><InputNumber min={0} /></Form.Item>
  474. </Space>
  475. </Form>
  476. </Modal>
  477. <Modal
  478. title={editing ? `编辑抽奖 · #${editing.giveaway_id}` : '编辑抽奖'}
  479. open={Boolean(editing)}
  480. width={640}
  481. okText="保存并刷新群消息"
  482. confirmLoading={editSaving}
  483. cancelButtonProps={{ disabled: editSaving }}
  484. maskClosable={!editSaving}
  485. onCancel={() => {
  486. setEditing(null);
  487. editForm.resetFields();
  488. }}
  489. onOk={() => editForm.submit()}
  490. destroyOnHidden
  491. >
  492. <Form
  493. form={editForm}
  494. layout="vertical"
  495. onFinish={async (values) => {
  496. if (!editing) return;
  497. const startsAt = dayjs(values.starts_at);
  498. const endsAt = dayjs(values.ends_at);
  499. setEditSaving(true);
  500. try {
  501. await apiRequest<Giveaway>(
  502. `/giveaways/${editing.giveaway_id}`,
  503. jsonOptions('PATCH', {
  504. title: values.title,
  505. description: values.description || '',
  506. starts_at: startsAt.toISOString(),
  507. ends_at: endsAt.toISOString(),
  508. expected_updated_at: editing.updated_at,
  509. confirm: true,
  510. }),
  511. );
  512. setEditing(null);
  513. editForm.resetFields();
  514. message.success('抽奖已更新,群内消息已刷新');
  515. await load(data.page);
  516. } catch (error) {
  517. message.error(error instanceof Error ? error.message : '抽奖更新失败');
  518. } finally {
  519. setEditSaving(false);
  520. }
  521. }}
  522. >
  523. <Form.Item
  524. name="title"
  525. label="标题"
  526. rules={[{ required: true, whitespace: true, message: '请输入抽奖标题' }]}
  527. >
  528. <Input maxLength={200} />
  529. </Form.Item>
  530. <Form.Item
  531. name="description"
  532. label="说明"
  533. extra="保存后会同步更新群内原抽奖消息"
  534. >
  535. <Input.TextArea rows={4} maxLength={4000} showCount />
  536. </Form.Item>
  537. <Space wrap align="start" size={16} style={{ display: 'flex' }}>
  538. <Form.Item
  539. name="starts_at"
  540. label="报名开始时间"
  541. extra={
  542. editing && !dayjs(editing.starts_at || editing.created_at).isAfter(dayjs())
  543. ? '报名已经开始,此时间不可修改'
  544. : '按浏览器当前时区输入'
  545. }
  546. rules={[{ required: true, message: '请选择报名开始时间' }]}
  547. style={{ flex: 1, minWidth: 240 }}
  548. >
  549. <DatePicker
  550. disabled={Boolean(
  551. editing && !dayjs(editing.starts_at || editing.created_at).isAfter(dayjs()),
  552. )}
  553. showTime={{ format: 'HH:mm' }}
  554. format="YYYY-MM-DD HH:mm"
  555. style={{ width: '100%' }}
  556. />
  557. </Form.Item>
  558. <Form.Item
  559. name="ends_at"
  560. label="开奖时间"
  561. dependencies={['starts_at']}
  562. extra="修改后按新时间自动开奖"
  563. rules={[
  564. { required: true, message: '请选择开奖时间' },
  565. ({ getFieldValue }) => ({
  566. validator(_, value) {
  567. if (!value || !getFieldValue('starts_at')) return Promise.resolve();
  568. if (!dayjs(value).isAfter(dayjs())) {
  569. return Promise.reject(new Error('开奖时间必须晚于当前时间'));
  570. }
  571. if (!dayjs(value).isAfter(dayjs(getFieldValue('starts_at')))) {
  572. return Promise.reject(new Error('开奖时间必须晚于报名开始时间'));
  573. }
  574. return Promise.resolve();
  575. },
  576. }),
  577. ]}
  578. style={{ flex: 1, minWidth: 240 }}
  579. >
  580. <DatePicker
  581. showTime={{ format: 'HH:mm' }}
  582. format="YYYY-MM-DD HH:mm"
  583. style={{ width: '100%' }}
  584. />
  585. </Form.Item>
  586. </Space>
  587. <Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
  588. 奖项与积分规则不可编辑,避免报名后改变抽奖条件。
  589. </Typography.Paragraph>
  590. </Form>
  591. </Modal>
  592. <Drawer
  593. title={selected ? `参与者 · #${selected.giveaway_id}` : '参与者'}
  594. open={Boolean(selected)}
  595. width="min(760px, 100vw)"
  596. onClose={() => setSelected(null)}
  597. extra={
  598. selected ? (
  599. <Button
  600. icon={<Download size={16} />}
  601. onClick={() => void downloadFile(`/giveaways/${selected.giveaway_id}/export`, `giveaway-${selected.giveaway_id}.csv`)}
  602. >
  603. CSV
  604. </Button>
  605. ) : null
  606. }
  607. >
  608. {selected ? (
  609. <>
  610. <Descriptions size="small" column={2} style={{ marginBottom: 16 }}>
  611. <Descriptions.Item label="标题">{selected.title}</Descriptions.Item>
  612. <Descriptions.Item label="状态"><StatusTag status={giveawayDisplayStatus(selected)} /></Descriptions.Item>
  613. <Descriptions.Item label="报名开始">{dayjs(selected.starts_at || selected.created_at).format('YYYY-MM-DD HH:mm')}</Descriptions.Item>
  614. <Descriptions.Item label="开奖时间">{dayjs(selected.ends_at).format('YYYY-MM-DD HH:mm')}</Descriptions.Item>
  615. <Descriptions.Item label="报名消耗">{selected.entry_cost}</Descriptions.Item>
  616. <Descriptions.Item label="参与奖励">{selected.participation_reward}</Descriptions.Item>
  617. </Descriptions>
  618. <Table rowKey="user_id" columns={participantColumns} dataSource={participants} pagination={false} scroll={{ x: 820 }} size="small" />
  619. </>
  620. ) : null}
  621. </Drawer>
  622. <Drawer
  623. title={`抽奖资格封禁${chats.find((chat) => chat.chat_id === chatId)?.title ? ` · ${chats.find((chat) => chat.chat_id === chatId)?.title}` : ''}`}
  624. open={banOpen}
  625. width="min(760px, 100vw)"
  626. destroyOnHidden
  627. onClose={() => setBanOpen(false)}
  628. >
  629. <Form
  630. form={banForm}
  631. layout="vertical"
  632. onFinish={(values) => {
  633. Modal.confirm({
  634. title: '确认封禁该用户的抽奖资格?',
  635. content: '该用户将无法报名此群后续抽奖。',
  636. okText: '确认封禁',
  637. okButtonProps: { danger: true },
  638. onOk: async () => {
  639. await apiRequest(
  640. `/chats/${chatId}/giveaway-bans`,
  641. jsonOptions('POST', { ...values, confirm: true }),
  642. );
  643. banForm.resetFields();
  644. message.success('抽奖资格已封禁');
  645. await loadBans(1);
  646. },
  647. });
  648. }}
  649. >
  650. <Form.Item name="user_id" label="用户 ID" rules={[{ required: true }, { pattern: /^\d+$/, message: '请输入十进制用户 ID' }]}>
  651. <Input inputMode="numeric" />
  652. </Form.Item>
  653. <Form.Item name="reason" label="原因" rules={[{ required: true, message: '请填写封禁原因' }]}>
  654. <Input.TextArea rows={3} maxLength={500} showCount />
  655. </Form.Item>
  656. <Button type="primary" danger htmlType="submit" icon={<UserX size={16} />}>
  657. 添加封禁
  658. </Button>
  659. </Form>
  660. <Divider />
  661. <Table
  662. rowKey="user_id"
  663. columns={banColumns}
  664. dataSource={bans.items}
  665. loading={banLoading}
  666. pagination={{ current: bans.page, pageSize: bans.page_size, total: bans.total, showSizeChanger: false }}
  667. onChange={(pagination) => void loadBans(pagination.current || 1)}
  668. scroll={{ x: 620 }}
  669. size="small"
  670. />
  671. </Drawer>
  672. </>
  673. );
  674. }