1、团队积分变动增加对应手机号和日期搜索

2、翻页样式应该放在左下角,别的页面也在左下角吧
3、列表页面显示总使用积分和下载excel
4、团队管理员分配积分的逻辑有问题,团队积分变动不显示,后台显示流水类型也不对,应该增加团队的内部积分变动类型,同步搜索下载
5、调整积分弹窗应该让用户显示增加还是减少的button
6、检查代码是否有不合理的地方,比如枚举是否放在enums里了
This commit is contained in:
2026-07-06 16:21:41 +08:00
parent f515362c8e
commit 04110db524
9 changed files with 827 additions and 604 deletions
+91 -4
View File
@@ -5,6 +5,7 @@ from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import StreamingResponse
from app.config import settings
from app.dependencies import get_current_user, get_db
@@ -99,6 +100,7 @@ async def transfer_credits(
current_user.id,
req.target_user_id,
req.amount,
req.direction or "increase",
req.description,
)
return {"message": "ok"}
@@ -261,9 +263,10 @@ 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),
phone: str | None = Query(None, description="按手机号搜索"),
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|team_internal)$"),
start_date: str | None = Query(None, description="起始日期 YYYY-MM-DD"),
end_date: str | None = Query(None, description="截止日期 YYYY-MM-DD"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
@@ -273,13 +276,97 @@ async def list_team_credit_records(
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
from app.services.admin_credit_record_service import list_admin_credit_records
# 如果传了 phone,先找到对应的 user_id
resolved_user_id = user_id
if phone and not user_id:
phone_result = await db.execute(
select(User.id).where(
User.team_id == team.id,
User.phone == phone,
User.is_active.is_(True),
).limit(1)
)
resolved_user_id = phone_result.scalar_one_or_none()
if not resolved_user_id:
return {"items": [], "total": 0, "summary": {}}
return await list_admin_credit_records(
db,
page=page,
page_size=page_size,
team_id=team.id,
user_id=user_id,
user_id=resolved_user_id,
record_type=record_type,
start_date=start_date,
end_date=end_date,
)
# ── 团队积分导出 Excel ──────────────────────────────────
@router.get("/credit-records/export")
async def export_team_credit_records(
user_id: str | None = Query(None),
phone: str | None = Query(None),
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|team_internal)$"),
start_date: str | None = Query(None),
end_date: str | None = Query(None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""导出团队积分变动记录为 Excel(仅管理人)。"""
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
resolved_user_id = user_id
if phone and not user_id:
phone_result = await db.execute(
select(User.id).where(
User.team_id == team.id,
User.phone == phone,
User.is_active.is_(True),
).limit(1)
)
resolved_user_id = phone_result.scalar_one_or_none()
# 拉取全部记录(不分页)
result = await list_admin_credit_records(
db,
page=1,
page_size=10000,
team_id=team.id,
user_id=resolved_user_id,
record_type=record_type,
start_date=start_date,
end_date=end_date,
)
# 生成 CSV(兼容 Excel 打开)
import csv
import io
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["用户名", "手机号", "类型", "积分变动", "余额", "说明", "时间"])
for item in result.get("items", []):
writer.writerow([
item.get("username") or "-",
item.get("phone") or "-",
item.get("record_type_label") or item.get("type") or "-",
item.get("amount", 0),
item.get("balance_after", 0),
item.get("description") or "-",
item.get("created_at") or "-",
])
from starlette.responses import StreamingResponse
output.seek(0)
filename = f"team_credits_{team.id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv; charset=utf-8-sig",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"},
)
+2
View File
@@ -5,6 +5,7 @@ class CreditRecordType(str, Enum):
RECHARGE = "recharge"
CONSUME = "consume"
REFUND = "refund"
TEAM_INTERNAL = "team_internal" # 团队内部积分流转(管理人分配)
class CreditRecordOwnerType(str, Enum):
@@ -111,6 +112,7 @@ CREDIT_RECORD_TYPE_LABELS = {
CreditRecordType.RECHARGE.value: "充值",
CreditRecordType.CONSUME.value: "消费",
CreditRecordType.REFUND.value: "回退",
CreditRecordType.TEAM_INTERNAL.value: "团队内部",
}
CREDIT_RECORD_SUBJECT_LABELS = {
@@ -21,6 +21,7 @@ class TeamMemberOut(BaseModel):
class ManagerTransferRequest(BaseModel):
target_user_id: str = Field(..., description="接收积分的成员用户ID")
amount: float = Field(gt=0, description="转账积分数量(正数)")
direction: str = Field(default="increase", pattern="^(increase|decrease)$", description="increase=管理人转给成员;decrease=从成员扣减回管理人")
description: str | None = Field(None, max_length=256, description="转账说明")
@@ -129,11 +129,19 @@ async def transfer_credits_to_member(
manager_id: str,
target_member_id: str,
amount: float,
direction: str = "increase", # "increase" 管理人→成员; "decrease" 成员扣减
description: str | None = None,
) -> None:
"""管理人从自己余额转积分给团队成员。"""
"""管理人为团队成员增加或扣减积分。
direction:
- "increase": 管理人从自己余额转积分给成员(管理人减少,成员增加)
- "decrease": 从成员扣积分回到管理人(成员减少,管理人增加)
"""
if amount <= 0:
raise HTTPException(status_code=400, description="转账积分必须大于0")
raise HTTPException(status_code=400, description="积分数量必须大于0")
if direction not in ("increase", "decrease"):
raise HTTPException(status_code=400, description="无效操作方向")
# 获取管理人
manager_result = await db.execute(
@@ -155,7 +163,7 @@ async def transfer_credits_to_member(
if not manager.team_id:
raise HTTPException(status_code=400, detail="您不在任何团队中")
if member.team_id != manager.team_id:
raise HTTPException(status_code=400, detail="只能转账给同团队成员")
raise HTTPException(status_code=400, detail="只能操作同团队成员")
team_result = await db.execute(
select(Team).where(Team.id == manager.team_id, Team.deleted_at.is_(None)).limit(1)
@@ -164,22 +172,44 @@ async def transfer_credits_to_member(
if not team or team.manager_id != manager_id:
raise HTTPException(status_code=403, detail="只有团队管理人才能分配积分")
desc = description or "团队积分发放"
desc = description or ("团队积分发放" if direction == "increase" else "团队积分扣减")
xfer_id = generate_id()
# 从管理人扣减
await deduct_credits(
db,
manager_id,
amount,
f"分配给成员 {member.username}: {desc}",
biz_key=f"mgr_xfer_out:{manager_id}:{target_member_id}:{generate_id()}",
)
# 给成员增加
await add_credits(
db,
target_member_id,
amount,
f"来自团队管理人: {desc}",
record_type="admin",
biz_key=f"mgr_xfer_in:{manager_id}:{target_member_id}:{generate_id()}",
)
if direction == "increase":
# 管理人扣减
await deduct_credits(
db,
manager_id,
amount,
f"分配给成员 {member.username}: {desc}",
record_type="team_internal",
biz_key=f"mgr_xfer_out:{manager_id}:{target_member_id}:{xfer_id}",
)
# 成员增加
await add_credits(
db,
target_member_id,
amount,
f"来自团队管理人: {desc}",
record_type="team_internal",
biz_key=f"mgr_xfer_in:{manager_id}:{target_member_id}:{xfer_id}",
)
else:
# 成员扣减
await deduct_credits(
db,
target_member_id,
amount,
f"扣减给团队管理人: {desc}",
record_type="team_internal",
biz_key=f"mgr_deduct_out:{manager_id}:{target_member_id}:{xfer_id}",
)
# 管理人增加
await add_credits(
db,
manager_id,
amount,
f"来自成员 {member.username}: {desc}",
record_type="team_internal",
biz_key=f"mgr_deduct_in:{manager_id}:{target_member_id}:{xfer_id}",
)
File diff suppressed because one or more lines are too long
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-XSsSl940.js"></script>
<script type="module" crossorigin src="/assets/index-D7ovjI8j.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D9_3MPsN.css">
</head>
<body>
+19 -2
View File
@@ -818,8 +818,8 @@ export async function getTeamMembers(page = 1, pageSize = 20): Promise<any> {
return api.get(`/team/members?${params.toString()}`);
}
export async function transferCredits(memberId: string, amount: number, description?: string): Promise<void> {
await api.post(`/team/members/${memberId}/credits`, { target_user_id: memberId, amount, description: description || null });
export async function transferCredits(memberId: string, amount: number, direction: string = "increase", description?: string): Promise<void> {
await api.post(`/team/members/${memberId}/credits`, { target_user_id: memberId, amount, direction, description: description || null });
}
export async function getTeamInvitations(): Promise<any[]> {
@@ -854,6 +854,7 @@ export async function getTeamCreditRecords(params: {
page?: number;
pageSize?: number;
userId?: string;
phone?: string;
recordType?: string;
startDate?: string;
endDate?: string;
@@ -862,8 +863,24 @@ export async function getTeamCreditRecords(params: {
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.phone) p.set('phone', params.phone);
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()}`);
}
export function getTeamCreditExportUrl(params: {
phone?: string;
recordType?: string;
startDate?: string;
endDate?: string;
}): string {
const p = new URLSearchParams();
if (params.phone) p.set('phone', params.phone);
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);
const base = (import.meta as any).env?.VITE_API_BASE || 'http://localhost:8000';
return `${base}/api/team/credit-records/export?${p.toString()}`;
}
+157 -71
View File
@@ -1,13 +1,13 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Button, Card, Empty, Form, Input, InputNumber, message, Modal, Select, Space, Table, Tabs, Tag, Tooltip, Typography,
Button, Card, Empty, Form, Input, InputNumber, message, Modal, Radio, Select, Space, Table, Tabs, Tag, Tooltip, Typography,
} from 'antd';
import {
CopyOutlined, PlusOutlined, ReloadOutlined, UserOutlined, HistoryOutlined, WalletOutlined,
CopyOutlined, DownloadOutlined, PlusOutlined, ReloadOutlined, UserOutlined, HistoryOutlined, WalletOutlined,
} from '@ant-design/icons';
import {
createTeamInvitation, getJoinTeamInfo, getManagedTeam, getPendingJoinRequests,
getTeamCreditRecords, getTeamInvitations, getTeamMembers, handleJoinRequest, revokeInvitation, submitJoinRequest, transferCredits,
getTeamCreditExportUrl, getTeamCreditRecords, getTeamInvitations, getTeamMembers, handleJoinRequest, revokeInvitation, submitJoinRequest, transferCredits,
} from '../api';
import type { ManagedTeam, TeamInvitation, TeamJoinRequest, TeamMember } from '../types';
import { formatDate } from '../utils/formatDate';
@@ -61,14 +61,20 @@ const TeamManagementPage: React.FC = () => {
try {
const values = await creditForm.validateFields();
setCreditSaving(true);
await transferCredits(creditModal.member.id, values.amount, values.description);
message.success('积分转账成功');
await transferCredits(
creditModal.member.id,
values.amount,
values.direction || "increase",
values.description,
);
message.success(values.direction === "decrease" ? '积分扣减成功' : '积分增加成功');
setCreditModal({ open: false, member: null });
creditForm.resetFields();
loadMembers();
loadCreditRecords();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '转账失败');
message.error(e?.message || '操作失败');
} finally {
setCreditSaving(false);
}
@@ -163,9 +169,13 @@ const TeamManagementPage: React.FC = () => {
// ── Tab 4: 团队积分变动 ──
const [creditRecords, setCreditRecords] = useState<any[]>([]);
const [creditTotal, setCreditTotal] = useState(0);
const [creditSummary, setCreditSummary] = useState<any>(null);
const [creditLoading, setCreditLoading] = useState(false);
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 loadCreditRecords = useCallback(async () => {
setCreditLoading(true);
@@ -173,19 +183,53 @@ const TeamManagementPage: React.FC = () => {
const res = await getTeamCreditRecords({
page: creditPage,
pageSize: 20,
phone: creditFilterPhone || undefined,
recordType: creditFilterType || undefined,
startDate: creditFilterStart || undefined,
endDate: creditFilterEnd || undefined,
});
setCreditRecords(res.items || []);
setCreditTotal(res.total || 0);
setCreditSummary(res.summary || null);
} catch (e: any) {
message.error(e?.message || '加载积分记录失败');
} finally {
setCreditLoading(false);
}
}, [creditPage, creditFilterType]);
}, [creditPage, creditFilterType, creditFilterPhone, creditFilterStart, creditFilterEnd]);
useEffect(() => { loadCreditRecords(); }, [loadCreditRecords]);
const resetCreditFilters = () => {
setCreditFilterType('');
setCreditFilterPhone('');
setCreditFilterStart('');
setCreditFilterEnd('');
setCreditPage(1);
};
const handleExportCredits = () => {
const url = getTeamCreditExportUrl({
phone: creditFilterPhone || undefined,
recordType: creditFilterType || undefined,
startDate: creditFilterStart || undefined,
endDate: creditFilterEnd || undefined,
});
// 携带 token 下载
const token = localStorage.getItem('auth_token');
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
fetch(url, { headers })
.then((res) => res.blob())
.then((blob) => {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `team_credits_${Date.now()}.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: 'phone', width: 130, render: (v: string) => v || '-' },
@@ -270,12 +314,107 @@ const TeamManagementPage: React.FC = () => {
total: membersTotal,
onChange: (p) => setMembersPage(p),
showTotal: (t) => `${t}`,
position: ['bottomLeft'],
}}
scroll={{ x: 800 }}
locale={{ emptyText: <Empty description="暂无成员" /> }}
/>
),
},
{
key: 'credits',
label: <Space><WalletOutlined /></Space>,
children: (
<div>
{/* 搜索栏 */}
<div style={{ marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<Select
value={creditFilterType}
onChange={(v) => { setCreditFilterType(v || ''); setCreditPage(1); }}
allowClear
placeholder="全部类型"
style={{ width: 130 }}
options={[
{ value: 'recharge', label: '充值' },
{ value: 'consume', label: '消费' },
{ value: 'team_internal', label: '团队内部' },
{ value: 'refund', label: '退款' },
]}
/>
<Input
placeholder="搜索手机号"
value={creditFilterPhone}
onChange={(e) => { setCreditFilterPhone(e.target.value); setCreditPage(1); }}
style={{ width: 160 }}
allowClear
/>
<Input
placeholder="起始日期 YYYY-MM-DD"
value={creditFilterStart}
onChange={(e) => { setCreditFilterStart(e.target.value); setCreditPage(1); }}
style={{ width: 160 }}
/>
<Input
placeholder="截止日期 YYYY-MM-DD"
value={creditFilterEnd}
onChange={(e) => { setCreditFilterEnd(e.target.value); setCreditPage(1); }}
style={{ width: 160 }}
/>
<Button onClick={resetCreditFilters}></Button>
<Button type="primary" icon={<DownloadOutlined />} onClick={handleExportCredits}> Excel</Button>
</div>
{/* 汇总统计 */}
{creditSummary && (
<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>
<span><strong>{creditSummary.transaction_count ?? 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'],
}}
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) },
]}
locale={{ emptyText: <Empty description="暂无积分记录" /> }}
/>
</div>
),
},
{
key: 'invitations',
label: <Space><CopyOutlined /></Space>,
@@ -311,65 +450,6 @@ 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 (
@@ -390,19 +470,25 @@ const TeamManagementPage: React.FC = () => {
<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="确认转账"
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 form={creditForm} layout="vertical" style={{ marginTop: 16 }} initialValues={{ direction: 'increase' }}>
<Form.Item name="direction" label="操作类型" rules={[{ required: true }]}>
<Radio.Group buttonStyle="solid" size="large" style={{ width: '100%' }}>
<Radio.Button value="increase" style={{ width: '50%', textAlign: 'center' }}></Radio.Button>
<Radio.Button value="decrease" style={{ width: '50%', textAlign: 'center' }}></Radio.Button>
</Radio.Group>
</Form.Item>
<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="选填,例如:活动奖励" />