1
This commit is contained in:
+94
-94
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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>
|
||||
|
||||
@@ -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="选填,例如:活动奖励" />
|
||||
|
||||
Reference in New Issue
Block a user