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, 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>({ items: [], total: 0, page: 1, page_size: 20 }); const [chats, setChats] = useState([]); const [chatId, setChatId] = useState(); const [status, setStatus] = useState(); const [loading, setLoading] = useState(false); const [createOpen, setCreateOpen] = useState(false); const [createForm] = Form.useForm(); const [selected, setSelected] = useState(null); const [participants, setParticipants] = useState([]); const [banOpen, setBanOpen] = useState(false); const [bans, setBans] = useState>({ 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>(`/giveaways?${params}`)); } catch (error) { message.error(error instanceof Error ? error.message : '抽奖加载失败'); } finally { setLoading(false); } }; const loadParticipants = async (giveaway: Giveaway) => { const result = await apiRequest>( `/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>( `/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); }; useEffect(() => { apiRequest>('/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) => { Modal.confirm({ title, content: '提交后服务端会重新校验机器人 Telegram 权限和抽奖状态。', okText: '确认执行', okButtonProps: { danger: true }, onOk: async () => { await action(); message.success('操作成功'); await load(data.page); }, }); }; const columns: ColumnsType = [ { title: '抽奖', key: 'giveaway', minWidth: 220, render: (_, record) => ( {record.title} #{record.giveaway_id} ), }, { title: '群 ID', dataIndex: 'chat_id', width: 150, responsive: ['md'], className: 'numeric' }, { title: '状态', key: 'status', width: 100, render: (_, record) => }, { title: '积分条件', key: 'points', width: 190, render: (_, record) => ( 门槛 {record.minimum_points} 消耗 {record.entry_cost} 参与 +{record.participation_reward} ), }, { title: '报名 / 开奖时间', key: 'schedule', width: 190, render: (_, record) => ( {dayjs(record.starts_at || record.created_at).format('YYYY-MM-DD HH:mm')} 至 {dayjs(record.ends_at).format('YYYY-MM-DD HH:mm')} ), }, { title: '操作', key: 'actions', fixed: 'right', width: 270, render: (_, record) => ( {record.status === 'running' ? ( <> ) : null} {record.status === 'finished' ? ( ) : null} ), }, ]; const participantColumns: ColumnsType = [ { 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) => {item.active === false ? '已移除' : '有效'} }, { title: '操作', width: 220, render: (_, item) => ( removeParticipant(item, true)} > removeParticipant(item, false)} > ), }, ]; const banColumns: ColumnsType = [ { 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) => ( { await apiRequest( `/chats/${chatId}/giveaway-bans/${item.user_id}`, jsonOptions('DELETE', { confirm: true }), ); await loadBans(bans.page); }} > ), }, ]; return ( <> } /> } > void load(pagination.current || 1)} scroll={{ x: 1100 }} /> setCreateOpen(false)} onOk={() => createForm.submit()} destroyOnHidden >
{ 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); }, }); }} > ({ 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 }} > {(fields, { add, remove }) => ( <> 分级奖项 {fields.map(({ key, ...field }) => ( )} 积分联动
setSelected(null)} extra={ selected ? ( ) : null } > {selected ? ( <> {selected.title} {dayjs(selected.starts_at || selected.created_at).format('YYYY-MM-DD HH:mm')} {dayjs(selected.ends_at).format('YYYY-MM-DD HH:mm')} {selected.entry_cost} {selected.participation_reward}
) : null} chat.chat_id === chatId)?.title ? ` · ${chats.find((chat) => chat.chat_id === chatId)?.title}` : ''}`} open={banOpen} width="min(760px, 100vw)" destroyOnHidden onClose={() => setBanOpen(false)} >
{ 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); }, }); }} >
void loadBans(pagination.current || 1)} scroll={{ x: 620 }} size="small" /> ); }