This commit is contained in:
2026-07-06 16:57:13 +08:00
3 changed files with 225 additions and 184 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
} }
})(); })();
</script> </script>
<script type="module" crossorigin src="/assets/index-DlbOvnzQ.js"></script> <script type="module" crossorigin src="/assets/index-DlCxaJzD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D9_3MPsN.css"> <link rel="stylesheet" crossorigin href="/assets/index-D9_3MPsN.css">
</head> </head>
<body> <body>
+130 -89
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState, useCallback } from 'react'; import React, { useEffect, useState, useCallback } from 'react';
import { import {
Button, Card, Empty, Form, Input, InputNumber, message, Modal, Radio, Select, Space, Table, Tabs, Tag, Tooltip, Typography, Button, Empty, Form, Input, InputNumber, message, Modal, Pagination, Radio, Select, Space, Table, Tabs, Tag, Tooltip, Typography,
} from 'antd'; } from 'antd';
import { DatePicker } from 'antd'; import { DatePicker } from 'antd';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@@ -14,9 +14,26 @@ import {
getTeamCreditExportUrl, getTeamCreditRecords, getTeamInvitations, getTeamMembers, handleJoinRequest, revokeInvitation, submitJoinRequest, transferCredits, getTeamCreditExportUrl, getTeamCreditRecords, getTeamInvitations, getTeamMembers, handleJoinRequest, revokeInvitation, submitJoinRequest, transferCredits,
} from '../api'; } from '../api';
import type { ManagedTeam, TeamInvitation, TeamJoinRequest, TeamMember } from '../types'; import type { ManagedTeam, TeamInvitation, TeamJoinRequest, TeamMember } from '../types';
import { formatDate } from '../utils/formatDate';
import { useAuthStore } from '../store/useAuthStore'; 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 TeamManagementPage: React.FC = () => {
const [team, setTeam] = useState<ManagedTeam | null>(null); const [team, setTeam] = useState<ManagedTeam | null>(null);
const [teamLoading, setTeamLoading] = useState(false); const [teamLoading, setTeamLoading] = useState(false);
@@ -57,6 +74,7 @@ const TeamManagementPage: React.FC = () => {
useEffect(() => { loadMembers(); }, [loadMembers]); useEffect(() => { loadMembers(); }, [loadMembers]);
// ── 调整积分弹窗 ──
const [creditModal, setCreditModal] = useState<{ open: boolean; member: TeamMember | null }>({ open: false, member: null }); const [creditModal, setCreditModal] = useState<{ open: boolean; member: TeamMember | null }>({ open: false, member: null });
const [creditForm] = Form.useForm(); const [creditForm] = Form.useForm();
const [creditSaving, setCreditSaving] = useState(false); const [creditSaving, setCreditSaving] = useState(false);
@@ -65,14 +83,20 @@ const TeamManagementPage: React.FC = () => {
if (!creditModal.member) return; if (!creditModal.member) return;
try { try {
const values = await creditForm.validateFields(); const values = await creditForm.validateFields();
// 二次校验:确保是正数
const amount = Number(values.amount);
if (!amount || amount <= 0 || amount > 9999999) {
message.error('请输入有效的正数积分数量');
return;
}
setCreditSaving(true); setCreditSaving(true);
await transferCredits( await transferCredits(
creditModal.member.id, creditModal.member.id,
values.amount, amount,
values.direction || "increase", values.direction || 'increase',
values.description, values.description,
); );
message.success(values.direction === "decrease" ? '积分扣减成功' : '积分增加成功'); message.success(values.direction === 'decrease' ? '积分扣减成功' : '积分增加成功');
setCreditModal({ open: false, member: null }); setCreditModal({ open: false, member: null });
creditForm.resetFields(); creditForm.resetFields();
loadMembers(); loadMembers();
@@ -179,19 +203,18 @@ const TeamManagementPage: React.FC = () => {
const [creditPage, setCreditPage] = useState(1); const [creditPage, setCreditPage] = useState(1);
const [creditFilterType, setCreditFilterType] = useState<string>(''); const [creditFilterType, setCreditFilterType] = useState<string>('');
const [creditFilterPhone, setCreditFilterPhone] = useState<string>(''); const [creditFilterPhone, setCreditFilterPhone] = useState<string>('');
const [creditFilterStart, setCreditFilterStart] = useState<string>(''); const [creditDateRange, setCreditDateRange] = useState<[string, string] | null>(null);
const [creditFilterEnd, setCreditFilterEnd] = useState<string>('');
const loadCreditRecords = useCallback(async () => { const loadCreditRecords = useCallback(async () => {
setCreditLoading(true); setCreditLoading(true);
try { try {
const res = await getTeamCreditRecords({ const res = await getTeamCreditRecords({
page: creditPage, page: creditPage,
pageSize: 20, pageSize: 10,
phone: creditFilterPhone || undefined, phone: creditFilterPhone || undefined,
recordType: creditFilterType || undefined, recordType: creditFilterType || undefined,
startDate: creditFilterStart || undefined, startDate: creditDateRange?.[0] || undefined,
endDate: creditFilterEnd || undefined, endDate: creditDateRange?.[1] || undefined,
}); });
setCreditRecords(res.items || []); setCreditRecords(res.items || []);
setCreditTotal(res.total || 0); setCreditTotal(res.total || 0);
@@ -201,15 +224,14 @@ const TeamManagementPage: React.FC = () => {
} finally { } finally {
setCreditLoading(false); setCreditLoading(false);
} }
}, [creditPage, creditFilterType, creditFilterPhone, creditFilterStart, creditFilterEnd]); }, [creditPage, creditFilterType, creditFilterPhone, creditDateRange]);
useEffect(() => { loadCreditRecords(); }, [loadCreditRecords]); useEffect(() => { loadCreditRecords(); }, [loadCreditRecords]);
const resetCreditFilters = () => { const resetCreditFilters = () => {
setCreditFilterType(''); setCreditFilterType('');
setCreditFilterPhone(''); setCreditFilterPhone('');
setCreditFilterStart(''); setCreditDateRange(null);
setCreditFilterEnd('');
setCreditPage(1); setCreditPage(1);
}; };
@@ -217,10 +239,9 @@ const TeamManagementPage: React.FC = () => {
const url = getTeamCreditExportUrl({ const url = getTeamCreditExportUrl({
phone: creditFilterPhone || undefined, phone: creditFilterPhone || undefined,
recordType: creditFilterType || undefined, recordType: creditFilterType || undefined,
startDate: creditFilterStart || undefined, startDate: creditDateRange?.[0] || undefined,
endDate: creditFilterEnd || undefined, endDate: creditDateRange?.[1] || undefined,
}); });
// 携带 token 下载
const token = localStorage.getItem('auth_token'); const token = localStorage.getItem('auth_token');
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {}; const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
fetch(url, { headers }) fetch(url, { headers })
@@ -228,23 +249,24 @@ const TeamManagementPage: React.FC = () => {
.then((blob) => { .then((blob) => {
const a = document.createElement('a'); const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.href = URL.createObjectURL(blob);
a.download = `team_credits_${Date.now()}.csv`; a.download = `team_credits_${dayjs().format('YYYYMMDD_HHmmss')}.csv`;
a.click(); a.click();
URL.revokeObjectURL(a.href); URL.revokeObjectURL(a.href);
}) })
.catch(() => message.error('导出失败')); .catch(() => message.error('导出失败'));
}; };
/* ── 表格列定义 ──────────────────────────────────────── */
const memberColumns = [ const memberColumns = [
{ title: '用户名', dataIndex: 'username', width: 150, render: (v: string) => <Typography.Text strong>{v}</Typography.Text> }, { 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: '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: '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: 'isActive', width: 80, render: (v: boolean) => <Tag color={v ? 'green' : 'red'}>{v ? '启用' : '禁用'}</Tag> },
{ title: '加入时间', dataIndex: 'joinedAt', width: 160, render: (v: string) => formatDate(v) }, { title: '加入时间', dataIndex: 'joinedAt', width: 170, render: (v: string) => formatDateTime(v) },
{ {
title: '操作', key: 'action', width: 100, title: '操作', key: 'action', width: 100,
render: (_: any, r: TeamMember) => ( render: (_: any, r: TeamMember) => (
<Button size="small" type="link" onClick={() => { setCreditModal({ open: true, member: r }); creditForm.resetFields(); }}></Button> <Button size="small" type="link" style={{ padding: 0 }} onClick={() => { setCreditModal({ open: true, member: r }); creditForm.resetFields(); }}></Button>
), ),
}, },
]; ];
@@ -264,7 +286,7 @@ const TeamManagementPage: React.FC = () => {
}, },
{ title: '状态', dataIndex: 'status', width: 80, render: (v: string) => <Tag color={v === 'active' ? 'green' : 'default'}>{v === 'active' ? '有效' : '已撤销'}</Tag> }, { 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: '使用次数', 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: '过期时间', dataIndex: 'expiresAt', width: 170, render: (v: string) => v ? formatDateTime(v) : '永不过期' },
{ {
title: '操作', key: 'action', width: 80, title: '操作', key: 'action', width: 80,
render: (_: any, r: TeamInvitation) => r.status === 'active' ? ( render: (_: any, r: TeamInvitation) => r.status === 'active' ? (
@@ -274,9 +296,9 @@ const TeamManagementPage: React.FC = () => {
]; ];
const reqColumns = [ const reqColumns = [
{ title: '申请人', dataIndex: 'username', width: 150, render: (v: string) => <Typography.Text strong>{v}</Typography.Text> }, { 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: 'phone', width: 130, render: (v: string) => v || '-' },
{ title: '申请时间', dataIndex: 'createdAt', width: 160, render: (v: string) => formatDate(v) }, { title: '申请时间', dataIndex: 'createdAt', width: 170, render: (v: string) => formatDateTime(v) },
{ {
title: '操作', key: 'action', width: 160, title: '操作', key: 'action', width: 160,
render: (_: any, r: TeamJoinRequest) => ( render: (_: any, r: TeamJoinRequest) => (
@@ -303,6 +325,30 @@ const TeamManagementPage: React.FC = () => {
}, },
]; ];
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 tabItems = [ const tabItems = [
{ {
key: 'members', key: 'members',
@@ -313,14 +359,7 @@ const TeamManagementPage: React.FC = () => {
dataSource={members} dataSource={members}
rowKey="id" rowKey="id"
loading={membersLoading} loading={membersLoading}
pagination={{ pagination={false}
current: membersPage,
pageSize: 20,
total: membersTotal,
onChange: (p) => setMembersPage(p),
showTotal: (t) => `${t}`,
position: ['bottomLeft'],
}}
scroll={{ x: 800 }} scroll={{ x: 800 }}
locale={{ emptyText: <Empty description="暂无成员" /> }} locale={{ emptyText: <Empty description="暂无成员" /> }}
/> />
@@ -354,14 +393,12 @@ const TeamManagementPage: React.FC = () => {
allowClear allowClear
/> />
<RangePicker <RangePicker
value={creditFilterStart && creditFilterEnd ? [dayjs(creditFilterStart), dayjs(creditFilterEnd)] : undefined} value={creditDateRange ? [dayjs(creditDateRange[0]), dayjs(creditDateRange[1])] : undefined}
onChange={(dates) => { onChange={(dates) => {
if (dates && dates[0] && dates[1]) { if (dates && dates[0] && dates[1]) {
setCreditFilterStart(dates[0].format('YYYY-MM-DD')); setCreditDateRange([dates[0].format('YYYY-MM-DD'), dates[1].format('YYYY-MM-DD')]);
setCreditFilterEnd(dates[1].format('YYYY-MM-DD'));
} else { } else {
setCreditFilterStart(''); setCreditDateRange(null);
setCreditFilterEnd('');
} }
setCreditPage(1); setCreditPage(1);
}} }}
@@ -371,48 +408,18 @@ const TeamManagementPage: React.FC = () => {
</div> </div>
{/* 汇总统计 */} {/* 汇总统计 */}
<div style={{ marginBottom: 12, padding: '8px 12px', background: '#f8f9fc', borderRadius: 8, display: 'flex', gap: 20, flexWrap: 'wrap', fontSize: 13 }}> <div style={{ marginBottom: 12, padding: '8px 16px', background: '#f8f9fc', borderRadius: 8, display: 'flex', gap: 24, flexWrap: 'wrap', fontSize: 13 }}>
<span><strong style={{ color: '#16a34a' }}>+{creditSummary?.total_recharge ?? 0}</strong></span> <span><strong style={{ color: '#10b981', fontSize: 15 }}>+{creditSummary?.total_recharge ?? 0}</strong></span>
<span><strong style={{ color: '#dc2626' }}>-{creditSummary?.total_consume ?? 0}</strong></span> <span><strong style={{ color: '#ef4444', fontSize: 15 }}>-{creditSummary?.total_consume ?? 0}</strong></span>
</div> </div>
<Table <Table
size="small"
rowKey="id" rowKey="id"
loading={creditLoading} loading={creditLoading}
dataSource={creditRecords} dataSource={creditRecords}
pagination={{ pagination={false}
current: creditPage,
pageSize: 20,
total: creditTotal,
onChange: (p) => setCreditPage(p),
showTotal: (t) => `${t}`,
position: ['bottomLeft'],
}}
scroll={{ x: 950 }} scroll={{ x: 950 }}
columns={[ columns={creditColumns}
{ title: '用户名', key: 'username', width: 120, render: (_: any, r: any) => <Typography.Text strong>{r.username || '-'}</Typography.Text> },
{ title: '手机号', key: 'phone', width: 120, render: (_: any, r: any) => r.phone || '-' },
{
title: '类型', key: 'type', width: 100,
render: (_: any, r: any) => {
const colorMap: Record<string, string> = { recharge: 'green', consume: 'red', refund: 'orange', team_internal: 'blue' };
const labelMap: Record<string, string> = { recharge: '充值', consume: '消费', refund: '退款', team_internal: '团队内部' };
return <Tag color={colorMap[r.type] || 'default'}>{labelMap[r.type] || r.type || '-'}</Tag>;
},
},
{
title: '积分变动', key: 'amount', width: 100,
render: (_: any, r: any) => (
<Typography.Text style={{ color: r.amount >= 0 ? '#16a34a' : '#dc2626', fontWeight: 600 }}>
{r.amount >= 0 ? '+' : ''}{(r.amount ?? 0).toFixed(2)}
</Typography.Text>
),
},
{ title: '余额', key: 'balance_after', width: 100, render: (_: any, r: any) => (r.balance_after ?? 0).toFixed(2) },
{ title: '说明', dataIndex: 'description', ellipsis: true, render: (v: string) => v || '-' },
{ title: '时间', key: 'created_at', width: 160, render: (_: any, r: any) => formatDate(r.created_at) },
]}
locale={{ emptyText: <Empty description="暂无积分记录" /> }} locale={{ emptyText: <Empty description="暂无积分记录" /> }}
/> />
</div> </div>
@@ -455,10 +462,14 @@ const TeamManagementPage: React.FC = () => {
}, },
]; ];
/* ── 渲染 ────────────────────────────────────────────── */
return ( return (
<div style={{ padding: 24, maxWidth: 1200, margin: '0 auto' }}> <div style={{ padding: 24 }}>
<Card variant="outlined" style={{ borderRadius: 12, marginBottom: 16 }} loading={teamLoading}> {/* 顶部团队信息 */}
{team && ( <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 style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
<div> <div>
<Typography.Title level={4} style={{ margin: 0 }}>{team.name}</Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}>{team.name}</Typography.Title>
@@ -466,12 +477,41 @@ const TeamManagementPage: React.FC = () => {
</div> </div>
<Button icon={<ReloadOutlined />} onClick={() => { loadTeam(); loadMembers(); loadInvitations(); loadRequests(); loadCreditRecords(); }}></Button> <Button icon={<ReloadOutlined />} onClick={() => { loadTeam(); loadMembers(); loadInvitations(); loadRequests(); loadCreditRecords(); }}></Button>
</div> </div>
) : (
<Typography.Text type="secondary"></Typography.Text>
)} )}
</Card> </div>
<Card variant="outlined" style={{ borderRadius: 12 }}> {/* 标签页 */}
<Tabs items={tabItems} defaultActiveKey="members" /> <Tabs items={tabItems} defaultActiveKey="members" size="large" />
</Card>
{/* 成员列表分页(左下角) */}
{membersTotal > 0 && (
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'flex-start' }}>
<Pagination
current={membersPage}
pageSize={20}
total={membersTotal}
onChange={(p) => setMembersPage(p)}
showTotal={(t) => `${t}`}
size="small"
/>
</div>
)}
{/* 积分记录分页(左下角) */}
{creditTotal > 0 && (
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'flex-start' }}>
<Pagination
current={creditPage}
pageSize={10}
total={creditTotal}
onChange={(p) => setCreditPage(p)}
showTotal={(t) => `${t}`}
size="small"
/>
</div>
)}
{/* 调整积分弹窗 */} {/* 调整积分弹窗 */}
<Modal <Modal
@@ -485,9 +525,9 @@ const TeamManagementPage: React.FC = () => {
> >
<Form form={creditForm} layout="vertical" style={{ marginTop: 16 }} initialValues={{ direction: 'increase' }}> <Form form={creditForm} layout="vertical" style={{ marginTop: 16 }} initialValues={{ direction: 'increase' }}>
{/* 显示管理人当前积分 */} {/* 显示管理人当前积分 */}
<div style={{ marginBottom: 16, padding: '8px 12px', background: '#f0f4ff', borderRadius: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <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 type="secondary"></Typography.Text>
<Typography.Text strong style={{ fontSize: 18, color: '#6366f1' }}> <Typography.Text strong style={{ fontSize: 20, color: '#6366f1' }}>
{useAuthStore.getState().user?.credits?.toFixed(2) ?? '0.00'} {useAuthStore.getState().user?.credits?.toFixed(2) ?? '0.00'}
</Typography.Text> </Typography.Text>
</div> </div>
@@ -504,17 +544,18 @@ const TeamManagementPage: React.FC = () => {
{ required: true, message: '请输入积分数量' }, { required: true, message: '请输入积分数量' },
{ type: 'number', min: 0.01, message: '必须大于 0' }, { type: 'number', min: 0.01, message: '必须大于 0' },
{ type: 'number', max: 9999999, message: '单次不能超过 9999999' }, { type: 'number', max: 9999999, message: '单次不能超过 9999999' },
{
validator: (_, value) => {
if (!value || value <= 0) return Promise.reject(new Error('请输入有效的正数'));
const decimals = String(value).split('.')[1];
if (decimals && decimals.length > 2) return Promise.reject(new Error('最多两位小数'));
return Promise.resolve();
},
},
]} ]}
> >
<InputNumber style={{ width: '100%' }} step={1} min={0.01} precision={2} placeholder="请输入正数积分数量" size="large" /> <InputNumber
style={{ width: '100%' }}
step={1}
min={0.01}
max={9999999}
precision={2}
placeholder="请输入正数积分数量"
size="large"
formatter={(value) => value ? `${value}`.replace(/[^0-9.]/g, '') : ''}
/>
</Form.Item> </Form.Item>
<Form.Item name="description" label="备注"> <Form.Item name="description" label="备注">
<Input.TextArea rows={2} maxLength={256} placeholder="选填,例如:活动奖励" /> <Input.TextArea rows={2} maxLength={256} placeholder="选填,例如:活动奖励" />