Files
video-gen/video-gen-app/src/pages/TeamManagementPage.tsx
T
2026-08-05 10:24:56 +08:00

791 lines
31 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 React, { useEffect, useState, useCallback, useRef } from 'react';
import {
Button, Empty, Form, Input, InputNumber, message, Modal, Pagination, Radio, Select, Segmented, Space, Table, Tabs, Tag, Tooltip, Typography,
} from 'antd';
import { DatePicker } from 'antd';
import dayjs from 'dayjs';
import {
CopyOutlined, DownloadOutlined, PlusOutlined, ReloadOutlined, UserOutlined, HistoryOutlined, WalletOutlined, BellOutlined, ClockCircleOutlined, CheckOutlined, CloseOutlined,
} 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);
await createTeamInvitation(values.maxUses || null, null);
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) => {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(link).then(() => {
message.success('邀请链接已复制');
}).catch(() => {
fallbackCopy(link);
});
} else {
fallbackCopy(link);
}
};
const fallbackCopy = (text: string) => {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-9999px';
textArea.style.top = '-9999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
message.success('邀请链接已复制');
} catch {
message.warning('复制失败,请手动复制');
}
document.body.removeChild(textArea);
};
// ── Tab 3: 加入申请 ──
const [requests, setRequests] = useState<TeamJoinRequest[]>([]);
const [reqLoading, setReqLoading] = useState(false);
const [reqStatusFilter, setReqStatusFilter] = useState<string>('pending');
const [activeTab, setActiveTab] = useState('members');
const initialNoticeShownRef = useRef(false);
const lastRequestCountRef = useRef(0);
const loadRequests = useCallback(async (status: string, isInitial = false) => {
setReqLoading(true);
try {
const data = await getPendingJoinRequests(status);
const currentCount = data?.length || 0;
setRequests(data || []);
// 只有待处理状态才显示弹窗通知
if (status === 'pending' && currentCount > 0) {
if (isInitial && !initialNoticeShownRef.current) {
initialNoticeShownRef.current = true;
Modal.confirm({
title: (
<Space>
<BellOutlined style={{ color: '#f59e0b' }} />
待处理的加入申请
</Space>
),
content: (
<div>
<p>您有 <strong style={{ color: '#ef4444' }}>{currentCount}</strong> 条待处理的团队加入申请。</p>
<p style={{ color: '#64748b', fontSize: 13, marginBottom: 0 }}>请及时处理新成员的加入申请。</p>
</div>
),
okText: '立即处理',
cancelText: '稍后处理',
onOk: () => {
setActiveTab('requests');
},
});
} else if (!isInitial && currentCount > lastRequestCountRef.current) {
message.info({
content: `有 ${currentCount - lastRequestCountRef.current} 条新的加入申请待处理`,
duration: 5,
});
}
}
if (status === 'pending') {
lastRequestCountRef.current = currentCount;
}
} catch (e: any) {
message.error(e?.message || '加载申请失败');
} finally {
setReqLoading(false);
}
}, []);
useEffect(() => {
loadRequests('pending', true);
}, [loadRequests]);
useEffect(() => {
if (!team) return;
const interval = setInterval(() => {
loadRequests('pending', false);
}, 60000);
return () => clearInterval(interval);
}, [team, loadRequests]);
const handleRequest = async (requestId: string, action: 'approve' | 'reject', note?: string) => {
try {
await handleJoinRequest(requestId, action, note);
message.success(action === 'approve' ? '已通过' : '已拒绝');
loadRequests(reqStatusFilter);
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>(() => {
const today = dayjs().format('YYYY-MM-DD');
return [today, today];
});
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('');
const today = dayjs().format('YYYY-MM-DD');
setCreditDateRange([today, today]);
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 = `团队积分_${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 buildInviteLink = (code: string) => {
const base = window.location.origin;
return `${base}/join-team?code=${code}`;
};
const invColumns = [
{ title: '邀请码', dataIndex: 'code', width: 200, render: (v: string) => <Typography.Text copyable style={{ fontFamily: 'monospace' }}>{v}</Typography.Text> },
{
title: '邀请链接', dataIndex: 'code',
render: (v: string) => {
const link = buildInviteLink(v);
return (
<Space style={{ width: '100%' }}>
<Typography.Text
style={{
flex: 1,
fontSize: 12,
wordBreak: 'break-all',
fontFamily: 'monospace',
color: '#64748b',
}}
>
{link}
</Typography.Text>
<Tooltip title="复制链接">
<Button
size="small"
type="text"
icon={<CopyOutlined />}
onClick={() => copyInviteLink(link)}
/>
</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 renderStatusTag = (status: string) => {
const map: Record<string, { color: string; text: string }> = {
pending: { color: 'orange', text: '待处理' },
approved: { color: 'green', text: '已通过' },
rejected: { color: 'red', text: '已拒绝' },
};
const info = map[status] || { color: 'default', text: status };
return <Tag color={info.color}>{info.text}</Tag>;
};
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: 'status', width: 100, render: (v: string) => renderStatusTag(v) },
{ title: '申请时间', dataIndex: 'createdAt', width: 170, render: (v: string) => formatDateTime(v) },
{ title: '处理时间', dataIndex: 'handledAt', width: 170, render: (v: string) => v ? formatDateTime(v) : '-' },
{ title: '备注', dataIndex: 'note', width: 180, ellipsis: true, render: (v: string) => v || '-' },
{
title: '操作', key: 'action', width: 160,
render: (_: any, r: TeamJoinRequest) => {
if (r.status !== 'pending') return <span style={{ color: '#94a3b8', fontSize: 12 }}>已处理</span>;
return (
<Space size={4}>
<Button size="small" type="link" icon={<CheckOutlined />} style={{ color: '#16a34a', padding: 0 }} onClick={() => handleRequest(r.id, 'approve')}>通过</Button>
<Button size="small" type="link" icon={<CloseOutlined />} 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: '12px 18px', background: '#f8f9fc', borderRadius: 10, display: 'flex', gap: 28, flexWrap: 'wrap', fontSize: 13, alignItems: 'center' }}>
<span>
总净消耗积分:
<strong style={{ color: '#ef4444', fontSize: 16, marginLeft: 4 }}>
{creditSummary?.netConsume ?? 0}
</strong>
</span>
<span style={{ color: '#94a3b8' }}>|</span>
<span>
消费合计:
<strong style={{ color: '#f97316', marginLeft: 4 }}>
{creditSummary?.totalConsume ?? 0}
</strong>
</span>
<span>
退款合计:
<strong style={{ color: '#10b981', marginLeft: 4 }}>
{creditSummary?.totalRefund ?? 0}
</strong>
</span>
<span>
充值合计:
<strong style={{ color: '#6366f1', marginLeft: 4 }}>
+ {creditSummary?.totalRecharge ?? 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 && reqStatusFilter === 'pending' && <Tag color="red">{requests.length}</Tag>}</Space>,
children: (
<>
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center' }}>
<Segmented
value={reqStatusFilter}
onChange={(v) => {
setReqStatusFilter(v as string);
loadRequests(v as string);
}}
options={[
{ label: '待处理', value: 'pending' },
{ label: '已通过', value: 'approved' },
{ label: '已拒绝', value: 'rejected' },
{ label: '全部', value: '' },
]}
style={{
borderRadius: 12,
border: '1px solid #e2e8f0',
padding: 3,
background: '#f8fafc',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
'--ant-segmented-item-selected-bg': '#6366f1',
'--ant-segmented-item-selected-color': '#ffffff',
} as React.CSSProperties}
size="middle"
/>
</div>
<div style={tableWrapper}>
<Table
columns={reqColumns}
dataSource={requests}
rowKey="id"
loading={reqLoading}
pagination={false}
bordered={false}
scroll={{ x: 1000 }}
locale={{ emptyText: <Empty description={reqStatusFilter === 'pending' ? '暂无待审批申请' : '暂无记录'} /> }}
/>
</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(reqStatusFilter); loadCreditRecords(); }}>刷新</Button>
</div>
) : (
<Typography.Text type="secondary">无法获取团队信息</Typography.Text>
)}
</div>
{/* 标签页 */}
<Tabs items={tabItems} activeKey={activeTab} onChange={setActiveTab} 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>
<div style={{
padding: '12px 16px',
background: '#eef2ff',
borderRadius: 8,
fontSize: 13,
color: '#4f46e5',
display: 'flex',
alignItems: 'center',
gap: 8,
}}>
<ClockCircleOutlined />
<span>邀请码自生成起 <strong>24 小时</strong> 内有效,过期自动失效。</span>
</div>
</Form>
</Modal>
</div>
);
};
export default TeamManagementPage;