Merge branch 'main' of https://gitee.com/wg123/video-gen
This commit is contained in:
@@ -32,6 +32,8 @@ import {
|
||||
} from '../api';
|
||||
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { PrivatePortraitAssetPicker } from '../components/privatePortrait';
|
||||
import type { PrivatePortraitSelectableAsset } from '../types';
|
||||
|
||||
import {
|
||||
PlusOutlined,
|
||||
@@ -73,6 +75,9 @@ interface MediaReference {
|
||||
duration?: number;
|
||||
role?: string;
|
||||
label?: string;
|
||||
source?: string;
|
||||
private_asset_id?: string;
|
||||
remote_asset_id?: string;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
@@ -169,6 +174,7 @@ const AIChatPage: React.FC = () => {
|
||||
const [lastFrame, setLastFrame] = useState<MediaReference | null>(null);
|
||||
const [uploadTarget, setUploadTarget] = useState<'first' | 'last' | null>(null);
|
||||
const [referenceModeDropdownVisible, setReferenceModeDropdownVisible] = useState(false);
|
||||
const [privateAssetPickerOpen, setPrivateAssetPickerOpen] = useState(false);
|
||||
const [mediaStackHovered, setMediaStackHovered] = useState(false);
|
||||
const mediaStackCloseTimerRef = useRef<number | null>(null);
|
||||
const openMediaStackTray = useCallback(() => {
|
||||
@@ -1413,6 +1419,41 @@ const AIChatPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrivatePortraitAssetsSelected = (assets: PrivatePortraitSelectableAsset[]) => {
|
||||
if (mediaType !== 'video') {
|
||||
message.warning('真人素材库第一版仅支持视频创作参考');
|
||||
return;
|
||||
}
|
||||
const imageCount = currentMedia.filter((m) => m.type === 'image').length;
|
||||
const available = Math.max(0, maxImage - imageCount);
|
||||
if (assets.length > available) {
|
||||
message.warning(`当前引擎最多还能添加 ${available} 张图片参考`);
|
||||
return;
|
||||
}
|
||||
const added: MediaReference[] = assets.map((asset) => ({
|
||||
name: asset.name || '真人素材',
|
||||
type: 'image',
|
||||
url: asset.previewUrl || '',
|
||||
source: 'private_portrait_asset',
|
||||
private_asset_id: asset.id,
|
||||
label: '',
|
||||
}));
|
||||
const newList = [...currentMedia, ...added];
|
||||
const labels = generateMediaLabels(newList);
|
||||
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
|
||||
message.success(`已添加 ${assets.length} 个真人素材参考`);
|
||||
};
|
||||
|
||||
const buildPreviewUrl = (url: string) => {
|
||||
if (!url) return '';
|
||||
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:') || url.startsWith('blob:')) {
|
||||
return url;
|
||||
}
|
||||
const base = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '');
|
||||
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||
};
|
||||
|
||||
|
||||
const handleRemoveMedia = (index: number) => {
|
||||
const newList = currentMedia.filter((_, i) => i !== index);
|
||||
const labels = generateMediaLabels(newList);
|
||||
@@ -2547,6 +2588,15 @@ const AIChatPage: React.FC = () => {
|
||||
</div>
|
||||
</UploadSelector>
|
||||
)}
|
||||
{mediaType === 'video' && currentMedia.length === 0 && (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setPrivateAssetPickerOpen(true)}
|
||||
style={{ marginTop: 8, borderRadius: 8, color: '#8b5cf6', borderColor: '#ddd6fe', background: '#fff' }}
|
||||
>
|
||||
真人素材库
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 层叠附件展示 - 鼠标移入向右排列展开 */}
|
||||
{currentMedia.length > 0 && (
|
||||
@@ -2572,7 +2622,7 @@ const AIChatPage: React.FC = () => {
|
||||
<div style={{ position: 'relative', width: 52, height: 60 }}>
|
||||
{media.type === 'image' ? (
|
||||
<img
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
|
||||
src={buildPreviewUrl(media.url)}
|
||||
alt={media.name}
|
||||
onClick={() => {
|
||||
setAttachmentPreviewUrl(media.url);
|
||||
@@ -2584,7 +2634,7 @@ const AIChatPage: React.FC = () => {
|
||||
/>
|
||||
) : media.type === 'video' ? (
|
||||
<video
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
|
||||
src={buildPreviewUrl(media.url)}
|
||||
muted
|
||||
onClick={() => {
|
||||
setAttachmentPreviewUrl(media.url);
|
||||
@@ -2602,7 +2652,7 @@ const AIChatPage: React.FC = () => {
|
||||
}}
|
||||
style={{ width: 52, height: 60, objectFit: 'cover', borderRadius: 10, cursor: 'pointer', border: '1px solid rgba(255,255,255,0.98)', boxShadow: '0 4px 12px rgba(31,41,55,0.15)', background: 'linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
>
|
||||
{playingAudioUrl === (media.url.startsWith('http') ? media.url : `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`) ? (
|
||||
{playingAudioUrl === (buildPreviewUrl(media.url)) ? (
|
||||
<PauseOutlined style={{ fontSize: 20, color: '#fff' }} />
|
||||
) : (
|
||||
<AudioOutlined style={{ fontSize: 20, color: '#fff' }} />
|
||||
@@ -2718,6 +2768,34 @@ const AIChatPage: React.FC = () => {
|
||||
</div>
|
||||
</UploadSelector>
|
||||
)}
|
||||
{mediaType === 'video' && currentMedia.length > 0 && (
|
||||
<Tooltip title="选择真人素材库">
|
||||
<div
|
||||
onClick={(e) => { e.stopPropagation(); setPrivateAssetPickerOpen(true); }}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: -32,
|
||||
bottom: 0,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 50,
|
||||
background: '#fff',
|
||||
border: '1px solid #ddd6fe',
|
||||
color: '#8b5cf6',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
fontWeight: 800,
|
||||
boxShadow: '0 2px 8px rgba(47, 52, 64, 0.08)',
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
真
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -4087,20 +4165,28 @@ const AIChatPage: React.FC = () => {
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
|
||||
{attachmentPreviewType === 'image' ? (
|
||||
<img
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${attachmentPreviewUrl}`}
|
||||
src={buildPreviewUrl(attachmentPreviewUrl)}
|
||||
alt="预览"
|
||||
style={{ width: '100%', maxHeight: '400px', objectFit: 'contain' }}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
ref={attachmentPreviewVideoRef}
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${attachmentPreviewUrl}`}
|
||||
src={buildPreviewUrl(attachmentPreviewUrl)}
|
||||
controls
|
||||
style={{ maxWidth: '100%', maxHeight: '400px' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
<PrivatePortraitAssetPicker
|
||||
open={privateAssetPickerOpen}
|
||||
onClose={() => setPrivateAssetPickerOpen(false)}
|
||||
onSelect={handlePrivatePortraitAssetsSelected}
|
||||
selectedIds={currentMedia.map((m) => m.private_asset_id).filter(Boolean) as string[]}
|
||||
maxCount={maxImage}
|
||||
/>
|
||||
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
PlayCircleOutlined,
|
||||
DeleteOutlined,
|
||||
LoadingOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { PrivatePortraitLibraryPanel } from '../components/privatePortrait';
|
||||
import { gethistory, gethistoryItems, getOAuthList, asyncBatchUploadMaterial, updateFilename, getUploadHistory, getAllOAuthAccountList, getOpenTypeAll, getPreTestList, getDefaultPreTest, deleteHistory, deleteResourcesMaterial } from '../api';
|
||||
|
||||
const { Search } = Input;
|
||||
@@ -48,7 +50,7 @@ const GeneratedRecord: React.FC = () => {
|
||||
setIsPageLoaded(true);
|
||||
});
|
||||
|
||||
const [filterType, setFilterType] = useState<'project' | 'creation' | 'hot_opening_replicate' | 'shot_replicate'>('project');
|
||||
const [filterType, setFilterType] = useState<'project' | 'creation' | 'hot_opening_replicate' | 'shot_replicate' | 'private_portrait'>('project');
|
||||
const [filterMedia, setFilterMedia] = useState<'video' | 'image'>('video');
|
||||
const [recordlist, setRecordList] = useState<any[]>([]);
|
||||
const [Pagebreak, setPagebreak] = useState<any>({
|
||||
@@ -754,6 +756,11 @@ const GeneratedRecord: React.FC = () => {
|
||||
});
|
||||
};
|
||||
const loadRecordList = () => {
|
||||
if (filterType === 'private_portrait') {
|
||||
setLoading(false);
|
||||
setRecordList([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
let historySource = '';
|
||||
if (filterType === 'project') {
|
||||
@@ -1003,8 +1010,29 @@ const GeneratedRecord: React.FC = () => {
|
||||
>
|
||||
拆镜复刻
|
||||
</Button>
|
||||
<Button
|
||||
type={filterType === 'private_portrait' ? 'primary' : 'default'}
|
||||
onClick={() => {
|
||||
setFilterType('private_portrait');
|
||||
setIsSelectionMode(false);
|
||||
setSelectedItems(new Set());
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: filterType === 'private_portrait'
|
||||
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
|
||||
: '#f8f9fc',
|
||||
border: filterType === 'private_portrait' ? 'none' : '1px solid #e2e8f0',
|
||||
color: filterType === 'private_portrait' ? '#fff' : '#64748b',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
icon={<UserOutlined />}
|
||||
>
|
||||
真人素材库
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
{filterType !== 'private_portrait' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
||||
{/* 多选模式按钮 */}
|
||||
{isSelectionMode ? (
|
||||
@@ -1085,7 +1113,12 @@ const GeneratedRecord: React.FC = () => {
|
||||
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{filterType === 'private_portrait' ? (
|
||||
<PrivatePortraitLibraryPanel />
|
||||
) : (
|
||||
<>
|
||||
{/* Second row filter: 视频 / 图片 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
@@ -1501,6 +1534,8 @@ const GeneratedRecord: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* 推送任务历史弹窗 */}
|
||||
<Modal
|
||||
title="推送任务历史"
|
||||
|
||||
@@ -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,619 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Button, Empty, Form, Input, InputNumber, message, Modal, Pagination, Radio, Select, Space, Table, Tabs, Tag, Tooltip, Typography,
|
||||
} from 'antd';
|
||||
import { DatePicker } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
CopyOutlined, DownloadOutlined, PlusOutlined, ReloadOutlined, UserOutlined, HistoryOutlined, WalletOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
import {
|
||||
createTeamInvitation, getJoinTeamInfo, getManagedTeam, getPendingJoinRequests,
|
||||
getTeamCreditExportUrl, getTeamCreditRecords, getTeamInvitations, getTeamMembers, handleJoinRequest, revokeInvitation, submitJoinRequest, transferCredits,
|
||||
} from '../api';
|
||||
import type { ManagedTeam, TeamInvitation, TeamJoinRequest, TeamMember } from '../types';
|
||||
import { useAuthStore } from '../store/useAuthStore';
|
||||
|
||||
/* ── 工具函数 ────────────────────────────────────────── */
|
||||
function formatDateTime(value: any): string {
|
||||
if (!value) return '-';
|
||||
try {
|
||||
return new Date(value).toLocaleString('zh-CN', { hour12: false });
|
||||
} catch {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
const RECORD_TYPE_CONFIG: Record<string, { color: string; label: string }> = {
|
||||
recharge: { color: 'green', label: '充值' },
|
||||
consume: { color: 'red', label: '消费' },
|
||||
refund: { color: 'orange', label: '退款' },
|
||||
team_internal: { color: 'blue', label: '团队内部' },
|
||||
};
|
||||
|
||||
/* ── 主组件 ──────────────────────────────────────────── */
|
||||
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();
|
||||
// 二次校验:确保是正数
|
||||
const amount = Number(values.amount);
|
||||
if (!amount || amount <= 0 || amount > 9999999) {
|
||||
message.error('请输入有效的正数积分数量');
|
||||
return;
|
||||
}
|
||||
setCreditSaving(true);
|
||||
await transferCredits(
|
||||
creditModal.member.id,
|
||||
amount,
|
||||
values.direction || 'increase',
|
||||
values.description,
|
||||
);
|
||||
message.success(values.direction === 'decrease' ? '积分扣减成功' : '积分增加成功');
|
||||
setCreditModal({ open: false, member: null });
|
||||
creditForm.resetFields();
|
||||
loadMembers();
|
||||
loadCreditRecords();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error(e?.message || '操作失败');
|
||||
} 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 || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
// ── Tab 4: 团队积分变动 ──
|
||||
const [creditRecords, setCreditRecords] = useState<any[]>([]);
|
||||
const [creditTotal, setCreditTotal] = useState(0);
|
||||
const [creditSummary, setCreditSummary] = useState<any>(null);
|
||||
const [creditLoading, setCreditLoading] = useState(false);
|
||||
const [creditPage, setCreditPage] = useState(1);
|
||||
const [creditFilterType, setCreditFilterType] = useState<string>('');
|
||||
const [creditFilterPhone, setCreditFilterPhone] = useState<string>('');
|
||||
const [creditDateRange, setCreditDateRange] = useState<[string, string] | null>(() => {
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
return [today, today];
|
||||
});
|
||||
|
||||
const loadCreditRecords = useCallback(async () => {
|
||||
setCreditLoading(true);
|
||||
try {
|
||||
const res = await getTeamCreditRecords({
|
||||
page: creditPage,
|
||||
pageSize: 10,
|
||||
phone: creditFilterPhone || undefined,
|
||||
recordType: creditFilterType || undefined,
|
||||
startDate: creditDateRange?.[0] || undefined,
|
||||
endDate: creditDateRange?.[1] || undefined,
|
||||
});
|
||||
setCreditRecords(res.items || []);
|
||||
setCreditTotal(res.total || 0);
|
||||
setCreditSummary(res.summary || null);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载积分记录失败');
|
||||
} finally {
|
||||
setCreditLoading(false);
|
||||
}
|
||||
}, [creditPage, creditFilterType, creditFilterPhone, creditDateRange]);
|
||||
|
||||
useEffect(() => { loadCreditRecords(); }, [loadCreditRecords]);
|
||||
|
||||
const resetCreditFilters = () => {
|
||||
setCreditFilterType('');
|
||||
setCreditFilterPhone('');
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
setCreditDateRange([today, today]);
|
||||
setCreditPage(1);
|
||||
};
|
||||
|
||||
const handleExportCredits = () => {
|
||||
const url = getTeamCreditExportUrl({
|
||||
phone: creditFilterPhone || undefined,
|
||||
recordType: creditFilterType || undefined,
|
||||
startDate: creditDateRange?.[0] || undefined,
|
||||
endDate: creditDateRange?.[1] || undefined,
|
||||
});
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
|
||||
fetch(url, { headers })
|
||||
.then((res) => res.blob())
|
||||
.then((blob) => {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `团队积分_${dayjs().format('YYYYMMDD_HHmmss')}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
})
|
||||
.catch(() => message.error('导出失败'));
|
||||
};
|
||||
|
||||
/* ── 表格列定义 ──────────────────────────────────────── */
|
||||
const memberColumns = [
|
||||
{ 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> },
|
||||
{ title: '加入时间', dataIndex: 'joinedAt', width: 170, render: (v: string) => formatDateTime(v) },
|
||||
{
|
||||
title: '操作', key: 'action', width: 100,
|
||||
render: (_: any, r: TeamMember) => {
|
||||
const currentUserId = useAuthStore.getState().user?.id;
|
||||
const isSelf = r.id === currentUserId;
|
||||
return isSelf ? (
|
||||
<Tooltip title="不能给自己调整积分">
|
||||
<Button size="small" type="link" style={{ padding: 0, color: '#999', cursor: 'not-allowed' }} disabled>调整积分</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button size="small" type="link" style={{ padding: 0 }} 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: 170, render: (v: string) => v ? formatDateTime(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: 140, render: (v: string) => <Typography.Text strong>{v}</Typography.Text> },
|
||||
{ title: '手机号', dataIndex: 'phone', width: 130, render: (v: string) => v || '-' },
|
||||
{ title: '申请时间', dataIndex: 'createdAt', width: 170, render: (v: string) => formatDateTime(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 creditColumns = [
|
||||
{ title: '用户名', dataIndex: 'username', width: 120, render: (v: string) => <Typography.Text strong>{v || '-'}</Typography.Text> },
|
||||
{ title: '手机号', dataIndex: 'phone', width: 120, render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '类型', dataIndex: 'type', width: 100,
|
||||
render: (_: any, r: any) => {
|
||||
const cfg = RECORD_TYPE_CONFIG[r.type] || { color: 'default', label: r.type || '-' };
|
||||
return <Tag color={cfg.color}>{cfg.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '变动积分', dataIndex: 'amount', width: 110, align: 'right' as const,
|
||||
render: (_: any, r: any) => (
|
||||
<Typography.Text strong style={{ color: r.amount >= 0 ? '#10b981' : '#ef4444', fontSize: 14 }}>
|
||||
{r.amount >= 0 ? '+' : ''}{(r.amount ?? 0).toFixed(2)}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{ title: '余额', dataIndex: 'balanceAfter', width: 100, align: 'right' as const, render: (v: number) => (v ?? 0).toFixed(2) },
|
||||
{ title: '说明', dataIndex: 'description', ellipsis: true, minWidth: 160, render: (v: string) => v || '-' },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 170, render: (v: string) => formatDateTime(v) },
|
||||
];
|
||||
|
||||
/* ── Tab 配置 ────────────────────────────────────────── */
|
||||
const tableWrapper: React.CSSProperties = { borderRadius: 16, background: '#fff', border: '1px solid #f0f0f5', overflow: 'hidden' };
|
||||
const paginationStyle: React.CSSProperties = { padding: '16px', textAlign: 'right' };
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'members',
|
||||
label: <Space><UserOutlined />成员列表{membersTotal > 0 && <span style={{ color: '#94a3b8', fontSize: 12 }}>({membersTotal})</span>}</Space>,
|
||||
children: (
|
||||
<div style={tableWrapper}>
|
||||
<Table
|
||||
columns={memberColumns}
|
||||
dataSource={members}
|
||||
rowKey="id"
|
||||
loading={membersLoading}
|
||||
pagination={false}
|
||||
bordered={false}
|
||||
scroll={{ x: 800 }}
|
||||
locale={{ emptyText: <Empty description="暂无成员" /> }}
|
||||
/>
|
||||
{membersTotal > 0 && (
|
||||
<div style={paginationStyle}>
|
||||
<Pagination current={membersPage} pageSize={20} total={membersTotal} onChange={(p) => setMembersPage(p)} size="small" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'credits',
|
||||
label: <Space><WalletOutlined />团队积分变动</Space>,
|
||||
children: (
|
||||
<div>
|
||||
{/* 搜索栏 */}
|
||||
<div style={{ marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<Select
|
||||
value={creditFilterType || undefined}
|
||||
onChange={(v) => { setCreditFilterType(v || ''); setCreditPage(1); }}
|
||||
allowClear
|
||||
placeholder="交易类型"
|
||||
style={{ width: 130 }}
|
||||
options={[
|
||||
{ value: 'recharge', label: '充值' },
|
||||
{ value: 'consume', label: '消费' },
|
||||
{ value: 'team_internal', label: '团队内部' },
|
||||
{ value: 'refund', label: '退款' },
|
||||
]}
|
||||
/>
|
||||
<Input
|
||||
placeholder="搜索手机号"
|
||||
value={creditFilterPhone}
|
||||
onChange={(e) => { setCreditFilterPhone(e.target.value); setCreditPage(1); }}
|
||||
style={{ width: 160 }}
|
||||
allowClear
|
||||
/>
|
||||
<RangePicker
|
||||
value={creditDateRange ? [dayjs(creditDateRange[0]), dayjs(creditDateRange[1])] : undefined}
|
||||
onChange={(dates) => {
|
||||
if (dates && dates[0] && dates[1]) {
|
||||
setCreditDateRange([dates[0].format('YYYY-MM-DD'), dates[1].format('YYYY-MM-DD')]);
|
||||
} else {
|
||||
setCreditDateRange(null);
|
||||
}
|
||||
setCreditPage(1);
|
||||
}}
|
||||
/>
|
||||
<Button onClick={resetCreditFilters}>重置</Button>
|
||||
<Button type="primary" icon={<DownloadOutlined />} onClick={handleExportCredits}>导出 Excel</Button>
|
||||
</div>
|
||||
|
||||
{/* 汇总统计 */}
|
||||
<div style={{ marginBottom: 12, padding: '8px 16px', background: '#f8f9fc', borderRadius: 8, display: 'flex', gap: 24, flexWrap: 'wrap', fontSize: 13 }}>
|
||||
<span>总消耗积分:<strong style={{ color: '#ef4444', fontSize: 15 }}>{creditSummary?.totalConsume ?? 0}</strong></span>
|
||||
</div>
|
||||
|
||||
<div style={tableWrapper}>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={creditLoading}
|
||||
dataSource={creditRecords}
|
||||
pagination={false}
|
||||
bordered={false}
|
||||
scroll={{ x: 950 }}
|
||||
columns={creditColumns}
|
||||
locale={{ emptyText: <Empty description="暂无积分记录" /> }}
|
||||
/>
|
||||
{creditTotal > 0 && (
|
||||
<div style={paginationStyle}>
|
||||
<Pagination current={creditPage} pageSize={10} total={creditTotal} onChange={(p) => setCreditPage(p)} size="small" showTotal={(t) => `共 ${t} 条`} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'invitations',
|
||||
label: <Space><CopyOutlined />邀请管理</Space>,
|
||||
children: (
|
||||
<div style={tableWrapper}>
|
||||
<div style={{ padding: 16, paddingBottom: 0 }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setInvModal(true)}>生成邀请码</Button>
|
||||
</div>
|
||||
<Table
|
||||
columns={invColumns}
|
||||
dataSource={invitations}
|
||||
rowKey="id"
|
||||
loading={invLoading}
|
||||
pagination={false}
|
||||
bordered={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: (
|
||||
<div style={tableWrapper}>
|
||||
<Table
|
||||
columns={reqColumns}
|
||||
dataSource={requests}
|
||||
rowKey="id"
|
||||
loading={reqLoading}
|
||||
pagination={false}
|
||||
bordered={false}
|
||||
scroll={{ x: 600 }}
|
||||
locale={{ emptyText: <Empty description="暂无待审批申请" /> }}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
/* ── 渲染 ────────────────────────────────────────────── */
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
{/* 顶部团队信息 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
{teamLoading ? (
|
||||
<Typography.Text type="secondary">加载中...</Typography.Text>
|
||||
) : 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(); loadCreditRecords(); }}>刷新</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Typography.Text type="secondary">无法获取团队信息</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 标签页 */}
|
||||
<Tabs items={tabItems} defaultActiveKey="members" size="large" />
|
||||
|
||||
{/* 调整积分弹窗 */}
|
||||
<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 }} initialValues={{ direction: 'increase' }}>
|
||||
{/* 显示管理人当前积分 */}
|
||||
<div style={{ marginBottom: 16, padding: '10px 16px', background: '#f0f4ff', borderRadius: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography.Text type="secondary">我的当前积分</Typography.Text>
|
||||
<Typography.Text strong style={{ fontSize: 20, color: '#6366f1' }}>
|
||||
{useAuthStore.getState().user?.credits?.toFixed(2) ?? '0.00'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Form.Item name="direction" label="操作类型" rules={[{ required: true, message: '请选择操作类型' }]}>
|
||||
<Radio.Group buttonStyle="solid" size="large" style={{ width: '100%' }}>
|
||||
<Radio.Button value="increase" style={{ width: '50%', textAlign: 'center' }}>增加成员积分</Radio.Button>
|
||||
<Radio.Button value="decrease" style={{ width: '50%', textAlign: 'center' }}>扣减成员积分</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="amount"
|
||||
label="积分数量"
|
||||
required
|
||||
rules={[
|
||||
{ required: true, message: '请输入积分数量' },
|
||||
{ type: 'number', min: 0.01, message: '必须大于 0' },
|
||||
{ type: 'number', max: 9999999, message: '单次不能超过 9999999' },
|
||||
]}
|
||||
validateTrigger={['onChange', 'onBlur']}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
step={1}
|
||||
min={0.01}
|
||||
max={9999999}
|
||||
precision={2}
|
||||
placeholder="请输入正数积分数量"
|
||||
size="large"
|
||||
formatter={(value) => {
|
||||
if (!value) return '';
|
||||
let str = `${value}`.replace(/[^0-9.]/g, '');
|
||||
str = str.replace(/^0+(?=\d)/, '');
|
||||
return str;
|
||||
}}
|
||||
parser={(str) => {
|
||||
if (!str || str === '.') return '' as any;
|
||||
let num = parseFloat(str);
|
||||
if (isNaN(num) || num <= 0) return '' as any;
|
||||
return Math.min(num, 9999999) as any;
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// 禁止输入负号、e、E
|
||||
if (e.key === '-' || e.key === 'e' || e.key === 'E') {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onChange={(val) => {
|
||||
if (val === null || val === undefined) {
|
||||
creditForm.validateFields(['amount']);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</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;
|
||||
Reference in New Issue
Block a user