This commit is contained in:
2026-07-06 15:47:50 +08:00
7 changed files with 680 additions and 555 deletions
+30
View File
@@ -253,3 +253,33 @@ async def handle_join_request(
db, request_id, current_user.id, req.action, req.note
)
return {"message": "ok"}
# ── 团队积分变动记录 ────────────────────────────────────
@router.get("/credit-records")
async def list_team_credit_records(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user_id: str | None = Query(None),
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|admin)$"),
start_date: str | None = Query(None),
end_date: str | None = Query(None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""查看团队所有成员的积分变动记录(仅管理人)。"""
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
from app.services.admin_credit_record_service import list_admin_credit_records
return await list_admin_credit_records(
db,
page=page,
page_size=page_size,
team_id=team.id,
user_id=user_id,
record_type=record_type,
start_date=start_date,
end_date=end_date,
)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+36 -36
View File
@@ -1,37 +1,37 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>民众智创</title>
<script>
(function() {
var cached = localStorage.getItem('siteInfo');
if (cached) {
try {
var info = JSON.parse(cached);
if (info.siteName) {
document.title = info.siteName;
}
if (info.siteLogo) {
var link = document.querySelector('link[rel="icon"]');
if (link) {
link.href = info.siteLogo;
link.type = 'image/png';
}
}
} catch (e) {}
}
})();
</script>
<script type="module" crossorigin src="/assets/index-DYv9t-Np.js"></script>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>民众智创</title>
<script>
(function() {
var cached = localStorage.getItem('siteInfo');
if (cached) {
try {
var info = JSON.parse(cached);
if (info.siteName) {
document.title = info.siteName;
}
if (info.siteLogo) {
var link = document.querySelector('link[rel="icon"]');
if (link) {
link.href = info.siteLogo;
link.type = 'image/png';
}
}
} catch (e) {}
}
})();
</script>
<script type="module" crossorigin src="/assets/index-XSsSl940.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D9_3MPsN.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
</head>
<body>
<div id="root"></div>
</body>
</html>
+18
View File
@@ -849,3 +849,21 @@ export async function getJoinTeamInfo(code: string): Promise<any> {
export async function submitJoinRequest(code: string): Promise<void> {
await api.post('/team/join', { invitation_code: code });
}
export async function getTeamCreditRecords(params: {
page?: number;
pageSize?: number;
userId?: string;
recordType?: string;
startDate?: string;
endDate?: string;
}): Promise<any> {
const p = new URLSearchParams();
if (params.page) p.set('page', String(params.page));
if (params.pageSize) p.set('page_size', String(params.pageSize));
if (params.userId) p.set('user_id', params.userId);
if (params.recordType) p.set('record_type', params.recordType);
if (params.startDate) p.set('start_date', params.startDate);
if (params.endDate) p.set('end_date', params.endDate);
return api.get(`/team/credit-records?${p.toString()}`);
}
@@ -597,6 +597,7 @@ const AppLayout: React.FC = () => {
{ key: 'profile', icon: <UserOutlined />, label: `账号: ${user?.username}`, disabled: true },
{ key: 'credits', icon: <WalletOutlined style={{ color: '#c9a96e' }} />, label: `积分: ${user?.credits ?? 0}`, disabled: true },
{ type: 'divider' as const },
...(user?.isTeamManager ? [{ key: 'teamManagement' as const, icon: <TeamOutlined style={{ color: '#6366f1' }} />, label: '团队管理' }] : []),
{ key: 'myCredits', icon: <WalletOutlined />, label: '积分明细' },
{ key: 'orderRecords', icon: <FileTextOutlined />, label: '订单记录' },
{ key: 'messages', icon: <BellOutlined />, label: `消息中心${unreadCount > 0 ? `(${unreadCount})` : ''}` },
@@ -612,6 +613,7 @@ const AppLayout: React.FC = () => {
else if (key === 'changePwd') { setPwdModalOpen(true); }
else if (key === 'messages') { navigate('/messages'); }
else if (key === 'recharge') { setRechargeModalOpen(true); }
else if (key === 'teamManagement') { navigate('/team-management'); }
else if (key === 'myCredits') { navigate('/user-center?tab=credits'); }
else if (key === 'orderRecords') { navigate('/user-center?tab=orders'); }
else if (key === 'manual') { window.open(operationManualUrl, '_blank'); }
@@ -837,16 +839,6 @@ const AppLayout: React.FC = () => {
<div style={{ flex: 1, padding: '8px 8px', overflow: 'auto' }}>
{topLevelItems.map(item => renderMenuItem(item))}
{user?.isTeamManager && renderMenuItem({
id: 'team-management',
key: 'team-management',
label: '团队管理',
path: '/team-management',
icon: 'TeamOutlined',
sortOrder: 999,
isActive: true,
menuType: 'page',
} as any)}
</div>
<div onClick={() => setRechargeModalOpen(true)} style={{
+89 -4
View File
@@ -1,13 +1,13 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Button, Card, Empty, Form, Input, InputNumber, message, Modal, Space, Table, Tabs, Tag, Tooltip, Typography,
Button, Card, Empty, Form, Input, InputNumber, message, Modal, Select, Space, Table, Tabs, Tag, Tooltip, Typography,
} from 'antd';
import {
CopyOutlined, PlusOutlined, ReloadOutlined, UserOutlined, HistoryOutlined,
CopyOutlined, PlusOutlined, ReloadOutlined, UserOutlined, HistoryOutlined, WalletOutlined,
} from '@ant-design/icons';
import {
createTeamInvitation, getJoinTeamInfo, getManagedTeam, getPendingJoinRequests,
getTeamInvitations, getTeamMembers, handleJoinRequest, revokeInvitation, submitJoinRequest, transferCredits,
getTeamCreditRecords, getTeamInvitations, getTeamMembers, handleJoinRequest, revokeInvitation, submitJoinRequest, transferCredits,
} from '../api';
import type { ManagedTeam, TeamInvitation, TeamJoinRequest, TeamMember } from '../types';
import { formatDate } from '../utils/formatDate';
@@ -160,6 +160,32 @@ const TeamManagementPage: React.FC = () => {
}
};
// ── Tab 4: 团队积分变动 ──
const [creditRecords, setCreditRecords] = useState<any[]>([]);
const [creditTotal, setCreditTotal] = useState(0);
const [creditLoading, setCreditLoading] = useState(false);
const [creditPage, setCreditPage] = useState(1);
const [creditFilterType, setCreditFilterType] = useState<string>('');
const loadCreditRecords = useCallback(async () => {
setCreditLoading(true);
try {
const res = await getTeamCreditRecords({
page: creditPage,
pageSize: 20,
recordType: creditFilterType || undefined,
});
setCreditRecords(res.items || []);
setCreditTotal(res.total || 0);
} catch (e: any) {
message.error(e?.message || '加载积分记录失败');
} finally {
setCreditLoading(false);
}
}, [creditPage, creditFilterType]);
useEffect(() => { loadCreditRecords(); }, [loadCreditRecords]);
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 || '-' },
@@ -285,6 +311,65 @@ const TeamManagementPage: React.FC = () => {
/>
),
},
{
key: 'credits',
label: <Space><WalletOutlined /></Space>,
children: (
<div>
<div style={{ marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Select
value={creditFilterType}
onChange={(v) => { setCreditFilterType(v); setCreditPage(1); }}
style={{ width: 140 }}
options={[
{ value: '', label: '全部类型' },
{ value: 'recharge', label: '充值' },
{ value: 'consume', label: '消费' },
{ value: 'admin', label: '管理员调整' },
{ value: 'refund', label: '退款' },
]}
/>
</div>
<Table
size="small"
rowKey="id"
loading={creditLoading}
dataSource={creditRecords}
pagination={{
current: creditPage,
pageSize: 20,
total: creditTotal,
onChange: (p) => setCreditPage(p),
showTotal: (t) => `${t}`,
}}
scroll={{ x: 900 }}
columns={[
{ title: '用户名', key: 'username', width: 120, render: (_: any, r: any) => <Typography.Text strong>{r.username || r.user_id || '-'}</Typography.Text> },
{
title: '类型', key: 'type', width: 100,
render: (_: any, r: any) => {
const colorMap: Record<string, string> = { recharge: 'green', consume: 'red', admin: 'blue', refund: 'orange' };
const labelMap: Record<string, string> = { recharge: '充值', consume: '消费', admin: '管理员调整', refund: '退款' };
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="暂无积分记录" /> }}
/>
</div>
),
},
];
return (
@@ -296,7 +381,7 @@ const TeamManagementPage: React.FC = () => {
<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>
<Button icon={<ReloadOutlined />} onClick={() => { loadTeam(); loadMembers(); loadInvitations(); loadRequests(); loadCreditRecords(); }}></Button>
</div>
)}
</Card>