1、后台/teams需要可以设置管理人,后台只给这个管理人分配积分,然后前台管理员自己分配积分

2、前台判断用户是管理人,左下角显示团队管理
3、团队管理可以看到团队的人员、分配人员积分、人员的积分情况的功能
4、团队管理还可以邀请用户,比如生成个链接,未注册需要注册绑定团队,已注册用户访问弹窗是否加入这个团队,然后都需要管理同意才可以加入团队
This commit is contained in:
2026-07-06 13:38:27 +08:00
parent 30db9fb425
commit 16b3d223c1
25 changed files with 1671 additions and 29 deletions
@@ -0,0 +1,351 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Button, Card, Empty, Form, Input, InputNumber, message, Modal, Space, Table, Tabs, Tag, Tooltip, Typography,
} from 'antd';
import {
CopyOutlined, PlusOutlined, ReloadOutlined, UserOutlined, HistoryOutlined,
} from '@ant-design/icons';
import {
createTeamInvitation, getJoinTeamInfo, getManagedTeam, getPendingJoinRequests,
getTeamInvitations, getTeamMembers, handleJoinRequest, revokeInvitation, submitJoinRequest, transferCredits,
} from '../api';
import type { ManagedTeam, TeamInvitation, TeamJoinRequest, TeamMember } from '../types';
import { formatDate } from '../utils/formatDate';
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();
setCreditSaving(true);
await transferCredits(creditModal.member.id, values.amount, values.description);
message.success('积分转账成功');
setCreditModal({ open: false, member: null });
creditForm.resetFields();
loadMembers();
} 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 || '操作失败');
}
};
const memberColumns = [
{ title: '用户名', dataIndex: 'username', width: 150, 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: 160, render: (v: string) => formatDate(v) },
{
title: '操作', key: 'action', width: 100,
render: (_: any, r: TeamMember) => (
<Button size="small" type="link" 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: 160, render: (v: string) => v ? formatDate(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: 150, render: (v: string) => <Typography.Text strong>{v}</Typography.Text> },
{ title: '手机号', dataIndex: 'phone', width: 130, render: (v: string) => v || '-' },
{ title: '申请时间', dataIndex: 'createdAt', width: 160, render: (v: string) => formatDate(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 tabItems = [
{
key: 'members',
label: <Space><UserOutlined />{membersTotal > 0 && <span style={{ color: '#94a3b8', fontSize: 12 }}>({membersTotal})</span>}</Space>,
children: (
<Table
columns={memberColumns}
dataSource={members}
rowKey="id"
loading={membersLoading}
pagination={{
current: membersPage,
pageSize: 20,
total: membersTotal,
onChange: (p) => setMembersPage(p),
showTotal: (t) => `${t}`,
}}
scroll={{ x: 800 }}
locale={{ emptyText: <Empty description="暂无成员" /> }}
/>
),
},
{
key: 'invitations',
label: <Space><CopyOutlined /></Space>,
children: (
<div>
<div style={{ marginBottom: 12 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setInvModal(true)}></Button>
</div>
<Table
columns={invColumns}
dataSource={invitations}
rowKey="id"
loading={invLoading}
pagination={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: (
<Table
columns={reqColumns}
dataSource={requests}
rowKey="id"
loading={reqLoading}
pagination={false}
scroll={{ x: 600 }}
locale={{ emptyText: <Empty description="暂无待审批申请" /> }}
/>
),
},
];
return (
<div style={{ padding: 24, maxWidth: 1200, margin: '0 auto' }}>
<Card variant="outlined" style={{ borderRadius: 12, marginBottom: 16 }} loading={teamLoading}>
{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(); }}></Button>
</div>
)}
</Card>
<Card variant="outlined" style={{ borderRadius: 12 }}>
<Tabs items={tabItems} defaultActiveKey="members" />
</Card>
{/* 积分转账弹窗 */}
<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 }}>
<Form.Item name="amount" label="转账积分" rules={[{ required: true, message: '请输入转账数量' }, { type: 'number', min: 0.01, message: '必须大于 0' }]}>
<InputNumber style={{ width: '100%' }} step={1} min={0.01} placeholder="正数:从您余额转给该成员" size="large" />
</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;