| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690 |
- import {
- Button,
- DatePicker,
- Descriptions,
- Divider,
- Drawer,
- Form,
- Input,
- InputNumber,
- Modal,
- Popconfirm,
- Select,
- Space,
- Table,
- Tag,
- Typography,
- message,
- } from 'antd';
- import type { ColumnsType } from 'antd/es/table';
- import dayjs from 'dayjs';
- import { Download, Pencil, Plus, RefreshCw, RotateCcw, Trash2, Trophy, UserX } from 'lucide-react';
- import { useLocation } from '@umijs/max';
- import { useEffect, useState } from 'react';
- import { PageHeader, Surface } from '@/components/Page';
- import { StatusTag } from '@/components/StatusTag';
- import { apiRequest, downloadFile, jsonOptions } from '@/services/api';
- import type { ChatSummary, Giveaway, Paged } from '@/types';
- import { userDisplayName } from '@/utils/users';
- import { giveawayDisplayStatus } from '@/utils/giveaways';
- interface Participant {
- giveaway_id: string;
- chat_id: string;
- user_id: string;
- username?: string | null;
- first_name?: string | null;
- last_name?: string | null;
- display_name?: string | null;
- active?: boolean;
- entry_cost?: number;
- joined_at: string;
- removed_at?: string | null;
- refunded_at?: string | null;
- }
- interface GiveawayBan {
- chat_id: string;
- user_id: string;
- reason?: string | null;
- moderator_id?: string | null;
- created_at: string;
- }
- export default function GiveawaysPage() {
- const location = useLocation();
- const initialChat = new URLSearchParams(location.search).get('chat_id') || undefined;
- const [data, setData] = useState<Paged<Giveaway>>({ items: [], total: 0, page: 1, page_size: 20 });
- const [chats, setChats] = useState<ChatSummary[]>([]);
- const [chatId, setChatId] = useState<string | undefined>();
- const [status, setStatus] = useState<string | undefined>();
- const [loading, setLoading] = useState(false);
- const [createOpen, setCreateOpen] = useState(false);
- const [createForm] = Form.useForm();
- const [editing, setEditing] = useState<Giveaway | null>(null);
- const [editSaving, setEditSaving] = useState(false);
- const [editForm] = Form.useForm();
- const [selected, setSelected] = useState<Giveaway | null>(null);
- const [participants, setParticipants] = useState<Participant[]>([]);
- const [banOpen, setBanOpen] = useState(false);
- const [bans, setBans] = useState<Paged<GiveawayBan>>({ items: [], total: 0, page: 1, page_size: 20 });
- const [banLoading, setBanLoading] = useState(false);
- const [banForm] = Form.useForm();
- const load = async (page = 1) => {
- setLoading(true);
- try {
- const params = new URLSearchParams({ page: String(page), page_size: '20' });
- if (chatId) params.set('chat_id', chatId);
- if (status) params.set('status', status);
- setData(await apiRequest<Paged<Giveaway>>(`/giveaways?${params}`));
- } catch (error) {
- message.error(error instanceof Error ? error.message : '抽奖加载失败');
- } finally {
- setLoading(false);
- }
- };
- const loadParticipants = async (giveaway: Giveaway) => {
- const result = await apiRequest<Paged<Participant>>(
- `/giveaways/${giveaway.giveaway_id}/participants?page_size=100`,
- );
- setParticipants(result.items);
- setSelected(giveaway);
- };
- const loadBans = async (page = 1) => {
- if (!chatId) return;
- setBanLoading(true);
- try {
- setBans(
- await apiRequest<Paged<GiveawayBan>>(
- `/chats/${chatId}/giveaway-bans?page=${page}&page_size=20`,
- ),
- );
- } finally {
- setBanLoading(false);
- }
- };
- const removeParticipant = async (item: Participant, refund: boolean) => {
- await apiRequest(
- `/giveaways/${selected?.giveaway_id}/participants/${item.user_id}`,
- jsonOptions('DELETE', {
- confirm: true,
- refund,
- reason: refund ? '管理员面板移除' : '违规移除,不退还报名积分',
- }),
- );
- if (selected) await loadParticipants(selected);
- };
- const openEdit = (giveaway: Giveaway) => {
- editForm.setFieldsValue({
- title: giveaway.title,
- description: giveaway.description || '',
- starts_at: dayjs(giveaway.starts_at || giveaway.created_at),
- ends_at: dayjs(giveaway.ends_at),
- });
- setEditing(giveaway);
- };
- useEffect(() => {
- apiRequest<Paged<ChatSummary>>('/chats?page_size=100').then((result) => {
- const accessibleChats = result.items.filter((chat) => chat.accessible);
- setChats(accessibleChats);
- setChatId(
- initialChat && accessibleChats.some((chat) => chat.chat_id === initialChat)
- ? initialChat
- : undefined,
- );
- });
- }, []);
- useEffect(() => {
- void load(1);
- }, [chatId, status]);
- const runDanger = (title: string, action: () => Promise<unknown>) => {
- Modal.confirm({
- title,
- content: '提交后服务端会重新校验机器人 Telegram 权限和抽奖状态。',
- okText: '确认执行',
- okButtonProps: { danger: true },
- onOk: async () => {
- await action();
- message.success('操作成功');
- await load(data.page);
- },
- });
- };
- const columns: ColumnsType<Giveaway> = [
- {
- title: '抽奖',
- key: 'giveaway',
- minWidth: 220,
- render: (_, record) => (
- <Space direction="vertical" size={0}>
- <Typography.Text strong>{record.title}</Typography.Text>
- <Typography.Text code>#{record.giveaway_id}</Typography.Text>
- </Space>
- ),
- },
- { title: '群 ID', dataIndex: 'chat_id', width: 150, responsive: ['md'], className: 'numeric' },
- { title: '状态', key: 'status', width: 100, render: (_, record) => <StatusTag status={giveawayDisplayStatus(record)} /> },
- {
- title: '积分条件',
- key: 'points',
- width: 190,
- render: (_, record) => (
- <Space size={[4, 4]} wrap>
- <Tag>门槛 {record.minimum_points}</Tag>
- <Tag color={record.entry_cost ? 'gold' : 'default'}>消耗 {record.entry_cost}</Tag>
- <Tag color={record.participation_reward ? 'green' : 'default'}>参与 +{record.participation_reward}</Tag>
- </Space>
- ),
- },
- {
- title: '报名 / 开奖时间',
- key: 'schedule',
- width: 190,
- render: (_, record) => (
- <Space direction="vertical" size={0}>
- <Typography.Text>{dayjs(record.starts_at || record.created_at).format('YYYY-MM-DD HH:mm')}</Typography.Text>
- <Typography.Text type="secondary">至 {dayjs(record.ends_at).format('YYYY-MM-DD HH:mm')}</Typography.Text>
- </Space>
- ),
- },
- {
- title: '操作',
- key: 'actions',
- fixed: 'right',
- width: 330,
- render: (_, record) => (
- <Space size={4} wrap>
- <Button type="link" onClick={() => void loadParticipants(record)}>参与者</Button>
- {record.status === 'running' ? (
- <>
- <Button
- type="link"
- icon={<Pencil size={15} />}
- onClick={() => openEdit(record)}
- >
- 编辑
- </Button>
- <Button
- type="link"
- icon={<Trophy size={15} />}
- onClick={() =>
- runDanger('确认立即开奖?', () =>
- apiRequest(`/giveaways/${record.giveaway_id}/finish`, jsonOptions('POST', { confirm: true })),
- )
- }
- >
- 开奖
- </Button>
- <Button
- danger
- type="link"
- onClick={() =>
- runDanger('确认取消并退还报名积分?', () =>
- apiRequest(`/giveaways/${record.giveaway_id}/cancel`, jsonOptions('POST', { confirm: true })),
- )
- }
- >
- 取消
- </Button>
- </>
- ) : null}
- {record.status === 'finished' ? (
- <Button
- type="link"
- icon={<RotateCcw size={15} />}
- onClick={() =>
- runDanger('确认重抽全部奖项?', () =>
- apiRequest(`/giveaways/${record.giveaway_id}/reroll`, jsonOptions('POST', { confirm: true })),
- )
- }
- >
- 重抽
- </Button>
- ) : null}
- </Space>
- ),
- },
- ];
- const participantColumns: ColumnsType<Participant> = [
- {
- title: '参与者',
- key: 'user',
- render: (_, item) => userDisplayName(item),
- },
- { title: '用户 ID', dataIndex: 'user_id', width: 140, className: 'numeric' },
- { title: '报名消耗', dataIndex: 'entry_cost', width: 100 },
- { title: '状态', key: 'state', width: 100, render: (_, item) => <Tag color={item.active === false ? 'default' : 'success'}>{item.active === false ? '已移除' : '有效'}</Tag> },
- {
- title: '操作',
- width: 220,
- render: (_, item) => (
- <Space size={4}>
- <Popconfirm
- title="移除参与者并退款?"
- description="确认后退还该参与者的报名积分。"
- disabled={item.active === false || selected?.status !== 'running'}
- onConfirm={() => removeParticipant(item, true)}
- >
- <Button danger type="text" icon={<Trash2 size={16} />} disabled={item.active === false || selected?.status !== 'running'}>
- 退款移除
- </Button>
- </Popconfirm>
- <Popconfirm
- title="违规移除且不退款?"
- description="该参与者的报名积分不会退还。"
- disabled={item.active === false || selected?.status !== 'running'}
- onConfirm={() => removeParticipant(item, false)}
- >
- <Button danger type="text" disabled={item.active === false || selected?.status !== 'running'}>
- 不退款
- </Button>
- </Popconfirm>
- </Space>
- ),
- },
- ];
- const banColumns: ColumnsType<GiveawayBan> = [
- { title: '用户 ID', dataIndex: 'user_id', width: 150, className: 'numeric' },
- { title: '原因', dataIndex: 'reason', render: (value) => value || '-' },
- { title: '封禁时间', dataIndex: 'created_at', width: 170, render: (value) => dayjs(value).format('YYYY-MM-DD HH:mm') },
- {
- title: '操作',
- width: 100,
- render: (_, item) => (
- <Popconfirm
- title="解除该用户的抽奖资格封禁?"
- onConfirm={async () => {
- await apiRequest(
- `/chats/${chatId}/giveaway-bans/${item.user_id}`,
- jsonOptions('DELETE', { confirm: true }),
- );
- await loadBans(bans.page);
- }}
- >
- <Button type="link">解除</Button>
- </Popconfirm>
- ),
- },
- ];
- return (
- <>
- <PageHeader
- title="抽奖管理"
- actions={
- <Space wrap>
- <Button icon={<RefreshCw size={16} />} loading={loading} onClick={() => void load(data.page)}>刷新</Button>
- <Button
- icon={<UserX size={16} />}
- disabled={!chatId}
- title={chatId ? undefined : '请先选择群组'}
- onClick={() => {
- setBanOpen(true);
- void loadBans(1);
- }}
- >
- 资格封禁
- </Button>
- <Button type="primary" icon={<Plus size={16} />} onClick={() => setCreateOpen(true)}>创建抽奖</Button>
- </Space>
- }
- />
- <Surface
- actions={
- <Space wrap>
- <Select
- allowClear
- showSearch
- value={chatId}
- onChange={setChatId}
- placeholder="全部群组"
- optionFilterProp="label"
- style={{ width: 230 }}
- options={chats.map((chat) => ({ value: chat.chat_id, label: chat.title }))}
- />
- <Select
- allowClear
- value={status}
- onChange={setStatus}
- placeholder="全部状态"
- style={{ width: 140 }}
- options={[
- { value: 'running', label: '进行中' },
- { value: 'drawing', label: '开奖中' },
- { value: 'finished', label: '已开奖' },
- { value: 'canceled', label: '已取消' },
- ]}
- />
- </Space>
- }
- >
- <Table
- rowKey="giveaway_id"
- columns={columns}
- dataSource={data.items}
- loading={loading}
- pagination={{ current: data.page, pageSize: data.page_size, total: data.total, showSizeChanger: false }}
- onChange={(pagination) => void load(pagination.current || 1)}
- scroll={{ x: 1180 }}
- />
- </Surface>
- <Modal
- title="创建抽奖"
- open={createOpen}
- width={720}
- okText="创建并发布"
- onCancel={() => setCreateOpen(false)}
- onOk={() => createForm.submit()}
- destroyOnHidden
- >
- <Form
- form={createForm}
- layout="vertical"
- initialValues={{
- chat_id: chatId,
- starts_at: dayjs().add(1, 'day').startOf('day'),
- ends_at: dayjs().add(1, 'day').hour(23).minute(59).second(0).millisecond(0),
- minimum_points: 0,
- entry_cost: 0,
- participation_reward: 0,
- prizes: [{ name: '一等奖', count: 1, points_reward: 0 }],
- }}
- onFinish={(values) => {
- const startsAt = dayjs(values.starts_at);
- const endsAt = dayjs(values.ends_at);
- Modal.confirm({
- title: '确认创建并发布抽奖?',
- content: `抽奖消息会立即发送到所选群组。报名时间:${startsAt.format('YYYY-MM-DD HH:mm')} 至 ${endsAt.format('YYYY-MM-DD HH:mm')}。`,
- okText: '确认发布',
- onOk: async () => {
- await apiRequest(
- '/giveaways',
- jsonOptions('POST', {
- ...values,
- starts_at: startsAt.toISOString(),
- ends_at: endsAt.toISOString(),
- confirm: true,
- }),
- );
- setCreateOpen(false);
- createForm.resetFields();
- message.success('抽奖已创建并发布到群内');
- await load(1);
- },
- });
- }}
- >
- <Form.Item name="chat_id" label="群组" rules={[{ required: true }]}>
- <Select showSearch optionFilterProp="label" options={chats.map((chat) => ({ value: chat.chat_id, label: chat.title }))} />
- </Form.Item>
- <Form.Item name="title" label="标题" rules={[{ required: true }]}><Input maxLength={200} /></Form.Item>
- <Form.Item name="description" label="说明"><Input.TextArea rows={3} maxLength={4000} /></Form.Item>
- <Space wrap align="start" size={16} style={{ display: 'flex' }}>
- <Form.Item
- name="starts_at"
- label="报名开始时间"
- extra="按浏览器当前时区输入"
- rules={[{ required: true, message: '请选择报名开始时间' }]}
- style={{ flex: 1, minWidth: 240 }}
- >
- <DatePicker showTime={{ format: 'HH:mm' }} format="YYYY-MM-DD HH:mm" style={{ width: '100%' }} />
- </Form.Item>
- <Form.Item
- name="ends_at"
- label="开奖时间"
- dependencies={['starts_at']}
- extra="到时自动公布抽奖结果"
- rules={[
- { required: true, message: '请选择开奖时间' },
- ({ getFieldValue }) => ({
- validator(_, value) {
- if (!value || !getFieldValue('starts_at')) return Promise.resolve();
- if (!dayjs(value).isAfter(dayjs())) {
- return Promise.reject(new Error('开奖时间必须晚于当前时间'));
- }
- if (!dayjs(value).isAfter(dayjs(getFieldValue('starts_at')))) {
- return Promise.reject(new Error('开奖时间必须晚于报名开始时间'));
- }
- return Promise.resolve();
- },
- }),
- ]}
- style={{ flex: 1, minWidth: 240 }}
- >
- <DatePicker showTime={{ format: 'HH:mm' }} format="YYYY-MM-DD HH:mm" style={{ width: '100%' }} />
- </Form.Item>
- </Space>
- <Form.List name="prizes">
- {(fields, { add, remove }) => (
- <>
- <Divider orientation="left">分级奖项</Divider>
- {fields.map(({ key, ...field }) => (
- <Space key={key} align="baseline" wrap style={{ display: 'flex', marginBottom: 8 }}>
- <Form.Item {...field} name={[field.name, 'name']} rules={[{ required: true }]}><Input placeholder="奖项名称" /></Form.Item>
- <Form.Item {...field} name={[field.name, 'count']} rules={[{ required: true }]}><InputNumber min={1} max={100} placeholder="人数" /></Form.Item>
- <Form.Item {...field} name={[field.name, 'points_reward']} rules={[{ required: true }]}><InputNumber min={0} placeholder="中奖积分" /></Form.Item>
- <Button danger type="text" icon={<Trash2 size={16} />} aria-label="删除奖项" onClick={() => remove(field.name)} />
- </Space>
- ))}
- <Button icon={<Plus size={16} />} onClick={() => add({ name: '', count: 1, points_reward: 0 })}>添加奖项</Button>
- </>
- )}
- </Form.List>
- <Divider orientation="left">积分联动</Divider>
- <Space wrap align="start">
- <Form.Item name="minimum_points" label="最低积分"><InputNumber min={0} /></Form.Item>
- <Form.Item name="entry_cost" label="报名消耗"><InputNumber min={0} /></Form.Item>
- <Form.Item name="participation_reward" label="参与奖励"><InputNumber min={0} /></Form.Item>
- </Space>
- </Form>
- </Modal>
- <Modal
- title={editing ? `编辑抽奖 · #${editing.giveaway_id}` : '编辑抽奖'}
- open={Boolean(editing)}
- width={640}
- okText="保存并刷新群消息"
- confirmLoading={editSaving}
- cancelButtonProps={{ disabled: editSaving }}
- maskClosable={!editSaving}
- onCancel={() => {
- setEditing(null);
- editForm.resetFields();
- }}
- onOk={() => editForm.submit()}
- destroyOnHidden
- >
- <Form
- form={editForm}
- layout="vertical"
- onFinish={async (values) => {
- if (!editing) return;
- const startsAt = dayjs(values.starts_at);
- const endsAt = dayjs(values.ends_at);
- setEditSaving(true);
- try {
- await apiRequest<Giveaway>(
- `/giveaways/${editing.giveaway_id}`,
- jsonOptions('PATCH', {
- title: values.title,
- description: values.description || '',
- starts_at: startsAt.toISOString(),
- ends_at: endsAt.toISOString(),
- expected_updated_at: editing.updated_at,
- confirm: true,
- }),
- );
- setEditing(null);
- editForm.resetFields();
- message.success('抽奖已更新,群内消息已刷新');
- await load(data.page);
- } catch (error) {
- message.error(error instanceof Error ? error.message : '抽奖更新失败');
- } finally {
- setEditSaving(false);
- }
- }}
- >
- <Form.Item
- name="title"
- label="标题"
- rules={[{ required: true, whitespace: true, message: '请输入抽奖标题' }]}
- >
- <Input maxLength={200} />
- </Form.Item>
- <Form.Item
- name="description"
- label="说明"
- extra="保存后会同步更新群内原抽奖消息"
- >
- <Input.TextArea rows={4} maxLength={4000} showCount />
- </Form.Item>
- <Space wrap align="start" size={16} style={{ display: 'flex' }}>
- <Form.Item
- name="starts_at"
- label="报名开始时间"
- extra={
- editing && !dayjs(editing.starts_at || editing.created_at).isAfter(dayjs())
- ? '报名已经开始,此时间不可修改'
- : '按浏览器当前时区输入'
- }
- rules={[{ required: true, message: '请选择报名开始时间' }]}
- style={{ flex: 1, minWidth: 240 }}
- >
- <DatePicker
- disabled={Boolean(
- editing && !dayjs(editing.starts_at || editing.created_at).isAfter(dayjs()),
- )}
- showTime={{ format: 'HH:mm' }}
- format="YYYY-MM-DD HH:mm"
- style={{ width: '100%' }}
- />
- </Form.Item>
- <Form.Item
- name="ends_at"
- label="开奖时间"
- dependencies={['starts_at']}
- extra="修改后按新时间自动开奖"
- rules={[
- { required: true, message: '请选择开奖时间' },
- ({ getFieldValue }) => ({
- validator(_, value) {
- if (!value || !getFieldValue('starts_at')) return Promise.resolve();
- if (!dayjs(value).isAfter(dayjs())) {
- return Promise.reject(new Error('开奖时间必须晚于当前时间'));
- }
- if (!dayjs(value).isAfter(dayjs(getFieldValue('starts_at')))) {
- return Promise.reject(new Error('开奖时间必须晚于报名开始时间'));
- }
- return Promise.resolve();
- },
- }),
- ]}
- style={{ flex: 1, minWidth: 240 }}
- >
- <DatePicker
- showTime={{ format: 'HH:mm' }}
- format="YYYY-MM-DD HH:mm"
- style={{ width: '100%' }}
- />
- </Form.Item>
- </Space>
- <Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
- 奖项与积分规则不可编辑,避免报名后改变抽奖条件。
- </Typography.Paragraph>
- </Form>
- </Modal>
- <Drawer
- title={selected ? `参与者 · #${selected.giveaway_id}` : '参与者'}
- open={Boolean(selected)}
- width="min(760px, 100vw)"
- onClose={() => setSelected(null)}
- extra={
- selected ? (
- <Button
- icon={<Download size={16} />}
- onClick={() => void downloadFile(`/giveaways/${selected.giveaway_id}/export`, `giveaway-${selected.giveaway_id}.csv`)}
- >
- CSV
- </Button>
- ) : null
- }
- >
- {selected ? (
- <>
- <Descriptions size="small" column={2} style={{ marginBottom: 16 }}>
- <Descriptions.Item label="标题">{selected.title}</Descriptions.Item>
- <Descriptions.Item label="状态"><StatusTag status={giveawayDisplayStatus(selected)} /></Descriptions.Item>
- <Descriptions.Item label="报名开始">{dayjs(selected.starts_at || selected.created_at).format('YYYY-MM-DD HH:mm')}</Descriptions.Item>
- <Descriptions.Item label="开奖时间">{dayjs(selected.ends_at).format('YYYY-MM-DD HH:mm')}</Descriptions.Item>
- <Descriptions.Item label="报名消耗">{selected.entry_cost}</Descriptions.Item>
- <Descriptions.Item label="参与奖励">{selected.participation_reward}</Descriptions.Item>
- </Descriptions>
- <Table rowKey="user_id" columns={participantColumns} dataSource={participants} pagination={false} scroll={{ x: 820 }} size="small" />
- </>
- ) : null}
- </Drawer>
- <Drawer
- title={`抽奖资格封禁${chats.find((chat) => chat.chat_id === chatId)?.title ? ` · ${chats.find((chat) => chat.chat_id === chatId)?.title}` : ''}`}
- open={banOpen}
- width="min(760px, 100vw)"
- destroyOnHidden
- onClose={() => setBanOpen(false)}
- >
- <Form
- form={banForm}
- layout="vertical"
- onFinish={(values) => {
- Modal.confirm({
- title: '确认封禁该用户的抽奖资格?',
- content: '该用户将无法报名此群后续抽奖。',
- okText: '确认封禁',
- okButtonProps: { danger: true },
- onOk: async () => {
- await apiRequest(
- `/chats/${chatId}/giveaway-bans`,
- jsonOptions('POST', { ...values, confirm: true }),
- );
- banForm.resetFields();
- message.success('抽奖资格已封禁');
- await loadBans(1);
- },
- });
- }}
- >
- <Form.Item name="user_id" label="用户 ID" rules={[{ required: true }, { pattern: /^\d+$/, message: '请输入十进制用户 ID' }]}>
- <Input inputMode="numeric" />
- </Form.Item>
- <Form.Item name="reason" label="原因" rules={[{ required: true, message: '请填写封禁原因' }]}>
- <Input.TextArea rows={3} maxLength={500} showCount />
- </Form.Item>
- <Button type="primary" danger htmlType="submit" icon={<UserX size={16} />}>
- 添加封禁
- </Button>
- </Form>
- <Divider />
- <Table
- rowKey="user_id"
- columns={banColumns}
- dataSource={bans.items}
- loading={banLoading}
- pagination={{ current: bans.page, pageSize: bans.page_size, total: bans.total, showSizeChanger: false }}
- onChange={(pagination) => void loadBans(pagination.current || 1)}
- scroll={{ x: 620 }}
- size="small"
- />
- </Drawer>
- </>
- );
- }
|