Files
whale-town-end-v2/client/src/pages/InvitationCodesPage.tsx

69 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Button, DatePicker, Form, Input, InputNumber, Modal, Space, Table, Tag, Typography, message } from 'antd';
import { useEffect, useState } from 'react';
import { api } from '../lib/api';
type Row = { id: string; code: string; max_uses: number; used_count: number; effective_status: string; expires_at?: string; note?: string; created_at: string };
const statusText: Record<string, [string, string]> = {
active: ['可使用', 'green'], exhausted: ['已用完', 'default'], expired: ['已过期', 'orange'], revoked: ['已作废', 'red'],
};
export function InvitationCodesPage() {
const [rows, setRows] = useState<Row[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [generated, setGenerated] = useState<string[]>([]);
const [form] = Form.useForm();
const load = async () => {
setLoading(true);
try { setRows((await api.listInvitationCodes())?.data?.items || []); }
catch (e: any) { message.error(e?.message || '加载失败'); }
finally { setLoading(false); }
};
useEffect(() => { void load(); }, []);
const generate = async () => {
try {
const value = await form.validateFields();
const res = await api.generateInvitationCodes({ ...value, expires_at: value.expires_at?.toISOString() });
setGenerated((res?.data?.codes || []).map((item: any) => item.code));
setOpen(false); form.resetFields(); await load();
} catch (e: any) { if (!e?.errorFields) message.error(e?.message || '生成失败'); }
};
const copyGenerated = async () => {
await navigator.clipboard.writeText(generated.join('\n'));
message.success('已复制全部邀请码');
};
const columns = [
{ title: '邀请码', dataIndex: 'code' },
{ title: '状态', dataIndex: 'effective_status', render: (v: string) => { const item = statusText[v] || [v, 'default']; return <Tag color={item[1]}>{item[0]}</Tag>; } },
{ title: '使用量', render: (_: unknown, row: Row) => `${row.used_count} / ${row.max_uses}` },
{ title: '有效期至', dataIndex: 'expires_at', render: (v?: string) => v ? new Date(v).toLocaleString() : '永久' },
{ title: '备注', dataIndex: 'note' },
{ title: '操作', render: (_: unknown, row: Row) => <Button danger size="small" disabled={row.effective_status === 'revoked'} onClick={() => Modal.confirm({ title: '作废该邀请码?', onOk: async () => { await api.revokeInvitationCode(row.id); await load(); } })}></Button> },
];
return <Space direction="vertical" size={16} style={{ width: '100%' }}>
<Space style={{ justifyContent: 'space-between', width: '100%' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Space><Button onClick={load} loading={loading}></Button><Button type="primary" onClick={() => setOpen(true)}></Button></Space>
</Space>
<Table rowKey="id" columns={columns as any} dataSource={rows} loading={loading} pagination={{ pageSize: 20 }} />
<Modal title="生成邀请码" open={open} onOk={generate} onCancel={() => setOpen(false)} okText="生成" cancelText="取消">
<Form form={form} layout="vertical" initialValues={{ count: 10, max_uses: 1 }}>
<Form.Item name="count" label="数量" rules={[{ required: true }]}><InputNumber min={1} max={200} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="max_uses" label="每个邀请码可使用次数" rules={[{ required: true }]}><InputNumber min={1} max={10000} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="expires_at" label="有效期至(可选)"><DatePicker showTime style={{ width: '100%' }} /></Form.Item>
<Form.Item name="note" label="备注"><Input maxLength={255} placeholder="例如8 月内测用户" /></Form.Item>
</Form>
</Modal>
<Modal title="邀请码已生成" open={generated.length > 0} onCancel={() => setGenerated([])} footer={<Button type="primary" onClick={copyGenerated}></Button>}>
<Typography.Paragraph type="warning"></Typography.Paragraph>
<Input.TextArea value={generated.join('\n')} autoSize={{ minRows: 5, maxRows: 14 }} readOnly />
</Modal>
</Space>;
}