Merge branch 'main' of https://gitee.com/wg123/video-gen
This commit is contained in:
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+36
-36
@@ -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-Dx-bvo36.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-C4H6x_-U.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>␍
|
||||
</body>
|
||||
</html>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -35,6 +35,7 @@ const RECORD_TYPE_MAP: Record<string, { text: string; color: string; icon: React
|
||||
recharge: { text: '充值', color: 'green', icon: <ArrowUpOutlined /> },
|
||||
consume: { text: '消费', color: 'red', icon: <ArrowDownOutlined /> },
|
||||
refund: { text: '回退', color: 'blue', icon: <RollbackOutlined /> },
|
||||
team_internal: { text: '团队内部', color: 'cyan', icon: <WalletOutlined /> },
|
||||
};
|
||||
|
||||
const userScopeOptions = [
|
||||
@@ -49,6 +50,7 @@ const recordTypeOptions = [
|
||||
{ value: 'recharge', label: '充值' },
|
||||
{ value: 'consume', label: '消费' },
|
||||
{ value: 'refund', label: '回退' },
|
||||
{ value: 'team_internal', label: '团队内部' },
|
||||
];
|
||||
|
||||
const creditSubjectOptions = [
|
||||
|
||||
@@ -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)$", description="流水类型"),
|
||||
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)$", description="流水类型"),
|
||||
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}"},
|
||||
)
|
||||
|
||||
@@ -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}",
|
||||
)
|
||||
|
||||
+505
File diff suppressed because one or more lines are too long
-505
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-XSsSl940.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-DlbOvnzQ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D9_3MPsN.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
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 { DatePicker } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
CopyOutlined, PlusOutlined, ReloadOutlined, UserOutlined, HistoryOutlined, WalletOutlined,
|
||||
CopyOutlined, DownloadOutlined, PlusOutlined, ReloadOutlined, UserOutlined, HistoryOutlined, WalletOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
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';
|
||||
import { useAuthStore } from '../store/useAuthStore';
|
||||
|
||||
const TeamManagementPage: React.FC = () => {
|
||||
const [team, setTeam] = useState<ManagedTeam | null>(null);
|
||||
@@ -61,14 +66,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 +174,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 +188,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 +319,105 @@ 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 || undefined}
|
||||
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
|
||||
/>
|
||||
<RangePicker
|
||||
value={creditFilterStart && creditFilterEnd ? [dayjs(creditFilterStart), dayjs(creditFilterEnd)] : undefined}
|
||||
onChange={(dates) => {
|
||||
if (dates && dates[0] && dates[1]) {
|
||||
setCreditFilterStart(dates[0].format('YYYY-MM-DD'));
|
||||
setCreditFilterEnd(dates[1].format('YYYY-MM-DD'));
|
||||
} else {
|
||||
setCreditFilterStart('');
|
||||
setCreditFilterEnd('');
|
||||
}
|
||||
setCreditPage(1);
|
||||
}}
|
||||
/>
|
||||
<Button onClick={resetCreditFilters}>重置</Button>
|
||||
<Button type="primary" icon={<DownloadOutlined />} onClick={handleExportCredits}>导出 Excel</Button>
|
||||
</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>
|
||||
|
||||
<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 +453,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 +473,48 @@ 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' }}>
|
||||
{/* 显示管理人当前积分 */}
|
||||
<div style={{ marginBottom: 16, padding: '8px 12px', 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' }}>
|
||||
{useAuthStore.getState().user?.credits?.toFixed(2) ?? '0.00'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Form.Item name="direction" label="操作类型" rules={[{ required: true, message: '请选择操作类型' }]}>
|
||||
<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' },
|
||||
{ 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" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="备注">
|
||||
<Input.TextArea rows={2} maxLength={256} placeholder="选填,例如:活动奖励" />
|
||||
|
||||
Reference in New Issue
Block a user