merge main
This commit is contained in:
Vendored
+7
-7
File diff suppressed because one or more lines are too long
Vendored
+36
-36
@@ -1,37 +1,37 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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" />
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
<title>后台管理</title>
|
<title>后台管理</title>
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
var cached = localStorage.getItem('siteInfo');
|
var cached = localStorage.getItem('siteInfo');
|
||||||
if (cached) {
|
if (cached) {
|
||||||
try {
|
try {
|
||||||
var info = JSON.parse(cached);
|
var info = JSON.parse(cached);
|
||||||
if (info.siteName) {
|
if (info.siteName) {
|
||||||
document.title = info.siteName + ' - 管理后台';
|
document.title = info.siteName + ' - 管理后台';
|
||||||
}
|
}
|
||||||
if (info.siteLogo) {
|
if (info.siteLogo) {
|
||||||
var link = document.querySelector('link[rel="icon"]');
|
var link = document.querySelector('link[rel="icon"]');
|
||||||
if (link) {
|
if (link) {
|
||||||
link.href = info.siteLogo;
|
link.href = info.siteLogo;
|
||||||
link.type = 'image/png';
|
link.type = 'image/png';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script type="module" crossorigin src="/assets/index-OStxKvM2.js"></script>
|
<script type="module" crossorigin src="/assets/index-BaN8_2WC.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>␍
|
<div id="root"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -181,6 +181,19 @@ export async function deleteAdminTeam(id: string): Promise<void> {
|
|||||||
await api.delete(`/admin/teams/${id}`);
|
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> {
|
export async function adjustCredits(userId: string, amount: number, description: string): Promise<void> {
|
||||||
await api.post(`/admin/users/${userId}/credits`, { amount, description });
|
await api.post(`/admin/users/${userId}/credits`, { amount, description });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography } from 'antd';
|
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 { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, TeamOutlined, UserOutlined } from '@ant-design/icons';
|
||||||
import { deleteAdminTeam, getAdminTeams, saveAdminTeam } from '../api';
|
import { deleteAdminTeam, getAdminTeams, getTeamMembersForAdmin, saveAdminTeam, setTeamManager } from '../api';
|
||||||
import type { AdminTeam } from '../types';
|
import type { AdminTeam, AdminUser } from '../types';
|
||||||
import { formatDate } from '../utils/formatDate';
|
import { formatDate } from '../utils/formatDate';
|
||||||
|
|
||||||
const statusOptions = [
|
const statusOptions = [
|
||||||
@@ -34,6 +34,17 @@ const AdminTeams: React.FC = () => {
|
|||||||
const [modal, setModal] = useState<{ open: boolean; item: AdminTeam | null }>({ open: false, item: null });
|
const [modal, setModal] = useState<{ open: boolean; item: AdminTeam | null }>({ open: false, item: null });
|
||||||
const [form] = Form.useForm();
|
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(() => ({
|
const query = useMemo(() => ({
|
||||||
page,
|
page,
|
||||||
pageSize,
|
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 = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: '团队名称',
|
title: '团队名称',
|
||||||
@@ -136,6 +189,16 @@ const AdminTeams: React.FC = () => {
|
|||||||
width: 100,
|
width: 100,
|
||||||
render: (v: number) => <Typography.Text strong>{Number(v || 0).toLocaleString()}</Typography.Text>,
|
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: '排序',
|
title: '排序',
|
||||||
dataIndex: 'sortOrder',
|
dataIndex: 'sortOrder',
|
||||||
@@ -157,10 +220,12 @@ const AdminTeams: React.FC = () => {
|
|||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'action',
|
key: 'action',
|
||||||
width: 160,
|
width: 230,
|
||||||
fixed: 'right' as const,
|
fixed: 'right' as const,
|
||||||
render: (_: any, r: AdminTeam) => (
|
render: (_: any, r: AdminTeam) => (
|
||||||
<Space size={4}>
|
<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>
|
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}>编辑</Button>
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="确定删除该团队?"
|
title="确定删除该团队?"
|
||||||
@@ -239,6 +304,73 @@ const AdminTeams: React.FC = () => {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -147,6 +147,8 @@ export interface AdminTeam {
|
|||||||
memberCount: number;
|
memberCount: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt?: string | null;
|
updatedAt?: string | null;
|
||||||
|
managerId?: string | null;
|
||||||
|
managerName?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminTeamOption {
|
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')
|
||||||
@@ -2,18 +2,57 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.dependencies import get_admin_user, get_db
|
from app.dependencies import get_admin_user, get_db
|
||||||
|
from app.models.team import Team
|
||||||
from app.models.user import User
|
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.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
|
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"])
|
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)
|
@router.get("", response_model=TeamListOut)
|
||||||
async def list_admin_teams(
|
async def list_admin_teams(
|
||||||
page: int = Query(1, ge=1),
|
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)
|
return await list_team_options(db, include_disabled=include_disabled)
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=TeamOut)
|
@router.post("", )
|
||||||
async def create_admin_team(
|
async def create_admin_team(
|
||||||
req: TeamCreate,
|
req: TeamCreate,
|
||||||
admin: User = Depends(get_admin_user),
|
admin: User = Depends(get_admin_user),
|
||||||
@@ -51,20 +90,10 @@ async def create_admin_team(
|
|||||||
"/admin/teams",
|
"/admin/teams",
|
||||||
detail=json.dumps({"after": req.model_dump()}, ensure_ascii=False),
|
detail=json.dumps({"after": req.model_dump()}, ensure_ascii=False),
|
||||||
)
|
)
|
||||||
return {
|
return await _team_detail_payload(db, team)
|
||||||
"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,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{team_id}", response_model=TeamOut)
|
@router.put("/{team_id}", )
|
||||||
async def update_admin_team(
|
async def update_admin_team(
|
||||||
team_id: str,
|
team_id: str,
|
||||||
req: TeamUpdate,
|
req: TeamUpdate,
|
||||||
@@ -81,17 +110,35 @@ async def update_admin_team(
|
|||||||
f"/admin/teams/{team_id}",
|
f"/admin/teams/{team_id}",
|
||||||
detail=json.dumps({"before": before, "after": after}, ensure_ascii=False),
|
detail=json.dumps({"before": before, "after": after}, ensure_ascii=False),
|
||||||
)
|
)
|
||||||
return {
|
return await _team_detail_payload(db, team)
|
||||||
"id": team.id,
|
|
||||||
"name": team.name,
|
|
||||||
"code": team.code,
|
@router.put("/{team_id}/manager", )
|
||||||
"description": team.description,
|
async def set_team_manager_endpoint(
|
||||||
"status": team.status,
|
team_id: str,
|
||||||
"sort_order": team.sort_order,
|
req: SetManagerRequest = Body(...),
|
||||||
"member_count": 0,
|
admin: User = Depends(get_admin_user),
|
||||||
"created_at": team.created_at,
|
db: AsyncSession = Depends(get_db),
|
||||||
"updated_at": team.updated_at,
|
):
|
||||||
}
|
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}")
|
@router.delete("/{team_id}")
|
||||||
|
|||||||
@@ -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.open_type import router as open_type_router
|
||||||
from app.api.v1.resources_material import router as resources_material_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.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.v1.home_materials import router as home_materials_router
|
||||||
from app.api.admin import router as admin_module_router
|
from app.api.admin import router as admin_module_router
|
||||||
from app.api.v1.material_admin import router as material_admin_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(open_type_router)
|
||||||
api_router.include_router(resources_material_router)
|
api_router.include_router(resources_material_router)
|
||||||
api_router.include_router(contact_router)
|
api_router.include_router(contact_router)
|
||||||
|
api_router.include_router(team_router)
|
||||||
api_router.include_router(home_materials_router)
|
api_router.include_router(home_materials_router)
|
||||||
api_router.include_router(admin_module_router)
|
api_router.include_router(admin_module_router)
|
||||||
api_router.include_router(material_admin_router)
|
api_router.include_router(material_admin_router)
|
||||||
|
|||||||
@@ -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.username = "用户"+current_user.username[-4:] if current_user.username == current_user.phone else current_user.username
|
||||||
current_user.credits = round(current_user.credits, 2)
|
current_user.credits = round(current_user.credits, 2)
|
||||||
resource_capacity = await get_user_resource_capacity_usage(db, current_user.id)
|
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(
|
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,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
from app.models.base import Base, TimestampMixin, SoftDeleteMixin, engine, async_session, init_database, close_database
|
from app.models.base import Base, TimestampMixin, SoftDeleteMixin, engine, async_session, init_database, close_database
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.team import Team
|
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.project import Project
|
||||||
from app.models.generation_record import GenerationRecord
|
from app.models.generation_record import GenerationRecord
|
||||||
from app.models.credit_record import CreditRecord
|
from app.models.credit_record import CreditRecord
|
||||||
@@ -37,7 +39,7 @@ from app.models.private_portrait import PrivatePortraitProject, PrivatePortraitV
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"Base", "TimestampMixin", "SoftDeleteMixin", "engine", "async_session",
|
"Base", "TimestampMixin", "SoftDeleteMixin", "engine", "async_session",
|
||||||
"init_database", "close_database",
|
"init_database", "close_database",
|
||||||
"User", "Team", "Project", "GenerationRecord", "CreditRecord",
|
"User", "Team", "TeamInvitation", "TeamJoinRequest", "Project", "GenerationRecord", "CreditRecord",
|
||||||
"ModelConfig", "SystemConfig", "Notification", "PaymentOrder",
|
"ModelConfig", "SystemConfig", "Notification", "PaymentOrder",
|
||||||
"TokenUsage", "IndustryConfig", "VideoEngine", "CreditRatio",
|
"TokenUsage", "IndustryConfig", "VideoEngine", "CreditRatio",
|
||||||
"MenuConfig", "RechargePackage", "OperationLog", "ContactRequest",
|
"MenuConfig", "RechargePackage", "OperationLog", "ContactRequest",
|
||||||
|
|||||||
@@ -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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.enums.team import TeamStatus
|
from app.enums.team import TeamStatus
|
||||||
@@ -32,3 +32,6 @@ class Team(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
index=True,
|
index=True,
|
||||||
comment="排序值,越小越靠前",
|
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)
|
||||||
@@ -29,6 +29,8 @@ class TeamOut(TeamBase):
|
|||||||
member_count: int = 0
|
member_count: int = 0
|
||||||
created_at: NaiveDatetime
|
created_at: NaiveDatetime
|
||||||
updated_at: NaiveDatetimeOptional = None
|
updated_at: NaiveDatetimeOptional = None
|
||||||
|
manager_id: str | None = None
|
||||||
|
manager_name: str | None = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
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
|
||||||
@@ -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}
|
||||||
@@ -16,5 +16,8 @@ class UserOut(BaseModel):
|
|||||||
must_set_password: bool = False
|
must_set_password: bool = False
|
||||||
resource_capacity: ResourceCapacityUsageOut | None = None
|
resource_capacity: ResourceCapacityUsageOut | None = None
|
||||||
private_portrait_image_limit: int = 5
|
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}
|
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()}",
|
||||||
|
)
|
||||||
@@ -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 {
|
return {
|
||||||
"id": team.id,
|
"id": team.id,
|
||||||
"name": team.name,
|
"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),
|
"member_count": int(member_count or 0),
|
||||||
"created_at": team.created_at,
|
"created_at": team.created_at,
|
||||||
"updated_at": team.updated_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)
|
.group_by(User.team_id)
|
||||||
)
|
)
|
||||||
member_map = {row[0]: int(row[1] or 0) for row in member_result.all()}
|
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 {
|
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,
|
"total": total,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,6 +183,7 @@ async def create_team(db: AsyncSession, req: TeamCreate) -> Team:
|
|||||||
)
|
)
|
||||||
db.add(team)
|
db.add(team)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
await db.refresh(team)
|
||||||
return 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.status = req.status or TeamStatus.ACTIVE.value
|
||||||
team.sort_order = req.sort_order or 0
|
team.sort_order = req.sort_order or 0
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
await db.refresh(team)
|
||||||
|
|
||||||
after = {
|
after = {
|
||||||
"id": team.id,
|
"id": team.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>
|
||||||
<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">
|
<link rel="stylesheet" crossorigin href="/assets/index-D9_3MPsN.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import ConsumePage from './pages/ConsumePage';
|
|||||||
import AuthorizationWaitingPage from './pages/AuthorizationWaitingPage';
|
import AuthorizationWaitingPage from './pages/AuthorizationWaitingPage';
|
||||||
import PopularPage from './pages/PopularPage';
|
import PopularPage from './pages/PopularPage';
|
||||||
import CreativePlazaPage from './pages/CreativePlazaPage';
|
import CreativePlazaPage from './pages/CreativePlazaPage';
|
||||||
|
import TeamManagementPage from './pages/TeamManagementPage';
|
||||||
|
import JoinTeamPage from './pages/JoinTeamPage';
|
||||||
import { useAuthStore } from './store/useAuthStore';
|
import { useAuthStore } from './store/useAuthStore';
|
||||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||||
const { user, loading, checkAuth } = useAuthStore();
|
const { user, loading, checkAuth } = useAuthStore();
|
||||||
@@ -124,6 +126,8 @@ const App = () => {
|
|||||||
<Route path="popular" element={<PopularPage />} />
|
<Route path="popular" element={<PopularPage />} />
|
||||||
<Route path="authacc" element={<AuthAccountPage />} />
|
<Route path="authacc" element={<AuthAccountPage />} />
|
||||||
<Route path="creativeplaza" element={<CreativePlazaPage />} />
|
<Route path="creativeplaza" element={<CreativePlazaPage />} />
|
||||||
|
<Route path="team-management" element={<TeamManagementPage />} />
|
||||||
|
<Route path="join-team" element={<JoinTeamPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/projects" replace />} />
|
<Route path="*" element={<Navigate to="/projects" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -805,3 +805,47 @@ export async function getPrivatePortraitSelectableAssets(params: { projectId?: s
|
|||||||
if (params.keyword) query.set('keyword', params.keyword);
|
if (params.keyword) query.set('keyword', params.keyword);
|
||||||
return api.get<PrivatePortraitSelectableAssetListOut>(`/private-portrait/selectable-assets?${query.toString()}`);
|
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' }}>
|
<div style={{ flex: 1, padding: '8px 8px', overflow: 'auto' }}>
|
||||||
{topLevelItems.map(item => renderMenuItem(item))}
|
{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>
|
||||||
|
|
||||||
<div onClick={() => setRechargeModalOpen(true)} style={{
|
<div onClick={() => setRechargeModalOpen(true)} style={{
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -22,6 +22,61 @@ export interface User {
|
|||||||
allowedMenus?: string[] | null;
|
allowedMenus?: string[] | null;
|
||||||
mustSetPassword?: boolean;
|
mustSetPassword?: boolean;
|
||||||
resourceCapacity?: ResourceCapacity;
|
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 {
|
export interface CreditRecord {
|
||||||
|
|||||||
Reference in New Issue
Block a user