merge main

This commit is contained in:
2026-07-06 15:16:30 +08:00
31 changed files with 2303 additions and 584 deletions
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-OStxKvM2.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-BaN8_2WC.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>
+13
View File
@@ -181,6 +181,19 @@ export async function deleteAdminTeam(id: string): Promise<void> {
await api.delete(`/admin/teams/${id}`);
}
export async function setTeamManager(teamId: string, userId: string | null): Promise<AdminTeam> {
return api.put(`/admin/teams/${teamId}/manager`, { user_id: userId });
}
export async function getTeamMembersForAdmin(teamId: string, page = 1, pageSize = 50): Promise<{ items: AdminUser[]; total: number }> {
const params = new URLSearchParams();
params.set('page', String(page));
params.set('page_size', String(pageSize));
params.set('user_type', 'frontend');
params.set('team_id', teamId);
return api.get(`/admin/users?${params.toString()}`);
}
export async function adjustCredits(userId: string, amount: number, description: string): Promise<void> {
await api.post(`/admin/users/${userId}/credits`, { amount, description });
}
+136 -4
View File
@@ -1,8 +1,8 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography } from 'antd';
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, TeamOutlined } from '@ant-design/icons';
import { deleteAdminTeam, getAdminTeams, saveAdminTeam } from '../api';
import type { AdminTeam } from '../types';
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, TeamOutlined, UserOutlined } from '@ant-design/icons';
import { deleteAdminTeam, getAdminTeams, getTeamMembersForAdmin, saveAdminTeam, setTeamManager } from '../api';
import type { AdminTeam, AdminUser } from '../types';
import { formatDate } from '../utils/formatDate';
const statusOptions = [
@@ -34,6 +34,17 @@ const AdminTeams: React.FC = () => {
const [modal, setModal] = useState<{ open: boolean; item: AdminTeam | null }>({ open: false, item: null });
const [form] = Form.useForm();
// 管理人弹窗状态
const [managerModal, setManagerModal] = useState<{ open: boolean; team: AdminTeam | null }>({ open: false, team: null });
const [managerMembers, setManagerMembers] = useState<AdminUser[]>([]);
const [managerLoading, setManagerLoading] = useState(false);
const [selectedManagerId, setSelectedManagerId] = useState<string | null>(null);
// 查看成员弹窗状态
const [membersModal, setMembersModal] = useState<{ open: boolean; team: AdminTeam | null }>({ open: false, team: null });
const [teamMembers, setTeamMembers] = useState<AdminUser[]>([]);
const [membersLoading, setMembersLoading] = useState(false);
const query = useMemo(() => ({
page,
pageSize,
@@ -107,6 +118,48 @@ const AdminTeams: React.FC = () => {
}
};
const loadTeamMembers = async (teamId: string): Promise<AdminUser[]> => {
try {
const res = await getTeamMembersForAdmin(teamId);
return res.items || [];
} catch (e: any) {
message.error(e?.message || '加载成员失败');
return [];
}
};
const openManagerModal = async (team: AdminTeam) => {
setManagerModal({ open: true, team });
setSelectedManagerId(team.managerId || null);
setManagerLoading(true);
const data = await loadTeamMembers(team.id);
setManagerMembers(data);
setManagerLoading(false);
};
const openMembersModal = async (team: AdminTeam) => {
setMembersModal({ open: true, team });
setMembersLoading(true);
const data = await loadTeamMembers(team.id);
setTeamMembers(data);
setMembersLoading(false);
};
const handleSetManager = async () => {
if (!managerModal.team) return;
try {
setManagerLoading(true);
await setTeamManager(managerModal.team.id, selectedManagerId);
message.success(selectedManagerId ? '已设置管理人' : '已取消管理人');
setManagerModal({ open: false, team: null });
load();
} catch (e: any) {
message.error(e?.message || '设置失败');
} finally {
setManagerLoading(false);
}
};
const columns = [
{
title: '团队名称',
@@ -136,6 +189,16 @@ const AdminTeams: React.FC = () => {
width: 100,
render: (v: number) => <Typography.Text strong>{Number(v || 0).toLocaleString()}</Typography.Text>,
},
{
title: '管理人',
key: 'manager',
width: 140,
render: (_: any, r: AdminTeam) => (
<Typography.Text style={{ fontSize: 13 }}>
{r.managerName || <span style={{ color: '#94a3b8' }}></span>}
</Typography.Text>
),
},
{
title: '排序',
dataIndex: 'sortOrder',
@@ -157,10 +220,12 @@ const AdminTeams: React.FC = () => {
{
title: '操作',
key: 'action',
width: 160,
width: 230,
fixed: 'right' as const,
render: (_: any, r: AdminTeam) => (
<Space size={4}>
<Button type="link" size="small" icon={<UserOutlined />} onClick={() => openMembersModal(r)}></Button>
<Button type="link" size="small" icon={<SettingOutlined />} onClick={() => openManagerModal(r)}></Button>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}></Button>
<Popconfirm
title="确定删除该团队?"
@@ -239,6 +304,73 @@ const AdminTeams: React.FC = () => {
</Form.Item>
</Form>
</Modal>
{/* 设置管理人弹窗 */}
<Modal
title={<Space><UserOutlined /></Space>}
open={managerModal.open}
confirmLoading={managerLoading}
onOk={handleSetManager}
onCancel={() => { setManagerModal({ open: false, team: null }); setSelectedManagerId(null); }}
okText="保存"
cancelText="取消"
width={480}
>
<div style={{ marginTop: 16 }}>
<Typography.Text style={{ fontSize: 13, color: '#64748b', display: 'block', marginBottom: 8 }}>
{managerModal.team?.name || '-'}
</Typography.Text>
<Typography.Text style={{ fontSize: 13, color: '#64748b', display: 'block', marginBottom: 12 }}>
</Typography.Text>
<Select
style={{ width: '100%' }}
placeholder="选择管理人(可清空取消)"
value={selectedManagerId}
onChange={(v) => setSelectedManagerId(v || null)}
allowClear
loading={managerLoading}
optionFilterProp="label"
options={managerMembers.map((m) => ({
value: m.id,
label: `${m.username}${m.phone ? ` (${m.phone})` : ''}`,
}))}
showSearch
/>
</div>
</Modal>
{/* 查看成员弹窗 */}
<Modal
title={<Space><UserOutlined /> - {membersModal.team?.name}</Space>}
open={membersModal.open}
onCancel={() => setMembersModal({ open: false, team: null })}
footer={null}
width={600}
>
<div style={{ marginTop: 8 }}>
{membersModal.team && (
<Table
size="small"
rowKey="id"
loading={membersLoading}
dataSource={teamMembers}
pagination={false}
scroll={{ y: 400 }}
columns={[
{ 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>,
},
]}
locale={{ emptyText: '该团队暂无成员' }}
/>
)}
</div>
</Modal>
</div>
);
};
+2
View File
@@ -147,6 +147,8 @@ export interface AdminTeam {
memberCount: number;
createdAt: string;
updatedAt?: string | null;
managerId?: string | null;
managerName?: string | null;
}
export interface AdminTeamOption {
@@ -0,0 +1,82 @@
"""add team manager and invite system
Revision ID: 20260706_20260706
Revises: 054735900c23
Create Date: 2026-07-06 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '20260706_20260706'
down_revision: Union[str, None] = '054735900c23'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# teams 表新增 manager_id 列
op.add_column('teams', sa.Column('manager_id', sa.String(length=32), nullable=True))
op.create_index(op.f('ix_teams_manager_id'), 'teams', ['manager_id'], unique=False)
op.create_foreign_key('fk_teams_manager_id', 'teams', 'users', ['manager_id'], ['id'])
# 邀请码表
op.create_table('team_invitations',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('team_id', sa.String(length=32), nullable=False),
sa.Column('code', sa.String(length=64), nullable=False),
sa.Column('created_by', sa.String(length=32), nullable=False),
sa.Column('status', sa.String(length=16), server_default='active', nullable=False),
sa.Column('max_uses', sa.Integer(), nullable=True),
sa.Column('use_count', sa.Integer(), server_default='0', nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('code', name='uq_team_invitations_code')
)
op.create_index(op.f('ix_team_invitations_team_id'), 'team_invitations', ['team_id'], unique=False)
op.create_index(op.f('ix_team_invitations_code'), 'team_invitations', ['code'], unique=False)
op.create_index('ix_team_invitations_team', 'team_invitations', ['team_id', 'status'], unique=False)
# 加入申请表
op.create_table('team_join_requests',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('team_id', sa.String(length=32), nullable=False),
sa.Column('user_id', sa.String(length=32), nullable=False),
sa.Column('invitation_id', sa.String(length=32), nullable=True),
sa.Column('status', sa.String(length=16), server_default='pending', nullable=False),
sa.Column('note', sa.String(length=256), nullable=True),
sa.Column('handled_by', sa.String(length=32), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['invitation_id'], ['team_invitations.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_team_join_requests_team_id'), 'team_join_requests', ['team_id'], unique=False)
op.create_index(op.f('ix_team_join_requests_user_id'), 'team_join_requests', ['user_id'], unique=False)
op.create_index('ix_tjr_team_status', 'team_join_requests', ['team_id', 'status'], unique=False)
def downgrade() -> None:
op.drop_index('ix_tjr_team_status', table_name='team_join_requests')
op.drop_index(op.f('ix_team_join_requests_user_id'), table_name='team_join_requests')
op.drop_index(op.f('ix_team_join_requests_team_id'), table_name='team_join_requests')
op.drop_table('team_join_requests')
op.drop_index('ix_team_invitations_team', table_name='team_invitations')
op.drop_index(op.f('ix_team_invitations_code'), table_name='team_invitations')
op.drop_index(op.f('ix_team_invitations_team_id'), table_name='team_invitations')
op.drop_table('team_invitations')
op.drop_constraint('fk_teams_manager_id', 'teams', type_='foreignkey')
op.drop_index(op.f('ix_teams_manager_id'), table_name='teams')
op.drop_column('teams', 'manager_id')
+73 -26
View File
@@ -2,18 +2,57 @@ from __future__ import annotations
import json
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Body, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.models.team import Team
from app.models.user import User
from app.schemas.team import TeamCreate, TeamListOut, TeamOptionOut, TeamOut, TeamUpdate
from app.schemas.team import TeamCreate, TeamListOut, TeamOptionOut, TeamUpdate
from app.schemas.team_manager import SetManagerRequest
from app.services.operation_log import log_operation
from app.services.team_manager_service import set_team_manager
from app.services.team_service import create_team, list_team_options, list_teams, soft_delete_team, update_team
router = APIRouter(prefix="/admin/teams", tags=["admin-teams"])
async def _team_detail_payload(db: AsyncSession, team: Team) -> dict:
"""构造返回团队详情,包含 manager_name。"""
payload = {
"id": team.id,
"name": team.name,
"code": getattr(team, "code", None),
"description": getattr(team, "description", None),
"status": getattr(team, "status", "active"),
"sort_order": getattr(team, "sort_order", 0) or 0,
"member_count": 0,
"created_at": team.created_at,
"updated_at": team.updated_at,
"manager_id": getattr(team, "manager_id", None),
"manager_name": None,
}
# 查询成员数和管理人用户名
from sqlalchemy import func
from app.enums.user import UserType
member_count = (await db.execute(
select(func.count(User.id)).where(
User.user_type == UserType.FRONTEND.value,
User.team_id == team.id,
)
)).scalar() or 0
payload["member_count"] = int(member_count)
if getattr(team, "manager_id", None):
mgr = await db.execute(
select(User.username).where(User.id == team.manager_id).limit(1)
)
payload["manager_name"] = mgr.scalar_one_or_none()
return payload
@router.get("", response_model=TeamListOut)
async def list_admin_teams(
page: int = Query(1, ge=1),
@@ -35,7 +74,7 @@ async def list_admin_team_options(
return await list_team_options(db, include_disabled=include_disabled)
@router.post("", response_model=TeamOut)
@router.post("", )
async def create_admin_team(
req: TeamCreate,
admin: User = Depends(get_admin_user),
@@ -51,20 +90,10 @@ async def create_admin_team(
"/admin/teams",
detail=json.dumps({"after": req.model_dump()}, ensure_ascii=False),
)
return {
"id": team.id,
"name": team.name,
"code": team.code,
"description": team.description,
"status": team.status,
"sort_order": team.sort_order,
"member_count": 0,
"created_at": team.created_at,
"updated_at": team.updated_at,
}
return await _team_detail_payload(db, team)
@router.put("/{team_id}", response_model=TeamOut)
@router.put("/{team_id}", )
async def update_admin_team(
team_id: str,
req: TeamUpdate,
@@ -81,17 +110,35 @@ async def update_admin_team(
f"/admin/teams/{team_id}",
detail=json.dumps({"before": before, "after": after}, ensure_ascii=False),
)
return {
"id": team.id,
"name": team.name,
"code": team.code,
"description": team.description,
"status": team.status,
"sort_order": team.sort_order,
"member_count": 0,
"created_at": team.created_at,
"updated_at": team.updated_at,
}
return await _team_detail_payload(db, team)
@router.put("/{team_id}/manager", )
async def set_team_manager_endpoint(
team_id: str,
req: SetManagerRequest = Body(...),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
team = await set_team_manager(db, team_id, req.user_id)
manager_name = None
# 使用 req.user_id 避免访问 team.manager_id 触发懒加载
if req.user_id:
mgr = await db.execute(
select(User.username).where(User.id == req.user_id).limit(1)
)
manager_name = mgr.scalar_one_or_none()
team_name = team.name
await log_operation(
db,
admin.id,
admin.username,
f"设置团队管理人 {team_name}: {manager_name or '取消'}",
"PUT",
f"/admin/teams/{team_id}/manager",
detail=json.dumps({"manager_id": req.user_id}, ensure_ascii=False),
)
return await _team_detail_payload(db, team)
@router.delete("/{team_id}")
+2
View File
@@ -28,6 +28,7 @@ from app.api.v1.material_consumption import router as material_consumption_route
from app.api.v1.open_type import router as open_type_router
from app.api.v1.resources_material import router as resources_material_router
from app.api.v1.contact import router as contact_router
from app.api.v1.team import router as team_router
from app.api.v1.home_materials import router as home_materials_router
from app.api.admin import router as admin_module_router
from app.api.v1.material_admin import router as material_admin_router
@@ -62,6 +63,7 @@ api_router.include_router(material_consumption_router)
api_router.include_router(open_type_router)
api_router.include_router(resources_material_router)
api_router.include_router(contact_router)
api_router.include_router(team_router)
api_router.include_router(home_materials_router)
api_router.include_router(admin_module_router)
api_router.include_router(material_admin_router)
+17 -1
View File
@@ -256,8 +256,24 @@ async def get_me(
current_user.username = "用户"+current_user.username[-4:] if current_user.username == current_user.phone else current_user.username
current_user.credits = round(current_user.credits, 2)
resource_capacity = await get_user_resource_capacity_usage(db, current_user.id)
# 计算 is_team_manager 和 team_name
is_team_manager = False
team_name = None
if current_user.team_id:
from app.services.team_manager_service import is_team_manager
from app.services.team_service import batch_get_team_name_map
is_team_manager = await is_team_manager(db, current_user.id, current_user.team_id)
name_map = await batch_get_team_name_map(db, [current_user.team_id])
team_name = name_map.get(current_user.team_id)
return UserOut.model_validate(current_user).model_copy(
update={"resource_capacity": resource_capacity}
update={
"resource_capacity": resource_capacity,
"is_team_manager": is_team_manager,
"team_id": current_user.team_id,
"team_name": team_name,
}
)
+255
View File
@@ -0,0 +1,255 @@
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.dependencies import get_current_user, get_db
from app.models.team import Team
from app.models.team_invitation import TeamInvitation
from app.models.team_join_request import TeamJoinRequest
from app.models.user import User
from app.schemas.team_invitation import TeamInvitationCreate, TeamInvitationOut
from app.schemas.team_join_request import (
JoinByCodeRequest,
JoinRequestHandle,
JoinRequestOut,
JoinTeamInfoOut,
)
from app.schemas.team_manager import (
ManagedTeamOut,
ManagerTransferRequest,
SetManagerRequest,
TeamMemberOut,
)
from app.services import team_invitation_service
from app.services.team_manager_service import (
get_managed_team,
get_team_members,
is_team_manager,
transfer_credits_to_member,
)
router = APIRouter(prefix="/team", tags=["team"])
def _build_invite_link(code: str) -> str:
base = getattr(settings, "FRONTEND_URL", "") or getattr(settings, "BASE_URL", "")
return f"{base}/join-team?code={code}"
# ── 获取当前用户管理的团队 ──────────────────────────────
@router.get("/managed")
async def get_managed_team_info(
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=404, detail="您不是任何团队的管理人")
from sqlalchemy import func
from app.enums.user import UserType
member_count = (await db.execute(
select(func.count(User.id)).where(
User.user_type == UserType.FRONTEND.value,
User.team_id == team.id,
)
)).scalar() or 0
return {
"id": team.id,
"name": team.name,
"code": team.code,
"description": team.description,
"status": team.status,
"member_count": int(member_count),
"manager_id": team.manager_id,
"manager_name": current_user.username,
}
# ── 团队成员列表 ──────────────────────────────────────
@router.get("/members")
async def list_team_members(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
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="只有团队管理人可查看")
return await get_team_members(db, team.id, page=page, page_size=page_size)
# ── 转账积分给成员 ────────────────────────────────────
@router.post("/members/{member_id}/credits")
async def transfer_credits(
member_id: str,
req: ManagerTransferRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await transfer_credits_to_member(
db,
current_user.id,
req.target_user_id,
req.amount,
req.description,
)
return {"message": "ok"}
# ── 邀请码管理 ────────────────────────────────────────
@router.post("/invitations", )
async def create_invitation(
req: TeamInvitationCreate,
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="只有团队管理人可创建邀请码")
expires_at = None
if req.expires_at:
try:
expires_at = datetime.fromisoformat(req.expires_at)
except ValueError:
raise HTTPException(status_code=400, detail="过期时间格式错误")
invitation = await team_invitation_service.create_invitation(
db, team.id, current_user.id, req.max_uses, expires_at
)
return {
"id": invitation.id,
"team_id": invitation.team_id,
"code": invitation.code,
"status": invitation.status,
"max_uses": invitation.max_uses,
"use_count": invitation.use_count,
"expires_at": invitation.expires_at,
"invite_link": _build_invite_link(invitation.code),
"created_at": invitation.created_at,
}
@router.get("/invitations", response_model=list[TeamInvitationOut])
async def list_invitations(
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="只有团队管理人可查看")
invitations = await team_invitation_service.get_invitations_for_team(db, team.id)
return [
{
"id": inv.id,
"team_id": inv.team_id,
"code": inv.code,
"status": inv.status,
"max_uses": inv.max_uses,
"use_count": inv.use_count,
"expires_at": inv.expires_at,
"invite_link": _build_invite_link(inv.code),
"created_at": inv.created_at,
}
for inv in invitations
]
@router.delete("/invitations/{invitation_id}")
async def revoke_invitation(
invitation_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await team_invitation_service.revoke_invitation(db, invitation_id, current_user.id)
return {"message": "ok"}
# ── 加入申请 ──────────────────────────────────────────
@router.post("/join")
async def join_by_code(
req: JoinByCodeRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await team_invitation_service.create_join_request(db, current_user.id, req.invitation_code)
return {"message": "申请已提交,请等待团队管理人审批"}
@router.get("/join-info", )
async def get_join_info(
code: str = Query(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""验证邀请码并返回团队信息(用于加入页面展示)。"""
invitation = await team_invitation_service.get_invitation_by_code(db, code)
if not invitation:
return JoinTeamInfoOut(team_name="", team_id="", valid=False)
team = await db.execute(
select(Team.name).where(Team.id == invitation.team_id, Team.deleted_at.is_(None)).limit(1)
)
team_name = team.scalar_one_or_none() or ""
already_in_team = current_user.team_id == invitation.team_id
return JoinTeamInfoOut(
team_name=team_name,
team_id=invitation.team_id,
valid=True,
already_in_team=already_in_team,
)
@router.get("/join-requests", )
async def list_join_requests(
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="只有团队管理人可查看")
requests = await team_invitation_service.get_pending_requests(db, team.id)
# 获取团队名
team_name_result = await db.execute(
select(Team.name).where(Team.id == team.id).limit(1)
)
team_name = team_name_result.scalar_one_or_none() or ""
return [
JoinRequestOut(
id=r["id"],
team_id=r["team_id"],
team_name=team_name,
user_id=r["user_id"],
username=r["username"],
phone=r.get("phone"),
status=r["status"],
note=r.get("note"),
created_at=r.get("created_at"),
)
for r in requests
]
@router.post("/join-requests/{request_id}")
async def handle_join_request(
request_id: str,
req: JoinRequestHandle,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await team_invitation_service.handle_join_request(
db, request_id, current_user.id, req.action, req.note
)
return {"message": "ok"}
+3 -1
View File
@@ -1,6 +1,8 @@
from app.models.base import Base, TimestampMixin, SoftDeleteMixin, engine, async_session, init_database, close_database
from app.models.user import User
from app.models.team import Team
from app.models.team_invitation import TeamInvitation
from app.models.team_join_request import TeamJoinRequest
from app.models.project import Project
from app.models.generation_record import GenerationRecord
from app.models.credit_record import CreditRecord
@@ -37,7 +39,7 @@ from app.models.private_portrait import PrivatePortraitProject, PrivatePortraitV
__all__ = [
"Base", "TimestampMixin", "SoftDeleteMixin", "engine", "async_session",
"init_database", "close_database",
"User", "Team", "Project", "GenerationRecord", "CreditRecord",
"User", "Team", "TeamInvitation", "TeamJoinRequest", "Project", "GenerationRecord", "CreditRecord",
"ModelConfig", "SystemConfig", "Notification", "PaymentOrder",
"TokenUsage", "IndustryConfig", "VideoEngine", "CreditRatio",
"MenuConfig", "RechargePackage", "OperationLog", "ContactRequest",
+4 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Index, Integer, String
from sqlalchemy import ForeignKey, Index, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.team import TeamStatus
@@ -32,3 +32,6 @@ class Team(Base, TimestampMixin, SoftDeleteMixin):
index=True,
comment="排序值,越小越靠前",
)
manager_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("users.id"), nullable=True, index=True, comment="团队管理人ID"
)
@@ -0,0 +1,22 @@
from datetime import datetime
from sqlalchemy import ForeignKey, Index, Integer, String, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
class TeamInvitation(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "team_invitations"
__table_args__ = (
Index("ix_team_invitations_team", "team_id", "status"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
team_id: Mapped[str] = mapped_column(String(32), ForeignKey("teams.id"), index=True, nullable=False)
code: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
created_by: Mapped[str] = mapped_column(String(32), ForeignKey("users.id"), nullable=False)
status: Mapped[str] = mapped_column(String(16), default="active", server_default="active", nullable=False)
max_uses: Mapped[int | None] = mapped_column(Integer, nullable=True)
use_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -0,0 +1,19 @@
from sqlalchemy import ForeignKey, Index, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class TeamJoinRequest(Base, TimestampMixin):
__tablename__ = "team_join_requests"
__table_args__ = (
Index("ix_tjr_team_status", "team_id", "status"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
team_id: Mapped[str] = mapped_column(String(32), ForeignKey("teams.id"), index=True, nullable=False)
user_id: Mapped[str] = mapped_column(String(32), ForeignKey("users.id"), index=True, nullable=False)
invitation_id: Mapped[str | None] = mapped_column(String(32), ForeignKey("team_invitations.id"), nullable=True)
status: Mapped[str] = mapped_column(String(16), default="pending", server_default="pending", nullable=False)
note: Mapped[str | None] = mapped_column(String(256), nullable=True)
handled_by: Mapped[str | None] = mapped_column(String(32), nullable=True)
+2
View File
@@ -29,6 +29,8 @@ class TeamOut(TeamBase):
member_count: int = 0
created_at: NaiveDatetime
updated_at: NaiveDatetimeOptional = None
manager_id: str | None = None
manager_name: str | None = None
model_config = {"from_attributes": True}
@@ -0,0 +1,22 @@
from pydantic import BaseModel, Field
from app.schemas.common import NaiveDatetimeOptional
class TeamInvitationCreate(BaseModel):
max_uses: int | None = Field(None, ge=1, description="最大使用次数,null 表示不限")
expires_at: str | None = Field(None, description="过期时间 ISO 格式,null 表示永不过期")
class TeamInvitationOut(BaseModel):
id: str
team_id: str
code: str
status: str
max_uses: int | None = None
use_count: int = 0
expires_at: NaiveDatetimeOptional = None
invite_link: str = ""
created_at: NaiveDatetimeOptional = None
model_config = {"from_attributes": True}
@@ -0,0 +1,33 @@
from pydantic import BaseModel, Field
from app.schemas.common import NaiveDatetimeOptional
class JoinByCodeRequest(BaseModel):
invitation_code: str = Field(..., description="邀请码")
class JoinRequestHandle(BaseModel):
action: str = Field(..., pattern="^(approve|reject)$", description="approve 通过 / reject 拒绝")
note: str | None = Field(None, max_length=256, description="拒绝原因")
class JoinRequestOut(BaseModel):
id: str
team_id: str
team_name: str
user_id: str
username: str
phone: str | None = None
status: str
note: str | None = None
created_at: NaiveDatetimeOptional = None
model_config = {"from_attributes": True}
class JoinTeamInfoOut(BaseModel):
team_name: str
team_id: str
valid: bool
already_in_team: bool = False
+37
View File
@@ -0,0 +1,37 @@
from pydantic import BaseModel, Field
from app.schemas.common import NaiveDatetime
class SetManagerRequest(BaseModel):
user_id: str | None = Field(None, description="设为管理人的前台用户ID;传 null 表示取消管理人")
class TeamMemberOut(BaseModel):
id: str
username: str
phone: str | None = None
credits: float
is_active: bool = True
joined_at: NaiveDatetime
model_config = {"from_attributes": True}
class ManagerTransferRequest(BaseModel):
target_user_id: str = Field(..., description="接收积分的成员用户ID")
amount: float = Field(gt=0, description="转账积分数量(正数)")
description: str | None = Field(None, max_length=256, description="转账说明")
class ManagedTeamOut(BaseModel):
id: str
name: str
code: str | None = None
description: str | None = None
status: str
member_count: int = 0
manager_id: str | None = None
manager_name: str | None = None
model_config = {"from_attributes": True}
+3
View File
@@ -16,5 +16,8 @@ class UserOut(BaseModel):
must_set_password: bool = False
resource_capacity: ResourceCapacityUsageOut | None = None
private_portrait_image_limit: int = 5
team_id: str | None = None
team_name: str | None = None
is_team_manager: bool = False
model_config = {"from_attributes": True}
@@ -0,0 +1,236 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.team import Team
from app.models.team_invitation import TeamInvitation
from app.models.team_join_request import TeamJoinRequest
from app.models.user import User
from app.utils.id_gen import generate_id
import secrets
def _generate_invite_code() -> str:
"""生成唯一邀请码。"""
return secrets.token_urlsafe(12)
async def _get_team_or_404(db: AsyncSession, team_id: str) -> Team:
result = await db.execute(
select(Team).where(Team.id == team_id, Team.deleted_at.is_(None)).limit(1)
)
team = result.scalar_one_or_none()
if not team:
raise HTTPException(status_code=404, detail="团队不存在")
return team
async def _assert_is_manager(db: AsyncSession, user_id: str, team_id: str) -> None:
team = await _get_team_or_404(db, team_id)
if team.manager_id != user_id:
raise HTTPException(status_code=403, detail="只有团队管理人才能操作")
async def create_invitation(
db: AsyncSession,
team_id: str,
created_by: str,
max_uses: int | None = None,
expires_at: datetime | None = None,
) -> TeamInvitation:
"""创建邀请码(仅团队管理人)。"""
await _assert_is_manager(db, created_by, team_id)
code = _generate_invite_code()
invitation = TeamInvitation(
id=generate_id(),
team_id=team_id,
code=code,
created_by=created_by,
status="active",
max_uses=max_uses,
expires_at=expires_at,
)
db.add(invitation)
await db.flush()
await db.refresh(invitation)
return invitation
async def get_invitation_by_code(db: AsyncSession, code: str) -> TeamInvitation | None:
"""根据邀请码获取邀请记录(验证有效)。"""
result = await db.execute(
select(TeamInvitation).where(
TeamInvitation.code == code,
TeamInvitation.deleted_at.is_(None),
).limit(1)
)
invitation = result.scalar_one_or_none()
if not invitation or invitation.status != "active":
return None
if invitation.expires_at and invitation.expires_at < datetime.now(timezone.utc):
return None
if invitation.max_uses and invitation.use_count >= invitation.max_uses:
return None
return invitation
async def revoke_invitation(db: AsyncSession, invitation_id: str, revoked_by: str) -> None:
"""撤销邀请码。"""
result = await db.execute(
select(TeamInvitation).where(
TeamInvitation.id == invitation_id,
TeamInvitation.deleted_at.is_(None),
).limit(1)
)
invitation = result.scalar_one_or_none()
if not invitation:
raise HTTPException(status_code=404, detail="邀请码不存在")
await _assert_is_manager(db, revoked_by, invitation.team_id)
invitation.status = "revoked"
await db.flush()
async def create_join_request(
db: AsyncSession,
user_id: str,
invitation_code: str,
) -> TeamJoinRequest:
"""通过邀请码创建加入申请。"""
invitation = await get_invitation_by_code(db, invitation_code)
if not invitation:
raise HTTPException(status_code=400, detail="邀请码无效或已过期/已用完")
# 验证用户存在
user_result = await db.execute(
select(User).where(User.id == user_id, User.deleted_at.is_(None)).limit(1)
)
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
# 检查是否已在该团队
if user.team_id == invitation.team_id:
raise HTTPException(status_code=400, detail="您已在此团队中")
# 检查是否已有待审批的申请
existing = await db.execute(
select(TeamJoinRequest).where(
TeamJoinRequest.user_id == user_id,
TeamJoinRequest.team_id == invitation.team_id,
TeamJoinRequest.status == "pending",
).limit(1)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="您已提交申请,请等待审批")
# 检查是否已有待审批的其他团队申请
existing_any = await db.execute(
select(TeamJoinRequest).where(
TeamJoinRequest.user_id == user_id,
TeamJoinRequest.status == "pending",
).limit(1)
)
if existing_any.scalar_one_or_none():
raise HTTPException(status_code=400, detail="您已有一个待审批的团队申请,请先等待处理")
request = TeamJoinRequest(
id=generate_id(),
team_id=invitation.team_id,
user_id=user_id,
invitation_id=invitation.id,
status="pending",
)
db.add(request)
# 增加邀请码使用次数
invitation.use_count = (invitation.use_count or 0) + 1
if invitation.max_uses and invitation.use_count >= invitation.max_uses:
invitation.status = "revoked"
await db.flush()
return request
async def handle_join_request(
db: AsyncSession,
request_id: str,
manager_id: str,
action: str,
note: str | None = None,
) -> None:
"""审批/拒绝加入申请(仅团队管理人)。"""
result = await db.execute(
select(TeamJoinRequest).where(TeamJoinRequest.id == request_id).limit(1)
)
request = result.scalar_one_or_none()
if not request:
raise HTTPException(status_code=404, detail="申请不存在")
if request.status != "pending":
raise HTTPException(status_code=400, detail="该申请已处理")
await _assert_is_manager(db, manager_id, request.team_id)
if action == "approve":
# 检查用户是否已在其他团队
user_result = await db.execute(
select(User).where(User.id == request.user_id, User.deleted_at.is_(None)).limit(1)
)
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if user.team_id and user.team_id != request.team_id:
raise HTTPException(status_code=400, detail="用户已在其他团队中,无法加入")
user.team_id = request.team_id
request.status = "approved"
elif action == "reject":
request.status = "rejected"
request.note = note
else:
raise HTTPException(status_code=400, detail="无效操作")
request.handled_by = manager_id
await db.flush()
async def get_pending_requests(db: AsyncSession, team_id: str) -> list[dict[str, Any]]:
"""获取团队待审批的申请列表。"""
result = await db.execute(
select(TeamJoinRequest, User.username, User.phone)
.join(User, User.id == TeamJoinRequest.user_id)
.where(
TeamJoinRequest.team_id == team_id,
TeamJoinRequest.status == "pending",
)
.order_by(TeamJoinRequest.created_at.asc())
)
rows = result.all()
return [
{
"id": req.id,
"team_id": req.team_id,
"user_id": req.user_id,
"username": username,
"phone": phone,
"status": req.status,
"note": req.note,
"created_at": req.created_at,
}
for req, username, phone in rows
]
async def get_invitations_for_team(db: AsyncSession, team_id: str) -> list[TeamInvitation]:
"""获取团队的所有邀请码。"""
result = await db.execute(
select(TeamInvitation).where(
TeamInvitation.team_id == team_id,
TeamInvitation.deleted_at.is_(None),
).order_by(TeamInvitation.created_at.desc())
)
return list(result.scalars().all())
@@ -0,0 +1,185 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.team import TeamStatus
from app.enums.user import UserType
from app.models.team import Team
from app.models.user import User
from app.services.credits import add_credits, deduct_credits
from app.utils.id_gen import generate_id
async def set_team_manager(db: AsyncSession, team_id: str, user_id: str | None) -> Team:
"""设置团队管理人。user_id 为 None 表示取消管理人。"""
result = await db.execute(
select(Team).where(Team.id == team_id, Team.deleted_at.is_(None)).limit(1)
)
team = result.scalar_one_or_none()
if not team:
raise HTTPException(status_code=404, detail="团队不存在")
if user_id is None:
team.manager_id = None
await db.flush()
await db.refresh(team)
return team
# 验证用户存在、是前台用户、属于该团队
user_result = await db.execute(
select(User).where(User.id == user_id, User.is_active.is_(True)).limit(1)
)
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if user.user_type != UserType.FRONTEND.value:
raise HTTPException(status_code=400, detail="仅前台用户可设为团队管理人")
if user.team_id != team_id:
raise HTTPException(status_code=400, detail="用户不属于该团队,请先将其加入团队")
team.manager_id = user_id
await db.flush()
await db.refresh(team)
return team
async def is_team_manager(db: AsyncSession, user_id: str, team_id: str | None) -> bool:
"""判断用户是否是指定团队的管理人。"""
if not team_id:
return False
result = await db.execute(
select(Team.manager_id).where(Team.id == team_id, Team.deleted_at.is_(None)).limit(1)
)
manager_id = result.scalar_one_or_none()
return manager_id == user_id
async def get_managed_team(db: AsyncSession, user_id: str) -> Team | None:
"""获取用户管理的团队。"""
result = await db.execute(
select(Team).where(Team.manager_id == user_id, Team.deleted_at.is_(None)).limit(1)
)
return result.scalar_one_or_none()
async def get_team_members(
db: AsyncSession,
team_id: str,
*,
page: int = 1,
page_size: int = 20,
) -> dict[str, Any]:
"""列出团队成员(仅前台用户)。"""
page = max(int(page or 1), 1)
page_size = min(max(int(page_size or 20), 1), 100)
# 验证团队存在
team_result = await db.execute(
select(Team).where(Team.id == team_id, Team.deleted_at.is_(None)).limit(1)
)
if not team_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="团队不存在")
# 总数
from sqlalchemy import func
total = (await db.execute(
select(func.count(User.id)).where(
User.user_type == UserType.FRONTEND.value,
User.team_id == team_id,
User.is_active.is_(True),
)
)).scalar() or 0
# 列表
result = await db.execute(
select(User).where(
User.user_type == UserType.FRONTEND.value,
User.team_id == team_id,
User.is_active.is_(True),
)
.order_by(User.created_at.asc())
.offset((page - 1) * page_size)
.limit(page_size)
)
members = list(result.scalars().all())
return {
"items": [
{
"id": m.id,
"username": m.username,
"phone": m.phone,
"credits": m.credits,
"is_active": m.is_active,
"joined_at": m.created_at,
}
for m in members
],
"total": total,
}
async def transfer_credits_to_member(
db: AsyncSession,
manager_id: str,
target_member_id: str,
amount: float,
description: str | None = None,
) -> None:
"""管理人从自己余额转积分给团队成员。"""
if amount <= 0:
raise HTTPException(status_code=400, description="转账积分必须大于0")
# 获取管理人
manager_result = await db.execute(
select(User).where(User.id == manager_id, User.is_active.is_(True)).limit(1)
)
manager = manager_result.scalar_one_or_none()
if not manager:
raise HTTPException(status_code=404, detail="管理员不存在")
# 获取目标成员
member_result = await db.execute(
select(User).where(User.id == target_member_id, User.is_active.is_(True)).limit(1)
)
member = member_result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=404, detail="成员不存在")
# 验证管理人是该团队的管理人且目标是同团队成员
if not manager.team_id:
raise HTTPException(status_code=400, detail="您不在任何团队中")
if member.team_id != manager.team_id:
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)
)
team = team_result.scalar_one_or_none()
if not team or team.manager_id != manager_id:
raise HTTPException(status_code=403, detail="只有团队管理人才能分配积分")
desc = description or "团队积分发放"
# 从管理人扣减
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()}",
)
+23 -2
View File
@@ -31,7 +31,7 @@ def _team_snapshot(team: Team | None) -> dict[str, Any]:
}
def _team_out_payload(team: Team, member_count: int = 0) -> dict[str, Any]:
def _team_out_payload(team: Team, member_count: int = 0, manager_name: str | None = None) -> dict[str, Any]:
return {
"id": team.id,
"name": team.name,
@@ -42,6 +42,8 @@ def _team_out_payload(team: Team, member_count: int = 0) -> dict[str, Any]:
"member_count": int(member_count or 0),
"created_at": team.created_at,
"updated_at": team.updated_at,
"manager_id": getattr(team, "manager_id", None),
"manager_name": manager_name,
}
@@ -112,8 +114,25 @@ async def list_teams(
.group_by(User.team_id)
)
member_map = {row[0]: int(row[1] or 0) for row in member_result.all()}
# 批量获取管理人用户名
manager_ids = [getattr(t, "manager_id", None) for t in teams if getattr(t, "manager_id", None)]
manager_name_map: dict[str, str] = {}
if manager_ids:
mgr_result = await db.execute(
select(User.id, User.username).where(User.id.in_(manager_ids))
)
manager_name_map = {row[0]: row[1] for row in mgr_result.all()}
return {
"items": [_team_out_payload(team, member_map.get(team.id, 0)) for team in teams],
"items": [
_team_out_payload(
team,
member_map.get(team.id, 0),
manager_name_map.get(getattr(team, "manager_id", None)),
)
for team in teams
],
"total": total,
}
@@ -164,6 +183,7 @@ async def create_team(db: AsyncSession, req: TeamCreate) -> Team:
)
db.add(team)
await db.flush()
await db.refresh(team)
return team
@@ -190,6 +210,7 @@ async def update_team(db: AsyncSession, team_id: str, req: TeamUpdate) -> tuple[
team.status = req.status or TeamStatus.ACTIVE.value
team.sort_order = req.sort_order or 0
await db.flush()
await db.refresh(team)
after = {
"id": team.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-CclRZKjR.js"></script>
<script type="module" crossorigin src="/assets/index-DefYYdtQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D9_3MPsN.css">
</head>
<body>
+4
View File
@@ -32,6 +32,8 @@ import ConsumePage from './pages/ConsumePage';
import AuthorizationWaitingPage from './pages/AuthorizationWaitingPage';
import PopularPage from './pages/PopularPage';
import CreativePlazaPage from './pages/CreativePlazaPage';
import TeamManagementPage from './pages/TeamManagementPage';
import JoinTeamPage from './pages/JoinTeamPage';
import { useAuthStore } from './store/useAuthStore';
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const { user, loading, checkAuth } = useAuthStore();
@@ -124,6 +126,8 @@ const App = () => {
<Route path="popular" element={<PopularPage />} />
<Route path="authacc" element={<AuthAccountPage />} />
<Route path="creativeplaza" element={<CreativePlazaPage />} />
<Route path="team-management" element={<TeamManagementPage />} />
<Route path="join-team" element={<JoinTeamPage />} />
</Route>
<Route path="*" element={<Navigate to="/projects" replace />} />
</Routes>
+44
View File
@@ -805,3 +805,47 @@ export async function getPrivatePortraitSelectableAssets(params: { projectId?: s
if (params.keyword) query.set('keyword', params.keyword);
return api.get<PrivatePortraitSelectableAssetListOut>(`/private-portrait/selectable-assets?${query.toString()}`);
}
// ── Team Management APIs ──────────────────────────────
export async function getManagedTeam(): Promise<any> {
return api.get('/team/managed');
}
export async function getTeamMembers(page = 1, pageSize = 20): Promise<any> {
const params = new URLSearchParams();
params.set('page', String(page));
params.set('page_size', String(pageSize));
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 getTeamInvitations(): Promise<any[]> {
return api.get('/team/invitations');
}
export async function createTeamInvitation(maxUses?: number, expiresAt?: string): Promise<any> {
return api.post('/team/invitations', { max_uses: maxUses || null, expires_at: expiresAt || null });
}
export async function revokeInvitation(invitationId: string): Promise<void> {
await api.delete(`/team/invitations/${invitationId}`);
}
export async function getPendingJoinRequests(): Promise<any[]> {
return api.get('/team/join-requests');
}
export async function handleJoinRequest(requestId: string, action: 'approve' | 'reject', note?: string): Promise<void> {
await api.post(`/team/join-requests/${requestId}`, { action, note: note || null });
}
export async function getJoinTeamInfo(code: string): Promise<any> {
return api.get(`/team/join-info?code=${encodeURIComponent(code)}`);
}
export async function submitJoinRequest(code: string): Promise<void> {
await api.post('/team/join', { invitation_code: code });
}
@@ -837,6 +837,16 @@ 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={{
+121
View File
@@ -0,0 +1,121 @@
import React, { useEffect, useState } from 'react';
import { Button, Card, Result, Spin, Typography, Modal, message } from 'antd';
import { CheckCircleOutlined, TeamOutlined } from '@ant-design/icons';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { getJoinTeamInfo, submitJoinRequest } from '../api';
import type { JoinTeamInfo } from '../types';
const JoinTeamPage: React.FC = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const code = searchParams.get('code') || '';
const [loading, setLoading] = useState(true);
const [info, setInfo] = useState<JoinTeamInfo | null>(null);
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
if (!code) {
setLoading(false);
return;
}
getJoinTeamInfo(code)
.then((data) => setInfo(data))
.catch(() => setInfo(null))
.finally(() => setLoading(false));
}, [code]);
const handleJoin = async () => {
if (!code) return;
Modal.confirm({
title: '确认加入团队',
icon: <TeamOutlined style={{ color: '#6366f1' }} />,
content: info?.teamName ? `您确定要加入团队「${info.teamName}」吗?提交后需等待团队管理人审批。` : '您确定要加入该团队吗?',
okText: '确认加入',
cancelText: '取消',
onOk: async () => {
try {
setSubmitting(true);
await submitJoinRequest(code);
setSubmitted(true);
} catch (e: any) {
message.error(e?.message || '申请失败');
} finally {
setSubmitting(false);
}
},
});
};
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh' }}>
<Spin size="large" />
</div>
);
}
if (submitted) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<Result
status="success"
icon={<CheckCircleOutlined style={{ color: '#6366f1' }} />}
title="申请已提交"
subTitle="您的加入申请已提交,请等待团队管理人审批。审批通过后将自动加入团队。"
extra={<Button type="primary" onClick={() => navigate('/projects')}></Button>}
/>
</div>
);
}
if (!code || !info?.valid) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<Result
status="error"
title="邀请链接无效"
subTitle="该邀请链接可能已过期或不存在,请联系团队管理人重新获取。"
extra={<Button type="primary" onClick={() => navigate('/projects')}></Button>}
/>
</div>
);
}
if (info.alreadyInTeam) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<Result
status="info"
title="您已在此团队中"
subTitle={`您已经是「${info.teamName}」的成员了,无需再次加入。`}
extra={<Button type="primary" onClick={() => navigate('/projects')}></Button>}
/>
</div>
);
}
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<Card variant="outlined" style={{ borderRadius: 16, maxWidth: 480, width: '100%', textAlign: 'center' }}>
<TeamOutlined style={{ fontSize: 48, color: '#6366f1', marginBottom: 16 }} />
<Typography.Title level={3}></Typography.Title>
<Typography.Text style={{ fontSize: 16, color: '#475569', display: 'block', marginBottom: 8 }}>
</Typography.Text>
<Typography.Title level={4} style={{ color: '#6366f1', margin: '16px 0 24px' }}>
{info.teamName}
</Typography.Title>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 24 }}>
使
</Typography.Text>
<Button type="primary" size="large" block loading={submitting} onClick={handleJoin}>
</Button>
</Card>
</div>
);
};
export default JoinTeamPage;
@@ -0,0 +1,351 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Button, Card, Empty, Form, Input, InputNumber, message, Modal, Space, Table, Tabs, Tag, Tooltip, Typography,
} from 'antd';
import {
CopyOutlined, PlusOutlined, ReloadOutlined, UserOutlined, HistoryOutlined,
} from '@ant-design/icons';
import {
createTeamInvitation, getJoinTeamInfo, getManagedTeam, getPendingJoinRequests,
getTeamInvitations, getTeamMembers, handleJoinRequest, revokeInvitation, submitJoinRequest, transferCredits,
} from '../api';
import type { ManagedTeam, TeamInvitation, TeamJoinRequest, TeamMember } from '../types';
import { formatDate } from '../utils/formatDate';
const TeamManagementPage: React.FC = () => {
const [team, setTeam] = useState<ManagedTeam | null>(null);
const [teamLoading, setTeamLoading] = useState(false);
const loadTeam = useCallback(async () => {
setTeamLoading(true);
try {
const data = await getManagedTeam();
setTeam(data);
} catch (e: any) {
message.error(e?.message || '获取团队信息失败');
} finally {
setTeamLoading(false);
}
}, []);
useEffect(() => { loadTeam(); }, [loadTeam]);
// ── Tab 1: 成员 ──
const [members, setMembers] = useState<TeamMember[]>([]);
const [membersTotal, setMembersTotal] = useState(0);
const [membersLoading, setMembersLoading] = useState(false);
const [membersPage, setMembersPage] = useState(1);
const loadMembers = useCallback(async () => {
if (!team) return;
setMembersLoading(true);
try {
const res = await getTeamMembers(membersPage);
setMembers(res.items || []);
setMembersTotal(res.total || 0);
} catch (e: any) {
message.error(e?.message || '加载成员失败');
} finally {
setMembersLoading(false);
}
}, [team, membersPage]);
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);
const handleTransfer = async () => {
if (!creditModal.member) return;
try {
const values = await creditForm.validateFields();
setCreditSaving(true);
await transferCredits(creditModal.member.id, values.amount, values.description);
message.success('积分转账成功');
setCreditModal({ open: false, member: null });
creditForm.resetFields();
loadMembers();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '转账失败');
} finally {
setCreditSaving(false);
}
};
// ── Tab 2: 邀请码 ──
const [invitations, setInvitations] = useState<TeamInvitation[]>([]);
const [invLoading, setInvLoading] = useState(false);
const [invModal, setInvModal] = useState(false);
const [invForm] = Form.useForm();
const [invSaving, setInvSaving] = useState(false);
const loadInvitations = useCallback(async () => {
setInvLoading(true);
try {
const data = await getTeamInvitations();
setInvitations(data || []);
} catch (e: any) {
message.error(e?.message || '加载邀请码失败');
} finally {
setInvLoading(false);
}
}, []);
useEffect(() => { loadInvitations(); }, [loadInvitations]);
const handleCreateInvitation = async () => {
try {
const values = await invForm.validateFields();
setInvSaving(true);
const expiresAt = values.expiresAt ? new Date(values.expiresAt).toISOString() : null;
await createTeamInvitation(values.maxUses || null, expiresAt);
message.success('邀请码已生成');
setInvModal(false);
invForm.resetFields();
loadInvitations();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '创建失败');
} finally {
setInvSaving(false);
}
};
const handleRevoke = async (invId: string) => {
try {
await revokeInvitation(invId);
message.success('已撤销');
loadInvitations();
} catch (e: any) {
message.error(e?.message || '撤销失败');
}
};
const copyInviteLink = (link: string) => {
navigator.clipboard.writeText(link).then(() => {
message.success('邀请链接已复制');
}).catch(() => {
message.warning('复制失败,请手动复制');
});
};
// ── Tab 3: 加入申请 ──
const [requests, setRequests] = useState<TeamJoinRequest[]>([]);
const [reqLoading, setReqLoading] = useState(false);
const loadRequests = useCallback(async () => {
setReqLoading(true);
try {
const data = await getPendingJoinRequests();
setRequests(data || []);
} catch (e: any) {
message.error(e?.message || '加载申请失败');
} finally {
setReqLoading(false);
}
}, []);
useEffect(() => { loadRequests(); }, [loadRequests]);
const handleRequest = async (requestId: string, action: 'approve' | 'reject', note?: string) => {
try {
await handleJoinRequest(requestId, action, note);
message.success(action === 'approve' ? '已通过' : '已拒绝');
loadRequests();
loadMembers();
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
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 || '-' },
{ 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: '操作', key: 'action', width: 100,
render: (_: any, r: TeamMember) => (
<Button size="small" type="link" onClick={() => { setCreditModal({ open: true, member: r }); creditForm.resetFields(); }}></Button>
),
},
];
const invColumns = [
{ title: '邀请码', dataIndex: 'code', width: 200, render: (v: string) => <Typography.Text copyable style={{ fontFamily: 'monospace' }}>{v}</Typography.Text> },
{
title: '邀请链接', dataIndex: 'inviteLink', ellipsis: true,
render: (v: string) => (
<Space>
<Typography.Text ellipsis style={{ maxWidth: 250, fontSize: 12 }}>{v}</Typography.Text>
<Tooltip title="复制链接">
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyInviteLink(v)} />
</Tooltip>
</Space>
),
},
{ 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: '操作', key: 'action', width: 80,
render: (_: any, r: TeamInvitation) => r.status === 'active' ? (
<Button size="small" type="link" danger onClick={() => handleRevoke(r.id)}></Button>
) : null,
},
];
const reqColumns = [
{ title: '申请人', dataIndex: 'username', width: 150, 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: '操作', key: 'action', width: 160,
render: (_: any, r: TeamJoinRequest) => (
<Space size={4}>
<Button size="small" type="link" style={{ color: '#16a34a', padding: 0 }} onClick={() => handleRequest(r.id, 'approve')}></Button>
<Button size="small" type="link" danger style={{ padding: 0 }} onClick={() => {
Modal.confirm({
title: '拒绝申请',
content: (
<Form layout="vertical" style={{ marginTop: 12 }}>
<Form.Item name="note" label="拒绝原因(可选)">
<Input.TextArea rows={2} placeholder="选填" id="reject-note-input" />
</Form.Item>
</Form>
),
onOk: () => {
const note = (document.getElementById('reject-note-input') as HTMLTextAreaElement)?.value || undefined;
handleRequest(r.id, 'reject', note);
},
});
}}></Button>
</Space>
),
},
];
const tabItems = [
{
key: 'members',
label: <Space><UserOutlined />{membersTotal > 0 && <span style={{ color: '#94a3b8', fontSize: 12 }}>({membersTotal})</span>}</Space>,
children: (
<Table
columns={memberColumns}
dataSource={members}
rowKey="id"
loading={membersLoading}
pagination={{
current: membersPage,
pageSize: 20,
total: membersTotal,
onChange: (p) => setMembersPage(p),
showTotal: (t) => `${t}`,
}}
scroll={{ x: 800 }}
locale={{ emptyText: <Empty description="暂无成员" /> }}
/>
),
},
{
key: 'invitations',
label: <Space><CopyOutlined /></Space>,
children: (
<div>
<div style={{ marginBottom: 12 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setInvModal(true)}></Button>
</div>
<Table
columns={invColumns}
dataSource={invitations}
rowKey="id"
loading={invLoading}
pagination={false}
scroll={{ x: 900 }}
locale={{ emptyText: <Empty description="暂无邀请码" /> }}
/>
</div>
),
},
{
key: 'requests',
label: <Space><HistoryOutlined />{requests.length > 0 && <Tag color="red">{requests.length}</Tag>}</Space>,
children: (
<Table
columns={reqColumns}
dataSource={requests}
rowKey="id"
loading={reqLoading}
pagination={false}
scroll={{ x: 600 }}
locale={{ emptyText: <Empty description="暂无待审批申请" /> }}
/>
),
},
];
return (
<div style={{ padding: 24, maxWidth: 1200, margin: '0 auto' }}>
<Card variant="outlined" style={{ borderRadius: 12, marginBottom: 16 }} loading={teamLoading}>
{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>
<Typography.Text type="secondary">: {team.code || '-'} : {team.memberCount} </Typography.Text>
</div>
<Button icon={<ReloadOutlined />} onClick={() => { loadTeam(); loadMembers(); loadInvitations(); loadRequests(); }}></Button>
</div>
)}
</Card>
<Card variant="outlined" style={{ borderRadius: 12 }}>
<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="确认转账"
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.Item>
<Form.Item name="description" label="备注">
<Input.TextArea rows={2} maxLength={256} placeholder="选填,例如:活动奖励" />
</Form.Item>
</Form>
</Modal>
{/* 生成邀请码弹窗 */}
<Modal
title={<Space><CopyOutlined /></Space>}
open={invModal}
confirmLoading={invSaving}
onOk={handleCreateInvitation}
onCancel={() => { setInvModal(false); invForm.resetFields(); }}
okText="生成"
width={480}
>
<Form form={invForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="maxUses" label="最大使用次数">
<InputNumber style={{ width: '100%' }} min={1} placeholder="留空表示不限" size="large" />
</Form.Item>
<Form.Item name="expiresAt" label="过期时间">
<Input type="datetime-local" style={{ width: '100%' }} placeholder="留空表示永不过期" size="large" />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default TeamManagementPage;
+55
View File
@@ -22,6 +22,61 @@ export interface User {
allowedMenus?: string[] | null;
mustSetPassword?: boolean;
resourceCapacity?: ResourceCapacity;
teamId?: string | null;
teamName?: string | null;
isTeamManager?: boolean;
}
// ── Team Management Types ─────────────────────────────
export interface TeamMember {
id: string;
username: string;
phone?: string | null;
credits: number;
isActive: boolean;
joinedAt: string;
}
export interface TeamInvitation {
id: string;
code: string;
status: string;
maxUses?: number | null;
useCount: number;
expiresAt?: string | null;
inviteLink: string;
createdAt?: string | null;
}
export interface TeamJoinRequest {
id: string;
teamId: string;
teamName: string;
userId: string;
username: string;
phone?: string | null;
status: string;
note?: string | null;
createdAt?: string | null;
}
export interface ManagedTeam {
id: string;
name: string;
code?: string | null;
description?: string | null;
status: string;
memberCount: number;
managerId?: string | null;
managerName?: string | null;
}
export interface JoinTeamInfo {
teamName: string;
teamId: string;
valid: boolean;
alreadyInTeam: boolean;
}
export interface CreditRecord {