merge main
This commit is contained in:
-505
File diff suppressed because one or more lines are too long
+505
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-CclRZKjR.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-DefYYdtQ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D9_3MPsN.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -32,6 +32,8 @@ import ConsumePage from './pages/ConsumePage';
|
||||
import AuthorizationWaitingPage from './pages/AuthorizationWaitingPage';
|
||||
import PopularPage from './pages/PopularPage';
|
||||
import CreativePlazaPage from './pages/CreativePlazaPage';
|
||||
import TeamManagementPage from './pages/TeamManagementPage';
|
||||
import JoinTeamPage from './pages/JoinTeamPage';
|
||||
import { useAuthStore } from './store/useAuthStore';
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const { user, loading, checkAuth } = useAuthStore();
|
||||
@@ -124,6 +126,8 @@ const App = () => {
|
||||
<Route path="popular" element={<PopularPage />} />
|
||||
<Route path="authacc" element={<AuthAccountPage />} />
|
||||
<Route path="creativeplaza" element={<CreativePlazaPage />} />
|
||||
<Route path="team-management" element={<TeamManagementPage />} />
|
||||
<Route path="join-team" element={<JoinTeamPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/projects" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -805,3 +805,47 @@ export async function getPrivatePortraitSelectableAssets(params: { projectId?: s
|
||||
if (params.keyword) query.set('keyword', params.keyword);
|
||||
return api.get<PrivatePortraitSelectableAssetListOut>(`/private-portrait/selectable-assets?${query.toString()}`);
|
||||
}
|
||||
|
||||
// ── Team Management APIs ──────────────────────────────
|
||||
export async function getManagedTeam(): Promise<any> {
|
||||
return api.get('/team/managed');
|
||||
}
|
||||
|
||||
export async function getTeamMembers(page = 1, pageSize = 20): Promise<any> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(page));
|
||||
params.set('page_size', String(pageSize));
|
||||
return api.get(`/team/members?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function transferCredits(memberId: string, amount: number, description?: string): Promise<void> {
|
||||
await api.post(`/team/members/${memberId}/credits`, { target_user_id: memberId, amount, description: description || null });
|
||||
}
|
||||
|
||||
export async function getTeamInvitations(): Promise<any[]> {
|
||||
return api.get('/team/invitations');
|
||||
}
|
||||
|
||||
export async function createTeamInvitation(maxUses?: number, expiresAt?: string): Promise<any> {
|
||||
return api.post('/team/invitations', { max_uses: maxUses || null, expires_at: expiresAt || null });
|
||||
}
|
||||
|
||||
export async function revokeInvitation(invitationId: string): Promise<void> {
|
||||
await api.delete(`/team/invitations/${invitationId}`);
|
||||
}
|
||||
|
||||
export async function getPendingJoinRequests(): Promise<any[]> {
|
||||
return api.get('/team/join-requests');
|
||||
}
|
||||
|
||||
export async function handleJoinRequest(requestId: string, action: 'approve' | 'reject', note?: string): Promise<void> {
|
||||
await api.post(`/team/join-requests/${requestId}`, { action, note: note || null });
|
||||
}
|
||||
|
||||
export async function getJoinTeamInfo(code: string): Promise<any> {
|
||||
return api.get(`/team/join-info?code=${encodeURIComponent(code)}`);
|
||||
}
|
||||
|
||||
export async function submitJoinRequest(code: string): Promise<void> {
|
||||
await api.post('/team/join', { invitation_code: code });
|
||||
}
|
||||
|
||||
@@ -837,6 +837,16 @@ const AppLayout: React.FC = () => {
|
||||
|
||||
<div style={{ flex: 1, padding: '8px 8px', overflow: 'auto' }}>
|
||||
{topLevelItems.map(item => renderMenuItem(item))}
|
||||
{user?.isTeamManager && renderMenuItem({
|
||||
id: 'team-management',
|
||||
key: 'team-management',
|
||||
label: '团队管理',
|
||||
path: '/team-management',
|
||||
icon: 'TeamOutlined',
|
||||
sortOrder: 999,
|
||||
isActive: true,
|
||||
menuType: 'page',
|
||||
} as any)}
|
||||
</div>
|
||||
|
||||
<div onClick={() => setRechargeModalOpen(true)} style={{
|
||||
|
||||
@@ -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;
|
||||
mustSetPassword?: boolean;
|
||||
resourceCapacity?: ResourceCapacity;
|
||||
teamId?: string | null;
|
||||
teamName?: string | null;
|
||||
isTeamManager?: boolean;
|
||||
}
|
||||
|
||||
// ── Team Management Types ─────────────────────────────
|
||||
|
||||
export interface TeamMember {
|
||||
id: string;
|
||||
username: string;
|
||||
phone?: string | null;
|
||||
credits: number;
|
||||
isActive: boolean;
|
||||
joinedAt: string;
|
||||
}
|
||||
|
||||
export interface TeamInvitation {
|
||||
id: string;
|
||||
code: string;
|
||||
status: string;
|
||||
maxUses?: number | null;
|
||||
useCount: number;
|
||||
expiresAt?: string | null;
|
||||
inviteLink: string;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
|
||||
export interface TeamJoinRequest {
|
||||
id: string;
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
phone?: string | null;
|
||||
status: string;
|
||||
note?: string | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
|
||||
export interface ManagedTeam {
|
||||
id: string;
|
||||
name: string;
|
||||
code?: string | null;
|
||||
description?: string | null;
|
||||
status: string;
|
||||
memberCount: number;
|
||||
managerId?: string | null;
|
||||
managerName?: string | null;
|
||||
}
|
||||
|
||||
export interface JoinTeamInfo {
|
||||
teamName: string;
|
||||
teamId: string;
|
||||
valid: boolean;
|
||||
alreadyInTeam: boolean;
|
||||
}
|
||||
|
||||
export interface CreditRecord {
|
||||
|
||||
Reference in New Issue
Block a user