616 lines
25 KiB
TypeScript
616 lines
25 KiB
TypeScript
import React, { useEffect, useState, useCallback } from 'react';
|
||
import {
|
||
Button, Empty, Form, Input, InputNumber, message, Modal, Pagination, Radio, Select, Space, Table, Tabs, Tag, Tooltip, Typography,
|
||
} from 'antd';
|
||
import { DatePicker } from 'antd';
|
||
import dayjs from 'dayjs';
|
||
import {
|
||
CopyOutlined, DownloadOutlined, PlusOutlined, ReloadOutlined, UserOutlined, HistoryOutlined, WalletOutlined,
|
||
} from '@ant-design/icons';
|
||
|
||
const { RangePicker } = DatePicker;
|
||
import {
|
||
createTeamInvitation, getJoinTeamInfo, getManagedTeam, getPendingJoinRequests,
|
||
getTeamCreditExportUrl, getTeamCreditRecords, getTeamInvitations, getTeamMembers, handleJoinRequest, revokeInvitation, submitJoinRequest, transferCredits,
|
||
} from '../api';
|
||
import type { ManagedTeam, TeamInvitation, TeamJoinRequest, TeamMember } from '../types';
|
||
import { useAuthStore } from '../store/useAuthStore';
|
||
|
||
/* ── 工具函数 ────────────────────────────────────────── */
|
||
function formatDateTime(value: any): string {
|
||
if (!value) return '-';
|
||
try {
|
||
return new Date(value).toLocaleString('zh-CN', { hour12: false });
|
||
} catch {
|
||
return '-';
|
||
}
|
||
}
|
||
|
||
const RECORD_TYPE_CONFIG: Record<string, { color: string; label: string }> = {
|
||
recharge: { color: 'green', label: '充值' },
|
||
consume: { color: 'red', label: '消费' },
|
||
refund: { color: 'orange', label: '退款' },
|
||
team_internal: { color: 'blue', label: '团队内部' },
|
||
};
|
||
|
||
/* ── 主组件 ──────────────────────────────────────────── */
|
||
const TeamManagementPage: React.FC = () => {
|
||
const [team, setTeam] = useState<ManagedTeam | null>(null);
|
||
const [teamLoading, setTeamLoading] = useState(false);
|
||
|
||
const loadTeam = useCallback(async () => {
|
||
setTeamLoading(true);
|
||
try {
|
||
const data = await getManagedTeam();
|
||
setTeam(data);
|
||
} catch (e: any) {
|
||
message.error(e?.message || '获取团队信息失败');
|
||
} finally {
|
||
setTeamLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => { loadTeam(); }, [loadTeam]);
|
||
|
||
// ── Tab 1: 成员 ──
|
||
const [members, setMembers] = useState<TeamMember[]>([]);
|
||
const [membersTotal, setMembersTotal] = useState(0);
|
||
const [membersLoading, setMembersLoading] = useState(false);
|
||
const [membersPage, setMembersPage] = useState(1);
|
||
|
||
const loadMembers = useCallback(async () => {
|
||
if (!team) return;
|
||
setMembersLoading(true);
|
||
try {
|
||
const res = await getTeamMembers(membersPage);
|
||
setMembers(res.items || []);
|
||
setMembersTotal(res.total || 0);
|
||
} catch (e: any) {
|
||
message.error(e?.message || '加载成员失败');
|
||
} finally {
|
||
setMembersLoading(false);
|
||
}
|
||
}, [team, membersPage]);
|
||
|
||
useEffect(() => { loadMembers(); }, [loadMembers]);
|
||
|
||
// ── 调整积分弹窗 ──
|
||
const [creditModal, setCreditModal] = useState<{ open: boolean; member: TeamMember | null }>({ open: false, member: null });
|
||
const [creditForm] = Form.useForm();
|
||
const [creditSaving, setCreditSaving] = useState(false);
|
||
|
||
const handleTransfer = async () => {
|
||
if (!creditModal.member) return;
|
||
try {
|
||
const values = await creditForm.validateFields();
|
||
// 二次校验:确保是正数
|
||
const amount = Number(values.amount);
|
||
if (!amount || amount <= 0 || amount > 9999999) {
|
||
message.error('请输入有效的正数积分数量');
|
||
return;
|
||
}
|
||
setCreditSaving(true);
|
||
await transferCredits(
|
||
creditModal.member.id,
|
||
amount,
|
||
values.direction || 'increase',
|
||
values.description,
|
||
);
|
||
message.success(values.direction === 'decrease' ? '积分扣减成功' : '积分增加成功');
|
||
setCreditModal({ open: false, member: null });
|
||
creditForm.resetFields();
|
||
loadMembers();
|
||
loadCreditRecords();
|
||
} catch (e: any) {
|
||
if (e?.errorFields) return;
|
||
message.error(e?.message || '操作失败');
|
||
} finally {
|
||
setCreditSaving(false);
|
||
}
|
||
};
|
||
|
||
// ── Tab 2: 邀请码 ──
|
||
const [invitations, setInvitations] = useState<TeamInvitation[]>([]);
|
||
const [invLoading, setInvLoading] = useState(false);
|
||
const [invModal, setInvModal] = useState(false);
|
||
const [invForm] = Form.useForm();
|
||
const [invSaving, setInvSaving] = useState(false);
|
||
|
||
const loadInvitations = useCallback(async () => {
|
||
setInvLoading(true);
|
||
try {
|
||
const data = await getTeamInvitations();
|
||
setInvitations(data || []);
|
||
} catch (e: any) {
|
||
message.error(e?.message || '加载邀请码失败');
|
||
} finally {
|
||
setInvLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => { loadInvitations(); }, [loadInvitations]);
|
||
|
||
const handleCreateInvitation = async () => {
|
||
try {
|
||
const values = await invForm.validateFields();
|
||
setInvSaving(true);
|
||
const expiresAt = values.expiresAt ? new Date(values.expiresAt).toISOString() : null;
|
||
await createTeamInvitation(values.maxUses || null, expiresAt);
|
||
message.success('邀请码已生成');
|
||
setInvModal(false);
|
||
invForm.resetFields();
|
||
loadInvitations();
|
||
} catch (e: any) {
|
||
if (e?.errorFields) return;
|
||
message.error(e?.message || '创建失败');
|
||
} finally {
|
||
setInvSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleRevoke = async (invId: string) => {
|
||
try {
|
||
await revokeInvitation(invId);
|
||
message.success('已撤销');
|
||
loadInvitations();
|
||
} catch (e: any) {
|
||
message.error(e?.message || '撤销失败');
|
||
}
|
||
};
|
||
|
||
const copyInviteLink = (link: string) => {
|
||
navigator.clipboard.writeText(link).then(() => {
|
||
message.success('邀请链接已复制');
|
||
}).catch(() => {
|
||
message.warning('复制失败,请手动复制');
|
||
});
|
||
};
|
||
|
||
// ── Tab 3: 加入申请 ──
|
||
const [requests, setRequests] = useState<TeamJoinRequest[]>([]);
|
||
const [reqLoading, setReqLoading] = useState(false);
|
||
|
||
const loadRequests = useCallback(async () => {
|
||
setReqLoading(true);
|
||
try {
|
||
const data = await getPendingJoinRequests();
|
||
setRequests(data || []);
|
||
} catch (e: any) {
|
||
message.error(e?.message || '加载申请失败');
|
||
} finally {
|
||
setReqLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => { loadRequests(); }, [loadRequests]);
|
||
|
||
const handleRequest = async (requestId: string, action: 'approve' | 'reject', note?: string) => {
|
||
try {
|
||
await handleJoinRequest(requestId, action, note);
|
||
message.success(action === 'approve' ? '已通过' : '已拒绝');
|
||
loadRequests();
|
||
loadMembers();
|
||
} catch (e: any) {
|
||
message.error(e?.message || '操作失败');
|
||
}
|
||
};
|
||
|
||
// ── Tab 4: 团队积分变动 ──
|
||
const [creditRecords, setCreditRecords] = useState<any[]>([]);
|
||
const [creditTotal, setCreditTotal] = useState(0);
|
||
const [creditSummary, setCreditSummary] = useState<any>(null);
|
||
const [creditLoading, setCreditLoading] = useState(false);
|
||
const [creditPage, setCreditPage] = useState(1);
|
||
const [creditFilterType, setCreditFilterType] = useState<string>('');
|
||
const [creditFilterPhone, setCreditFilterPhone] = useState<string>('');
|
||
const [creditDateRange, setCreditDateRange] = useState<[string, string] | null>(null);
|
||
|
||
const loadCreditRecords = useCallback(async () => {
|
||
setCreditLoading(true);
|
||
try {
|
||
const res = await getTeamCreditRecords({
|
||
page: creditPage,
|
||
pageSize: 10,
|
||
phone: creditFilterPhone || undefined,
|
||
recordType: creditFilterType || undefined,
|
||
startDate: creditDateRange?.[0] || undefined,
|
||
endDate: creditDateRange?.[1] || undefined,
|
||
});
|
||
setCreditRecords(res.items || []);
|
||
setCreditTotal(res.total || 0);
|
||
setCreditSummary(res.summary || null);
|
||
} catch (e: any) {
|
||
message.error(e?.message || '加载积分记录失败');
|
||
} finally {
|
||
setCreditLoading(false);
|
||
}
|
||
}, [creditPage, creditFilterType, creditFilterPhone, creditDateRange]);
|
||
|
||
useEffect(() => { loadCreditRecords(); }, [loadCreditRecords]);
|
||
|
||
const resetCreditFilters = () => {
|
||
setCreditFilterType('');
|
||
setCreditFilterPhone('');
|
||
setCreditDateRange(null);
|
||
setCreditPage(1);
|
||
};
|
||
|
||
const handleExportCredits = () => {
|
||
const url = getTeamCreditExportUrl({
|
||
phone: creditFilterPhone || undefined,
|
||
recordType: creditFilterType || undefined,
|
||
startDate: creditDateRange?.[0] || undefined,
|
||
endDate: creditDateRange?.[1] || undefined,
|
||
});
|
||
const token = localStorage.getItem('auth_token');
|
||
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
|
||
fetch(url, { headers })
|
||
.then((res) => res.blob())
|
||
.then((blob) => {
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `team_credits_${dayjs().format('YYYYMMDD_HHmmss')}.csv`;
|
||
a.click();
|
||
URL.revokeObjectURL(a.href);
|
||
})
|
||
.catch(() => message.error('导出失败'));
|
||
};
|
||
|
||
/* ── 表格列定义 ──────────────────────────────────────── */
|
||
const memberColumns = [
|
||
{ title: '用户名', dataIndex: 'username', width: 140, render: (v: string) => <Typography.Text strong>{v}</Typography.Text> },
|
||
{ title: '手机号', dataIndex: 'phone', width: 130, render: (v: string) => v || '-' },
|
||
{ title: '积分', dataIndex: 'credits', width: 100, render: (v: number) => <Typography.Text style={{ color: '#6366f1' }}>{(v ?? 0).toFixed(2)}</Typography.Text> },
|
||
{ title: '状态', dataIndex: 'isActive', width: 80, render: (v: boolean) => <Tag color={v ? 'green' : 'red'}>{v ? '启用' : '禁用'}</Tag> },
|
||
{ title: '加入时间', dataIndex: 'joinedAt', width: 170, render: (v: string) => formatDateTime(v) },
|
||
{
|
||
title: '操作', key: 'action', width: 100,
|
||
render: (_: any, r: TeamMember) => {
|
||
const currentUserId = useAuthStore.getState().user?.id;
|
||
const isSelf = r.id === currentUserId;
|
||
return isSelf ? (
|
||
<Tooltip title="不能给自己调整积分">
|
||
<Button size="small" type="link" style={{ padding: 0, color: '#999', cursor: 'not-allowed' }} disabled>调整积分</Button>
|
||
</Tooltip>
|
||
) : (
|
||
<Button size="small" type="link" style={{ padding: 0 }} onClick={() => { setCreditModal({ open: true, member: r }); creditForm.resetFields(); }}>调整积分</Button>
|
||
);
|
||
},
|
||
},
|
||
];
|
||
|
||
const invColumns = [
|
||
{ title: '邀请码', dataIndex: 'code', width: 200, render: (v: string) => <Typography.Text copyable style={{ fontFamily: 'monospace' }}>{v}</Typography.Text> },
|
||
{
|
||
title: '邀请链接', dataIndex: 'inviteLink', ellipsis: true,
|
||
render: (v: string) => (
|
||
<Space>
|
||
<Typography.Text ellipsis style={{ maxWidth: 250, fontSize: 12 }}>{v}</Typography.Text>
|
||
<Tooltip title="复制链接">
|
||
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyInviteLink(v)} />
|
||
</Tooltip>
|
||
</Space>
|
||
),
|
||
},
|
||
{ title: '状态', dataIndex: 'status', width: 80, render: (v: string) => <Tag color={v === 'active' ? 'green' : 'default'}>{v === 'active' ? '有效' : '已撤销'}</Tag> },
|
||
{ title: '使用次数', key: 'uses', width: 100, render: (_: any, r: TeamInvitation) => `${r.useCount}${r.maxUses ? `/${r.maxUses}` : ''}` },
|
||
{ title: '过期时间', dataIndex: 'expiresAt', width: 170, render: (v: string) => v ? formatDateTime(v) : '永不过期' },
|
||
{
|
||
title: '操作', key: 'action', width: 80,
|
||
render: (_: any, r: TeamInvitation) => r.status === 'active' ? (
|
||
<Button size="small" type="link" danger onClick={() => handleRevoke(r.id)}>撤销</Button>
|
||
) : null,
|
||
},
|
||
];
|
||
|
||
const reqColumns = [
|
||
{ title: '申请人', dataIndex: 'username', width: 140, render: (v: string) => <Typography.Text strong>{v}</Typography.Text> },
|
||
{ title: '手机号', dataIndex: 'phone', width: 130, render: (v: string) => v || '-' },
|
||
{ title: '申请时间', dataIndex: 'createdAt', width: 170, render: (v: string) => formatDateTime(v) },
|
||
{
|
||
title: '操作', key: 'action', width: 160,
|
||
render: (_: any, r: TeamJoinRequest) => (
|
||
<Space size={4}>
|
||
<Button size="small" type="link" style={{ color: '#16a34a', padding: 0 }} onClick={() => handleRequest(r.id, 'approve')}>通过</Button>
|
||
<Button size="small" type="link" danger style={{ padding: 0 }} onClick={() => {
|
||
Modal.confirm({
|
||
title: '拒绝申请',
|
||
content: (
|
||
<Form layout="vertical" style={{ marginTop: 12 }}>
|
||
<Form.Item name="note" label="拒绝原因(可选)">
|
||
<Input.TextArea rows={2} placeholder="选填" id="reject-note-input" />
|
||
</Form.Item>
|
||
</Form>
|
||
),
|
||
onOk: () => {
|
||
const note = (document.getElementById('reject-note-input') as HTMLTextAreaElement)?.value || undefined;
|
||
handleRequest(r.id, 'reject', note);
|
||
},
|
||
});
|
||
}}>拒绝</Button>
|
||
</Space>
|
||
),
|
||
},
|
||
];
|
||
|
||
const creditColumns = [
|
||
{ title: '用户名', dataIndex: 'username', width: 120, render: (v: string) => <Typography.Text strong>{v || '-'}</Typography.Text> },
|
||
{ title: '手机号', dataIndex: 'phone', width: 120, render: (v: string) => v || '-' },
|
||
{
|
||
title: '类型', dataIndex: 'type', width: 100,
|
||
render: (_: any, r: any) => {
|
||
const cfg = RECORD_TYPE_CONFIG[r.type] || { color: 'default', label: r.type || '-' };
|
||
return <Tag color={cfg.color}>{cfg.label}</Tag>;
|
||
},
|
||
},
|
||
{
|
||
title: '变动积分', dataIndex: 'amount', width: 110, align: 'right' as const,
|
||
render: (_: any, r: any) => (
|
||
<Typography.Text strong style={{ color: r.amount >= 0 ? '#10b981' : '#ef4444', fontSize: 14 }}>
|
||
{r.amount >= 0 ? '+' : ''}{(r.amount ?? 0).toFixed(2)}
|
||
</Typography.Text>
|
||
),
|
||
},
|
||
{ title: '余额', dataIndex: 'balanceAfter', width: 100, align: 'right' as const, render: (v: number) => (v ?? 0).toFixed(2) },
|
||
{ title: '说明', dataIndex: 'description', ellipsis: true, minWidth: 160, render: (v: string) => v || '-' },
|
||
{ title: '时间', dataIndex: 'createdAt', width: 170, render: (v: string) => formatDateTime(v) },
|
||
];
|
||
|
||
/* ── Tab 配置 ────────────────────────────────────────── */
|
||
const tableWrapper: React.CSSProperties = { borderRadius: 16, background: '#fff', border: '1px solid #f0f0f5', overflow: 'hidden' };
|
||
const paginationStyle: React.CSSProperties = { padding: '16px', textAlign: 'right' };
|
||
|
||
const tabItems = [
|
||
{
|
||
key: 'members',
|
||
label: <Space><UserOutlined />成员列表{membersTotal > 0 && <span style={{ color: '#94a3b8', fontSize: 12 }}>({membersTotal})</span>}</Space>,
|
||
children: (
|
||
<div style={tableWrapper}>
|
||
<Table
|
||
columns={memberColumns}
|
||
dataSource={members}
|
||
rowKey="id"
|
||
loading={membersLoading}
|
||
pagination={false}
|
||
bordered={false}
|
||
scroll={{ x: 800 }}
|
||
locale={{ emptyText: <Empty description="暂无成员" /> }}
|
||
/>
|
||
{membersTotal > 0 && (
|
||
<div style={paginationStyle}>
|
||
<Pagination current={membersPage} pageSize={20} total={membersTotal} onChange={(p) => setMembersPage(p)} size="small" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: 'credits',
|
||
label: <Space><WalletOutlined />团队积分变动</Space>,
|
||
children: (
|
||
<div>
|
||
{/* 搜索栏 */}
|
||
<div style={{ marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||
<Select
|
||
value={creditFilterType || undefined}
|
||
onChange={(v) => { setCreditFilterType(v || ''); setCreditPage(1); }}
|
||
allowClear
|
||
placeholder="交易类型"
|
||
style={{ width: 130 }}
|
||
options={[
|
||
{ value: 'recharge', label: '充值' },
|
||
{ value: 'consume', label: '消费' },
|
||
{ value: 'team_internal', label: '团队内部' },
|
||
{ value: 'refund', label: '退款' },
|
||
]}
|
||
/>
|
||
<Input
|
||
placeholder="搜索手机号"
|
||
value={creditFilterPhone}
|
||
onChange={(e) => { setCreditFilterPhone(e.target.value); setCreditPage(1); }}
|
||
style={{ width: 160 }}
|
||
allowClear
|
||
/>
|
||
<RangePicker
|
||
value={creditDateRange ? [dayjs(creditDateRange[0]), dayjs(creditDateRange[1])] : undefined}
|
||
onChange={(dates) => {
|
||
if (dates && dates[0] && dates[1]) {
|
||
setCreditDateRange([dates[0].format('YYYY-MM-DD'), dates[1].format('YYYY-MM-DD')]);
|
||
} else {
|
||
setCreditDateRange(null);
|
||
}
|
||
setCreditPage(1);
|
||
}}
|
||
/>
|
||
<Button onClick={resetCreditFilters}>重置</Button>
|
||
<Button type="primary" icon={<DownloadOutlined />} onClick={handleExportCredits}>导出 Excel</Button>
|
||
</div>
|
||
|
||
{/* 汇总统计 */}
|
||
<div style={{ marginBottom: 12, padding: '8px 16px', background: '#f8f9fc', borderRadius: 8, display: 'flex', gap: 24, flexWrap: 'wrap', fontSize: 13 }}>
|
||
<span>总消耗积分:<strong style={{ color: '#ef4444', fontSize: 15 }}>{creditSummary?.total_consume ?? 0}</strong></span>
|
||
</div>
|
||
|
||
<div style={tableWrapper}>
|
||
<Table
|
||
rowKey="id"
|
||
loading={creditLoading}
|
||
dataSource={creditRecords}
|
||
pagination={false}
|
||
bordered={false}
|
||
scroll={{ x: 950 }}
|
||
columns={creditColumns}
|
||
locale={{ emptyText: <Empty description="暂无积分记录" /> }}
|
||
/>
|
||
{creditTotal > 0 && (
|
||
<div style={paginationStyle}>
|
||
<Pagination current={creditPage} pageSize={10} total={creditTotal} onChange={(p) => setCreditPage(p)} size="small" showTotal={(t) => `共 ${t} 条`} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: 'invitations',
|
||
label: <Space><CopyOutlined />邀请管理</Space>,
|
||
children: (
|
||
<div style={tableWrapper}>
|
||
<div style={{ padding: 16, paddingBottom: 0 }}>
|
||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setInvModal(true)}>生成邀请码</Button>
|
||
</div>
|
||
<Table
|
||
columns={invColumns}
|
||
dataSource={invitations}
|
||
rowKey="id"
|
||
loading={invLoading}
|
||
pagination={false}
|
||
bordered={false}
|
||
scroll={{ x: 900 }}
|
||
locale={{ emptyText: <Empty description="暂无邀请码" /> }}
|
||
/>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: 'requests',
|
||
label: <Space><HistoryOutlined />加入申请{requests.length > 0 && <Tag color="red">{requests.length}</Tag>}</Space>,
|
||
children: (
|
||
<div style={tableWrapper}>
|
||
<Table
|
||
columns={reqColumns}
|
||
dataSource={requests}
|
||
rowKey="id"
|
||
loading={reqLoading}
|
||
pagination={false}
|
||
bordered={false}
|
||
scroll={{ x: 600 }}
|
||
locale={{ emptyText: <Empty description="暂无待审批申请" /> }}
|
||
/>
|
||
</div>
|
||
),
|
||
},
|
||
];
|
||
|
||
/* ── 渲染 ────────────────────────────────────────────── */
|
||
return (
|
||
<div style={{ padding: 24 }}>
|
||
{/* 顶部团队信息 */}
|
||
<div style={{ marginBottom: 24 }}>
|
||
{teamLoading ? (
|
||
<Typography.Text type="secondary">加载中...</Typography.Text>
|
||
) : team ? (
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
|
||
<div>
|
||
<Typography.Title level={4} style={{ margin: 0 }}>{team.name}</Typography.Title>
|
||
<Typography.Text type="secondary">代码: {team.code || '-'} 成员: {team.memberCount} 人</Typography.Text>
|
||
</div>
|
||
<Button icon={<ReloadOutlined />} onClick={() => { loadTeam(); loadMembers(); loadInvitations(); loadRequests(); loadCreditRecords(); }}>刷新</Button>
|
||
</div>
|
||
) : (
|
||
<Typography.Text type="secondary">无法获取团队信息</Typography.Text>
|
||
)}
|
||
</div>
|
||
|
||
{/* 标签页 */}
|
||
<Tabs items={tabItems} defaultActiveKey="members" size="large" />
|
||
|
||
{/* 调整积分弹窗 */}
|
||
<Modal
|
||
title={<Space><UserOutlined />调整成员积分 - {creditModal.member?.username}</Space>}
|
||
open={creditModal.open}
|
||
confirmLoading={creditSaving}
|
||
onOk={handleTransfer}
|
||
onCancel={() => setCreditModal({ open: false, member: null })}
|
||
okText="确认"
|
||
width={480}
|
||
>
|
||
<Form form={creditForm} layout="vertical" style={{ marginTop: 16 }} initialValues={{ direction: 'increase' }}>
|
||
{/* 显示管理人当前积分 */}
|
||
<div style={{ marginBottom: 16, padding: '10px 16px', background: '#f0f4ff', borderRadius: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<Typography.Text type="secondary">我的当前积分</Typography.Text>
|
||
<Typography.Text strong style={{ fontSize: 20, color: '#6366f1' }}>
|
||
{useAuthStore.getState().user?.credits?.toFixed(2) ?? '0.00'}
|
||
</Typography.Text>
|
||
</div>
|
||
<Form.Item name="direction" label="操作类型" rules={[{ required: true, message: '请选择操作类型' }]}>
|
||
<Radio.Group buttonStyle="solid" size="large" style={{ width: '100%' }}>
|
||
<Radio.Button value="increase" style={{ width: '50%', textAlign: 'center' }}>增加成员积分</Radio.Button>
|
||
<Radio.Button value="decrease" style={{ width: '50%', textAlign: 'center' }}>扣减成员积分</Radio.Button>
|
||
</Radio.Group>
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="amount"
|
||
label="积分数量"
|
||
required
|
||
rules={[
|
||
{ required: true, message: '请输入积分数量' },
|
||
{ type: 'number', min: 0.01, message: '必须大于 0' },
|
||
{ type: 'number', max: 9999999, message: '单次不能超过 9999999' },
|
||
]}
|
||
validateTrigger={['onChange', 'onBlur']}
|
||
>
|
||
<InputNumber
|
||
style={{ width: '100%' }}
|
||
step={1}
|
||
min={0.01}
|
||
max={9999999}
|
||
precision={2}
|
||
placeholder="请输入正数积分数量"
|
||
size="large"
|
||
formatter={(value) => {
|
||
if (!value) return '';
|
||
let str = `${value}`.replace(/[^0-9.]/g, '');
|
||
str = str.replace(/^0+(?=\d)/, '');
|
||
return str;
|
||
}}
|
||
parser={(str) => {
|
||
if (!str || str === '.') return '' as any;
|
||
let num = parseFloat(str);
|
||
if (isNaN(num) || num <= 0) return '' as any;
|
||
return Math.min(num, 9999999) as any;
|
||
}}
|
||
onKeyDown={(e) => {
|
||
// 禁止输入负号、e、E
|
||
if (e.key === '-' || e.key === 'e' || e.key === 'E') {
|
||
e.preventDefault();
|
||
}
|
||
}}
|
||
onChange={(val) => {
|
||
if (val === null || val === undefined) {
|
||
creditForm.validateFields(['amount']);
|
||
}
|
||
}}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="description" label="备注">
|
||
<Input.TextArea rows={2} maxLength={256} placeholder="选填,例如:活动奖励" />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
{/* 生成邀请码弹窗 */}
|
||
<Modal
|
||
title={<Space><CopyOutlined />生成邀请码</Space>}
|
||
open={invModal}
|
||
confirmLoading={invSaving}
|
||
onOk={handleCreateInvitation}
|
||
onCancel={() => { setInvModal(false); invForm.resetFields(); }}
|
||
okText="生成"
|
||
width={480}
|
||
>
|
||
<Form form={invForm} layout="vertical" style={{ marginTop: 16 }}>
|
||
<Form.Item name="maxUses" label="最大使用次数">
|
||
<InputNumber style={{ width: '100%' }} min={1} placeholder="留空表示不限" size="large" />
|
||
</Form.Item>
|
||
<Form.Item name="expiresAt" label="过期时间">
|
||
<Input type="datetime-local" style={{ width: '100%' }} placeholder="留空表示永不过期" size="large" />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default TeamManagementPage;
|