This commit is contained in:
2026-07-06 16:52:57 +08:00
parent 0a5f697c04
commit e72f54b65d
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 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">
</head>
<body>
+130 -89
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState, useCallback } from 'react';
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';
import { DatePicker } from 'antd';
import dayjs from 'dayjs';
@@ -14,9 +14,26 @@ import {
getTeamCreditExportUrl, getTeamCreditRecords, getTeamInvitations, getTeamMembers, handleJoinRequest, revokeInvitation, submitJoinRequest, transferCredits,
} from '../api';
import type { ManagedTeam, TeamInvitation, TeamJoinRequest, TeamMember } from '../types';
import { formatDate } from '../utils/formatDate';
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);
@@ -57,6 +74,7 @@ const TeamManagementPage: React.FC = () => {
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);
@@ -65,14 +83,20 @@ const TeamManagementPage: React.FC = () => {
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,
values.amount,
values.direction || "increase",
amount,
values.direction || 'increase',
values.description,
);
message.success(values.direction === "decrease" ? '积分扣减成功' : '积分增加成功');
message.success(values.direction === 'decrease' ? '积分扣减成功' : '积分增加成功');
setCreditModal({ open: false, member: null });
creditForm.resetFields();
loadMembers();
@@ -179,19 +203,18 @@ const TeamManagementPage: React.FC = () => {
const [creditPage, setCreditPage] = useState(1);
const [creditFilterType, setCreditFilterType] = useState<string>('');
const [creditFilterPhone, setCreditFilterPhone] = useState<string>('');
const [creditFilterStart, setCreditFilterStart] = useState<string>('');
const [creditFilterEnd, setCreditFilterEnd] = useState<string>('');
const [creditDateRange, setCreditDateRange] = useState<[string, string] | null>(null);
const loadCreditRecords = useCallback(async () => {
setCreditLoading(true);
try {
const res = await getTeamCreditRecords({
page: creditPage,
pageSize: 20,
pageSize: 10,
phone: creditFilterPhone || undefined,
recordType: creditFilterType || undefined,
startDate: creditFilterStart || undefined,
endDate: creditFilterEnd || undefined,
startDate: creditDateRange?.[0] || undefined,
endDate: creditDateRange?.[1] || undefined,
});
setCreditRecords(res.items || []);
setCreditTotal(res.total || 0);
@@ -201,15 +224,14 @@ const TeamManagementPage: React.FC = () => {
} finally {
setCreditLoading(false);
}
}, [creditPage, creditFilterType, creditFilterPhone, creditFilterStart, creditFilterEnd]);
}, [creditPage, creditFilterType, creditFilterPhone, creditDateRange]);
useEffect(() => { loadCreditRecords(); }, [loadCreditRecords]);
const resetCreditFilters = () => {
setCreditFilterType('');
setCreditFilterPhone('');
setCreditFilterStart('');
setCreditFilterEnd('');
setCreditDateRange(null);
setCreditPage(1);
};
@@ -217,10 +239,9 @@ const TeamManagementPage: React.FC = () => {
const url = getTeamCreditExportUrl({
phone: creditFilterPhone || undefined,
recordType: creditFilterType || undefined,
startDate: creditFilterStart || undefined,
endDate: creditFilterEnd || undefined,
startDate: creditDateRange?.[0] || undefined,
endDate: creditDateRange?.[1] || undefined,
});
// 携带 token 下载
const token = localStorage.getItem('auth_token');
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
fetch(url, { headers })
@@ -228,23 +249,24 @@ const TeamManagementPage: React.FC = () => {
.then((blob) => {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `team_credits_${Date.now()}.csv`;
a.download = `team_credits_${dayjs().format('YYYYMMDD_HHmmss')}.csv`;
a.click();
URL.revokeObjectURL(a.href);
})
.catch(() => message.error('导出失败'));
};
/* ── 表格列定义 ──────────────────────────────────────── */
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: '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: '加入时间', dataIndex: 'joinedAt', width: 170, render: (v: string) => formatDateTime(v) },
{
title: '操作', key: 'action', width: 100,
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: '使用次数', 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,
render: (_: any, r: TeamInvitation) => r.status === 'active' ? (
@@ -274,9 +296,9 @@ const TeamManagementPage: React.FC = () => {
];
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: 'createdAt', width: 160, render: (v: string) => formatDate(v) },
{ title: '申请时间', dataIndex: 'createdAt', width: 170, render: (v: string) => formatDateTime(v) },
{
title: '操作', key: 'action', width: 160,
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 = [
{
key: 'members',
@@ -313,14 +359,7 @@ const TeamManagementPage: React.FC = () => {
dataSource={members}
rowKey="id"
loading={membersLoading}
pagination={{
current: membersPage,
pageSize: 20,
total: membersTotal,
onChange: (p) => setMembersPage(p),
showTotal: (t) => `${t}`,
position: ['bottomLeft'],
}}
pagination={false}
scroll={{ x: 800 }}
locale={{ emptyText: <Empty description="暂无成员" /> }}
/>
@@ -354,14 +393,12 @@ const TeamManagementPage: React.FC = () => {
allowClear
/>
<RangePicker
value={creditFilterStart && creditFilterEnd ? [dayjs(creditFilterStart), dayjs(creditFilterEnd)] : undefined}
value={creditDateRange ? [dayjs(creditDateRange[0]), dayjs(creditDateRange[1])] : undefined}
onChange={(dates) => {
if (dates && dates[0] && dates[1]) {
setCreditFilterStart(dates[0].format('YYYY-MM-DD'));
setCreditFilterEnd(dates[1].format('YYYY-MM-DD'));
setCreditDateRange([dates[0].format('YYYY-MM-DD'), dates[1].format('YYYY-MM-DD')]);
} else {
setCreditFilterStart('');
setCreditFilterEnd('');
setCreditDateRange(null);
}
setCreditPage(1);
}}
@@ -371,48 +408,18 @@ const TeamManagementPage: React.FC = () => {
</div>
{/* 汇总统计 */}
<div style={{ marginBottom: 12, padding: '8px 12px', background: '#f8f9fc', borderRadius: 8, display: 'flex', gap: 20, flexWrap: 'wrap', fontSize: 13 }}>
<span><strong style={{ color: '#16a34a' }}>+{creditSummary?.total_recharge ?? 0}</strong></span>
<span><strong style={{ color: '#dc2626' }}>-{creditSummary?.total_consume ?? 0}</strong></span>
<div style={{ marginBottom: 12, padding: '8px 16px', background: '#f8f9fc', borderRadius: 8, display: 'flex', gap: 24, flexWrap: 'wrap', fontSize: 13 }}>
<span><strong style={{ color: '#10b981', fontSize: 15 }}>+{creditSummary?.total_recharge ?? 0}</strong></span>
<span><strong style={{ color: '#ef4444', fontSize: 15 }}>-{creditSummary?.total_consume ?? 0}</strong></span>
</div>
<Table
size="small"
rowKey="id"
loading={creditLoading}
dataSource={creditRecords}
pagination={{
current: creditPage,
pageSize: 20,
total: creditTotal,
onChange: (p) => setCreditPage(p),
showTotal: (t) => `${t}`,
position: ['bottomLeft'],
}}
pagination={false}
scroll={{ x: 950 }}
columns={[
{ 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) },
]}
columns={creditColumns}
locale={{ emptyText: <Empty description="暂无积分记录" /> }}
/>
</div>
@@ -455,10 +462,14 @@ const TeamManagementPage: React.FC = () => {
},
];
/* ── 渲染 ────────────────────────────────────────────── */
return (
<div style={{ padding: 24, maxWidth: 1200, margin: '0 auto' }}>
<Card variant="outlined" style={{ borderRadius: 12, marginBottom: 16 }} loading={teamLoading}>
{team && (
<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>
@@ -466,12 +477,41 @@ const TeamManagementPage: React.FC = () => {
</div>
<Button icon={<ReloadOutlined />} onClick={() => { loadTeam(); loadMembers(); loadInvitations(); loadRequests(); loadCreditRecords(); }}></Button>
</div>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</Card>
</div>
<Card variant="outlined" style={{ borderRadius: 12 }}>
<Tabs items={tabItems} defaultActiveKey="members" />
</Card>
{/* 标签页 */}
<Tabs items={tabItems} defaultActiveKey="members" size="large" />
{/* 成员列表分页(左下角) */}
{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
@@ -485,9 +525,9 @@ const TeamManagementPage: React.FC = () => {
>
<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 strong style={{ fontSize: 18, color: '#6366f1' }}>
<Typography.Text strong style={{ fontSize: 20, color: '#6366f1' }}>
{useAuthStore.getState().user?.credits?.toFixed(2) ?? '0.00'}
</Typography.Text>
</div>
@@ -504,17 +544,18 @@ const TeamManagementPage: React.FC = () => {
{ required: true, message: '请输入积分数量' },
{ type: 'number', min: 0.01, message: '必须大于 0' },
{ 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 name="description" label="备注">
<Input.TextArea rows={2} maxLength={256} placeholder="选填,例如:活动奖励" />