Merge branch 'main' of https://gitee.com/wg123/video-gen
This commit is contained in:
@@ -34,6 +34,8 @@ import PopularPage from './pages/PopularPage';
|
||||
import CreativePlazaPage from './pages/CreativePlazaPage';
|
||||
import TeamManagementPage from './pages/TeamManagementPage';
|
||||
import JoinTeamPage from './pages/JoinTeamPage';
|
||||
import PrivatePortraitAuthorizeResult from './pages/PrivatePortraitAuthorizeResult';
|
||||
import PrivatePortraitVirtualMaterialPage from './pages/PrivatePortraitVirtualMaterialPage';
|
||||
import { useAuthStore } from './store/useAuthStore';
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const { user, loading, checkAuth } = useAuthStore();
|
||||
@@ -92,6 +94,7 @@ const App = () => {
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/join-team" element={<JoinTeamPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
@@ -122,12 +125,12 @@ const App = () => {
|
||||
<Route path="authorization" element={<AuthorizationPage />} />
|
||||
<Route path="authoriza-waiting" element={<AuthorizationWaitingPage />} />
|
||||
<Route path="materials" element={<MaterialListPage />} />
|
||||
<Route path="materials/private-portrait-virtual" element={<PrivatePortraitVirtualMaterialPage />} />
|
||||
<Route path="consume" element={<ConsumePage />} />
|
||||
<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>
|
||||
|
||||
@@ -55,7 +55,7 @@ async function tryDecrypt(data: string): Promise<string | null> {
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { method = 'GET', body, auth = true, encryptBody = USE_ENCRYPTION, signal } = options;
|
||||
const { method = 'GET', body, auth = true, encryptBody = USE_ENCRYPTION, signal, skipAuthRedirect } = options;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
User, CreditRecord, Project, GenerationRecord, OptimizeParams, GenerateParams, OptimizeResult,
|
||||
Industry, IndustryConfig, AdminUser, AdminStats, ModelConfig, SystemConfig, AdminNotification,
|
||||
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitProject, PrivatePortraitValidateSession,
|
||||
PrivatePortraitAssetListOut, PrivatePortraitAsset, PrivatePortraitSelectableAssetListOut,
|
||||
PrivatePortraitProjectCreateWithValidateOut, PrivatePortraitAssetListOut, PrivatePortraitAsset, PrivatePortraitSelectableAssetListOut,
|
||||
} from '../types';
|
||||
const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true';
|
||||
// ── Auth ──────────────────────────────────────────────────
|
||||
@@ -756,8 +756,12 @@ export async function getPrivatePortraitProjects(params: { page?: number; pageSi
|
||||
return api.get<PrivatePortraitProjectListOut>(`/private-portrait/projects?${query.toString()}`);
|
||||
}
|
||||
|
||||
export async function createPrivatePortraitProject(payload: { name: string; description?: string | null }): Promise<PrivatePortraitProject> {
|
||||
return api.post<PrivatePortraitProject>('/private-portrait/projects', payload);
|
||||
export async function createPrivatePortraitProject(payload: { name: string; description?: string | null; callbackRedirectUrl?: string | null }): Promise<PrivatePortraitProjectCreateWithValidateOut> {
|
||||
return api.post<PrivatePortraitProjectCreateWithValidateOut>('/private-portrait/projects', {
|
||||
name: payload.name,
|
||||
description: payload.description || null,
|
||||
callback_redirect_url: payload.callbackRedirectUrl || null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updatePrivatePortraitProject(projectId: string, payload: { name?: string; description?: string | null; status?: string }): Promise<PrivatePortraitProject> {
|
||||
@@ -776,16 +780,25 @@ export async function getPrivatePortraitValidateSession(sessionId: string): Prom
|
||||
return api.get<PrivatePortraitValidateSession>(`/private-portrait/validate-sessions/${sessionId}`);
|
||||
}
|
||||
|
||||
export async function createPrivatePortraitAsset(projectId: string, payload: { url: string; assetType?: string; name?: string | null }): Promise<PrivatePortraitAsset> {
|
||||
return api.post<PrivatePortraitAsset>(`/private-portrait/projects/${projectId}/assets`, { url: payload.url, asset_type: payload.assetType || 'Image', name: payload.name || null });
|
||||
export async function createPrivatePortraitAsset(projectId: string, payload: { url: string; assetType?: string; name?: string | null; videoDuration?: number | null; videoCoverUrl?: string | null; fileSize?: number | null; mimeType?: string | null }): Promise<PrivatePortraitAsset> {
|
||||
return api.post<PrivatePortraitAsset>(`/private-portrait/projects/${projectId}/assets`, {
|
||||
url: payload.url,
|
||||
asset_type: payload.assetType || 'Image',
|
||||
name: payload.name || null,
|
||||
video_duration: payload.videoDuration ?? null,
|
||||
video_cover_url: payload.videoCoverUrl || null,
|
||||
file_size: payload.fileSize ?? null,
|
||||
mime_type: payload.mimeType || null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPrivatePortraitAssets(projectId: string, params: { page?: number; pageSize?: number; status?: string; keyword?: string } = {}): Promise<PrivatePortraitAssetListOut> {
|
||||
export async function getPrivatePortraitAssets(projectId: string, params: { page?: number; pageSize?: number; status?: string; keyword?: string; assetType?: string } = {}): Promise<PrivatePortraitAssetListOut> {
|
||||
const query = new URLSearchParams();
|
||||
query.set('page', String(params.page || 1));
|
||||
query.set('page_size', String(params.pageSize || 20));
|
||||
if (params.status) query.set('status', params.status);
|
||||
if (params.keyword) query.set('keyword', params.keyword);
|
||||
if (params.assetType) query.set('asset_type', params.assetType);
|
||||
return api.get<PrivatePortraitAssetListOut>(`/private-portrait/projects/${projectId}/assets?${query.toString()}`);
|
||||
}
|
||||
|
||||
@@ -797,15 +810,90 @@ export async function deletePrivatePortraitAsset(assetId: string): Promise<void>
|
||||
await api.delete(`/private-portrait/assets/${assetId}`);
|
||||
}
|
||||
|
||||
export async function getPrivatePortraitSelectableAssets(params: { projectId?: string; keyword?: string; page?: number; pageSize?: number } = {}): Promise<PrivatePortraitSelectableAssetListOut> {
|
||||
export async function getPrivatePortraitSelectableAssets(params: { projectId?: string; keyword?: string; page?: number; pageSize?: number; assetType?: string } = {}): Promise<PrivatePortraitSelectableAssetListOut> {
|
||||
const query = new URLSearchParams();
|
||||
query.set('page', String(params.page || 1));
|
||||
query.set('page_size', String(params.pageSize || 20));
|
||||
if (params.projectId) query.set('project_id', params.projectId);
|
||||
if (params.keyword) query.set('keyword', params.keyword);
|
||||
if (params.assetType) query.set('asset_type', params.assetType);
|
||||
return api.get<PrivatePortraitSelectableAssetListOut>(`/private-portrait/selectable-assets?${query.toString()}`);
|
||||
}
|
||||
|
||||
|
||||
// ── Private Portrait Virtual Library ─────────────────────
|
||||
export async function getPrivatePortraitVirtualConfig(): Promise<PrivatePortraitConfig> {
|
||||
return api.get<PrivatePortraitConfig>('/private-portrait/virtual/config');
|
||||
}
|
||||
|
||||
export async function getPrivatePortraitVirtualProjects(params: { page?: number; pageSize?: number; keyword?: string; status?: string } = {}): Promise<PrivatePortraitProjectListOut> {
|
||||
const query = new URLSearchParams();
|
||||
query.set('page', String(params.page || 1));
|
||||
query.set('page_size', String(params.pageSize || 20));
|
||||
if (params.keyword) query.set('keyword', params.keyword);
|
||||
if (params.status) query.set('status', params.status);
|
||||
return api.get<PrivatePortraitProjectListOut>(`/private-portrait/virtual-projects?${query.toString()}`);
|
||||
}
|
||||
|
||||
export async function createPrivatePortraitVirtualProject(payload: { name: string; description?: string | null }): Promise<PrivatePortraitProject> {
|
||||
return api.post<PrivatePortraitProject>('/private-portrait/virtual-projects', {
|
||||
name: payload.name,
|
||||
description: payload.description || null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updatePrivatePortraitVirtualProject(projectId: string, payload: { name?: string; description?: string | null; status?: string }): Promise<PrivatePortraitProject> {
|
||||
return api.put<PrivatePortraitProject>(`/private-portrait/virtual-projects/${projectId}`, payload);
|
||||
}
|
||||
|
||||
export async function deletePrivatePortraitVirtualProject(projectId: string): Promise<void> {
|
||||
await api.delete(`/private-portrait/virtual-projects/${projectId}`);
|
||||
}
|
||||
|
||||
export async function createPrivatePortraitVirtualAsset(projectId: string, payload: { url: string; assetType?: string; name?: string | null; videoDuration?: number | null; videoCoverUrl?: string | null; fileSize?: number | null; mimeType?: string | null }): Promise<PrivatePortraitAsset> {
|
||||
return api.post<PrivatePortraitAsset>(`/private-portrait/virtual-projects/${projectId}/assets`, {
|
||||
url: payload.url,
|
||||
asset_type: payload.assetType || 'Image',
|
||||
name: payload.name || null,
|
||||
video_duration: payload.videoDuration ?? null,
|
||||
video_cover_url: payload.videoCoverUrl || null,
|
||||
file_size: payload.fileSize ?? null,
|
||||
mime_type: payload.mimeType || null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPrivatePortraitVirtualAssets(projectId: string, params: { page?: number; pageSize?: number; status?: string; keyword?: string; assetType?: string } = {}): Promise<PrivatePortraitAssetListOut> {
|
||||
const query = new URLSearchParams();
|
||||
query.set('page', String(params.page || 1));
|
||||
query.set('page_size', String(params.pageSize || 20));
|
||||
if (params.status) query.set('status', params.status);
|
||||
if (params.keyword) query.set('keyword', params.keyword);
|
||||
if (params.assetType) query.set('asset_type', params.assetType);
|
||||
return api.get<PrivatePortraitAssetListOut>(`/private-portrait/virtual-projects/${projectId}/assets?${query.toString()}`);
|
||||
}
|
||||
|
||||
export async function getPrivatePortraitVirtualAsset(assetId: string): Promise<PrivatePortraitAsset> {
|
||||
return api.get<PrivatePortraitAsset>(`/private-portrait/virtual-assets/${assetId}`);
|
||||
}
|
||||
|
||||
export async function syncPrivatePortraitVirtualAsset(assetId: string): Promise<PrivatePortraitAsset> {
|
||||
return api.post<PrivatePortraitAsset>(`/private-portrait/virtual-assets/${assetId}/sync`);
|
||||
}
|
||||
|
||||
export async function deletePrivatePortraitVirtualAsset(assetId: string): Promise<void> {
|
||||
await api.delete(`/private-portrait/virtual-assets/${assetId}`);
|
||||
}
|
||||
|
||||
export async function getPrivatePortraitVirtualSelectableAssets(params: { projectId?: string; keyword?: string; page?: number; pageSize?: number; assetType?: string } = {}): Promise<PrivatePortraitSelectableAssetListOut> {
|
||||
const query = new URLSearchParams();
|
||||
query.set('page', String(params.page || 1));
|
||||
query.set('page_size', String(params.pageSize || 20));
|
||||
if (params.projectId) query.set('project_id', params.projectId);
|
||||
if (params.keyword) query.set('keyword', params.keyword);
|
||||
if (params.assetType) query.set('asset_type', params.assetType);
|
||||
return api.get<PrivatePortraitSelectableAssetListOut>(`/private-portrait/virtual-selectable-assets?${query.toString()}`);
|
||||
}
|
||||
|
||||
// ── Team Management APIs ──────────────────────────────
|
||||
export async function getManagedTeam(): Promise<any> {
|
||||
return api.get('/team/managed');
|
||||
@@ -843,7 +931,11 @@ export async function handleJoinRequest(requestId: string, action: 'approve' | '
|
||||
}
|
||||
|
||||
export async function getJoinTeamInfo(code: string): Promise<any> {
|
||||
return api.get(`/team/join-info?code=${encodeURIComponent(code)}`);
|
||||
return api.get(`/team/join-info?code=${encodeURIComponent(code)}`, { auth: true, skipAuthRedirect: true });
|
||||
}
|
||||
|
||||
export async function getJoinTeamInfoPublic(code: string): Promise<any> {
|
||||
return api.get(`/team/join-info/public?code=${encodeURIComponent(code)}`, false);
|
||||
}
|
||||
|
||||
export async function submitJoinRequest(code: string): Promise<void> {
|
||||
|
||||
@@ -1,22 +1,47 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Card, Col, Form, Input, Modal, Row, Space, Typography, message } from 'antd';
|
||||
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import type { PrivatePortraitProject } from '../../../types';
|
||||
import { createPrivatePortraitProject, getPrivatePortraitProjects } from '../../../api';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Button, Card, Col, Form, Input, Modal, QRCode, Row, Space, Spin, Typography, message } from 'antd';
|
||||
import { CheckCircleOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import type { PrivatePortraitProject, PrivatePortraitValidateSession } from '../../../types';
|
||||
import { createPrivatePortraitProject, getPrivatePortraitProjects, getPrivatePortraitValidateSession } from '../../../api';
|
||||
import PrivatePortraitProjectList from './ProjectList';
|
||||
import PrivatePortraitProjectDetail from './ProjectDetail';
|
||||
|
||||
const VALIDATE_SUCCESS_STATUS = 'group_active';
|
||||
const POLL_INTERVAL_FALLBACK = 2000;
|
||||
|
||||
const PrivatePortraitLibraryPanel: React.FC = () => {
|
||||
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
|
||||
const [selected, setSelected] = useState<PrivatePortraitProject | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createdProject, setCreatedProject] = useState<PrivatePortraitProject | null>(null);
|
||||
const [validateSession, setValidateSession] = useState<PrivatePortraitValidateSession | null>(null);
|
||||
const [polling, setPolling] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
const clearPollTimer = () => {
|
||||
if (timerRef.current) {
|
||||
window.clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const resetCreateModal = () => {
|
||||
clearPollTimer();
|
||||
setCreateOpen(false);
|
||||
setCreating(false);
|
||||
setPolling(false);
|
||||
setCreatedProject(null);
|
||||
setValidateSession(null);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
const loadProjects = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getPrivatePortraitProjects({ pageSize: 100 });
|
||||
const res = await getPrivatePortraitProjects({ pageSize: 100, status: 'active' });
|
||||
setProjects(res.items);
|
||||
setSelected((prev) => prev ? (res.items.find((item) => item.id === prev.id) || res.items[0] || null) : (res.items[0] || null));
|
||||
} catch (e: any) {
|
||||
@@ -27,27 +52,75 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
|
||||
};
|
||||
|
||||
useEffect(() => { loadProjects(); }, []);
|
||||
useEffect(() => () => clearPollTimer(), []);
|
||||
|
||||
const finishCreateSuccess = async (projectId: string) => {
|
||||
clearPollTimer();
|
||||
setPolling(false);
|
||||
message.success('真人认证完成,项目组已创建成功');
|
||||
setCreateOpen(false);
|
||||
setValidateSession(null);
|
||||
setCreatedProject(null);
|
||||
form.resetFields();
|
||||
const res = await getPrivatePortraitProjects({ pageSize: 100, status: 'active' });
|
||||
setProjects(res.items);
|
||||
setSelected(res.items.find((item) => item.id === projectId) || res.items[0] || null);
|
||||
};
|
||||
|
||||
const startPolling = (sessionId: string, projectId: string, intervalMs: number) => {
|
||||
clearPollTimer();
|
||||
setPolling(true);
|
||||
const run = async () => {
|
||||
try {
|
||||
const next = await getPrivatePortraitValidateSession(sessionId);
|
||||
setValidateSession(next);
|
||||
if (next.status === VALIDATE_SUCCESS_STATUS) {
|
||||
await finishCreateSuccess(projectId);
|
||||
return;
|
||||
}
|
||||
if (['callback_failed', 'failed', 'expired'].includes(next.status)) {
|
||||
clearPollTimer();
|
||||
setPolling(false);
|
||||
message.error(next.errorMessage || '真人认证未完成,请重新创建项目组');
|
||||
}
|
||||
} catch (e: any) {
|
||||
clearPollTimer();
|
||||
setPolling(false);
|
||||
message.error(e?.message || '轮询真人认证状态失败');
|
||||
}
|
||||
};
|
||||
timerRef.current = window.setInterval(run, Math.max(1000, intervalMs || POLL_INTERVAL_FALLBACK));
|
||||
void run();
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
const values = await form.validateFields();
|
||||
setCreating(true);
|
||||
try {
|
||||
const project = await createPrivatePortraitProject(values);
|
||||
message.success('项目组已创建');
|
||||
setCreateOpen(false);
|
||||
form.resetFields();
|
||||
await loadProjects();
|
||||
setSelected(project);
|
||||
const callbackRedirectUrl = `${window.location.origin}/private-portrait-authorized`;
|
||||
const res = await createPrivatePortraitProject({ ...values, callbackRedirectUrl });
|
||||
setCreatedProject(res.project);
|
||||
setValidateSession(res.validateSession);
|
||||
message.success('请使用手机扫码完成人脸认证');
|
||||
if (res.validateSession?.id) {
|
||||
startPolling(res.validateSession.id, res.project.id, res.pollIntervalMs || POLL_INTERVAL_FALLBACK);
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '创建项目组失败');
|
||||
message.error(e?.message || '创建项目组认证二维码失败');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const h5Link = validateSession?.h5Link || '';
|
||||
const isSuccess = validateSession?.status === VALIDATE_SUCCESS_STATUS;
|
||||
|
||||
return (
|
||||
<div style={{ padding: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>真人素材库</Typography.Title>
|
||||
<Typography.Text type="secondary">管理真人授权项目组和已入库 Active 素材,AI 创作添加参考内容时可直接选择。</Typography.Text>
|
||||
<Typography.Text type="secondary">创建项目组时先完成真人认证,认证成功后项目组才会正式创建并可上传素材。</Typography.Text>
|
||||
</div>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={loadProjects} loading={loading}>刷新</Button>
|
||||
@@ -68,15 +141,46 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
<Modal title="新建真人素材项目组" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={handleCreate} okText="创建">
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item label="项目组名称" name="name" rules={[{ required: true, message: '请输入项目组名称' }]}>
|
||||
<Input placeholder="例如:达人A、客户B、张三人像" />
|
||||
</Form.Item>
|
||||
<Form.Item label="描述" name="description">
|
||||
<Input.TextArea rows={3} placeholder="可选" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Modal
|
||||
title="新建真人素材项目组"
|
||||
open={createOpen}
|
||||
onCancel={resetCreateModal}
|
||||
footer={validateSession ? [<Button key="close" onClick={resetCreateModal}>关闭</Button>] : undefined}
|
||||
onOk={validateSession ? undefined : handleCreate}
|
||||
okText="开始认证并创建"
|
||||
confirmLoading={creating}
|
||||
maskClosable={!polling}
|
||||
>
|
||||
{!validateSession ? (
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item label="项目组名称" name="name" rules={[{ required: true, message: '请输入项目组名称' }]}>
|
||||
<Input placeholder="例如:达人A、客户B、张三人像" />
|
||||
</Form.Item>
|
||||
<Form.Item label="描述" name="description">
|
||||
<Input.TextArea rows={3} placeholder="可选" />
|
||||
</Form.Item>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
点击后会生成真人认证二维码。手机扫码认证成功后,项目组才会出现在项目列表中。
|
||||
</Typography.Paragraph>
|
||||
</Form>
|
||||
) : (
|
||||
<Space direction="vertical" align="center" size={16} style={{ width: '100%' }}>
|
||||
{isSuccess ? (
|
||||
<CheckCircleOutlined style={{ fontSize: 54, color: '#22c55e' }} />
|
||||
) : h5Link ? (
|
||||
<QRCode value={h5Link} size={220} />
|
||||
) : (
|
||||
<Spin />
|
||||
)}
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Typography.Title level={5} style={{ marginBottom: 8 }}>{createdProject?.name || '真人素材项目组'}</Typography.Title>
|
||||
<Typography.Text type={isSuccess ? 'success' : 'secondary'}>
|
||||
{isSuccess ? '认证成功,项目组正在刷新' : '请使用手机扫码完成人脸认证,成功后回到电脑端查看项目组。'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{h5Link && !isSuccess && <Typography.Text copyable style={{ wordBreak: 'break-all' }}>{h5Link}</Typography.Text>}
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Card, Popconfirm, Space, Typography, message } from 'antd';
|
||||
import { DeleteOutlined, ReloadOutlined, SafetyCertificateOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import { Button, Card, Popconfirm, Space, Tag, Typography, message } from 'antd';
|
||||
import { DeleteOutlined, ReloadOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import type { PrivatePortraitAsset, PrivatePortraitProject } from '../../../types';
|
||||
import { deletePrivatePortraitAsset, deletePrivatePortraitProject, getPrivatePortraitAssets, syncPrivatePortraitAsset } from '../../../api';
|
||||
import PrivatePortraitAssetGrid from './AssetGrid';
|
||||
import PrivatePortraitAssetUpload from './AssetUpload';
|
||||
import PrivatePortraitValidateModal from './ValidateModal';
|
||||
|
||||
interface Props {
|
||||
project: PrivatePortraitProject;
|
||||
@@ -17,7 +16,6 @@ const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onC
|
||||
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [validateOpen, setValidateOpen] = useState(false);
|
||||
|
||||
const loadAssets = async () => {
|
||||
setLoading(true);
|
||||
@@ -65,13 +63,14 @@ const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onC
|
||||
}
|
||||
};
|
||||
|
||||
const canUpload = project.status === 'active';
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={<span>{project.name}</span>}
|
||||
title={<Space><span>{project.name}</span><Tag color={canUpload ? 'green' : 'processing'}>{project.status}</Tag></Space>}
|
||||
extra={(
|
||||
<Space>
|
||||
<Button icon={<SafetyCertificateOutlined />} onClick={() => setValidateOpen(true)}>真人授权</Button>
|
||||
<Button type="primary" icon={<UploadOutlined />} onClick={() => setUploadOpen(true)}>上传素材</Button>
|
||||
<Button type="primary" icon={<UploadOutlined />} disabled={!canUpload} onClick={() => setUploadOpen(true)}>上传素材</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={loadAssets} loading={loading}>刷新</Button>
|
||||
<Popconfirm title="确认删除这个真人素材项目组吗?" onConfirm={handleDeleteProject}>
|
||||
<Button danger icon={<DeleteOutlined />}>删除项目组</Button>
|
||||
@@ -81,9 +80,13 @@ const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onC
|
||||
style={{ borderRadius: 12 }}
|
||||
>
|
||||
<Typography.Paragraph style={{ color: '#64748b' }}>{project.description || '暂无描述'}</Typography.Paragraph>
|
||||
{!canUpload && (
|
||||
<Typography.Paragraph style={{ color: '#f97316' }}>
|
||||
项目组未完成真人认证,暂不能上传素材。请重新创建项目组并完成手机扫码认证。
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
<PrivatePortraitAssetGrid items={assets} loading={loading} onSync={handleSync} onDelete={handleDeleteAsset} />
|
||||
<PrivatePortraitAssetUpload projectId={project.id} open={uploadOpen} onClose={() => setUploadOpen(false)} onSuccess={() => { loadAssets(); onChanged(); }} />
|
||||
<PrivatePortraitValidateModal projectId={project.id} open={validateOpen} onClose={() => setValidateOpen(false)} onCreated={() => onChanged()} />
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,54 +1,77 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Card, Result, Spin, Typography, Modal, message } from 'antd';
|
||||
import { CheckCircleOutlined, TeamOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Button, Card, Result, Spin, Typography, Modal, message, Space,
|
||||
} from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined, TeamOutlined, LoginOutlined, UserAddOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { getJoinTeamInfo, submitJoinRequest } from '../api';
|
||||
import { getJoinTeamInfo, getJoinTeamInfoPublic, submitJoinRequest } from '../api';
|
||||
import type { JoinTeamInfo } from '../types';
|
||||
import { useAuthStore } from '../store/useAuthStore';
|
||||
|
||||
const JoinTeamPage: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const code = searchParams.get('code') || '';
|
||||
const { user, loading: authLoading, checkAuth } = useAuthStore();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [infoLoading, setInfoLoading] = useState(true);
|
||||
const [info, setInfo] = useState<JoinTeamInfo | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [confirmModalOpen, setConfirmModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, [checkAuth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!code) {
|
||||
setLoading(false);
|
||||
setInfoLoading(false);
|
||||
return;
|
||||
}
|
||||
getJoinTeamInfo(code)
|
||||
.then((data) => setInfo(data))
|
||||
if (authLoading) return;
|
||||
|
||||
setInfoLoading(true);
|
||||
const fetchInfo = user ? getJoinTeamInfo(code) : getJoinTeamInfoPublic(code);
|
||||
fetchInfo
|
||||
.then((data) => {
|
||||
setInfo(data);
|
||||
if (data?.valid && user && !data.alreadyInTeam && !data.hasPendingRequest) {
|
||||
setConfirmModalOpen(true);
|
||||
}
|
||||
})
|
||||
.catch(() => setInfo(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [code]);
|
||||
.finally(() => setInfoLoading(false));
|
||||
}, [code, user, authLoading]);
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await submitJoinRequest(code);
|
||||
setSubmitted(true);
|
||||
setConfirmModalOpen(false);
|
||||
message.success('申请已提交');
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '申请失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
const handleLogin = () => {
|
||||
const redirect = encodeURIComponent(window.location.pathname + window.location.search);
|
||||
navigate(`/login?redirect=${redirect}`);
|
||||
};
|
||||
|
||||
const handleRegister = () => {
|
||||
const redirect = encodeURIComponent(window.location.pathname + window.location.search);
|
||||
navigate(`/login?tab=register&redirect=${redirect}`);
|
||||
};
|
||||
|
||||
if (authLoading || infoLoading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh' }}>
|
||||
<Spin size="large" />
|
||||
@@ -64,7 +87,14 @@ const JoinTeamPage: React.FC = () => {
|
||||
icon={<CheckCircleOutlined style={{ color: '#6366f1' }} />}
|
||||
title="申请已提交"
|
||||
subTitle="您的加入申请已提交,请等待团队管理人审批。审批通过后将自动加入团队。"
|
||||
extra={<Button type="primary" onClick={() => navigate('/projects')}>返回首页</Button>}
|
||||
extra={
|
||||
<Space>
|
||||
<Button type="primary" onClick={() => navigate('/projects')}>返回首页</Button>
|
||||
{user && (
|
||||
<Button onClick={() => navigate('/team-management')}>团队管理</Button>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -90,31 +120,113 @@ const JoinTeamPage: React.FC = () => {
|
||||
status="info"
|
||||
title="您已在此团队中"
|
||||
subTitle={`您已经是「${info.teamName}」的成员了,无需再次加入。`}
|
||||
extra={<Button type="primary" onClick={() => navigate('/projects')}>返回首页</Button>}
|
||||
extra={
|
||||
<Space>
|
||||
<Button type="primary" onClick={() => navigate('/projects')}>返回首页</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (info.hasPendingRequest) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
|
||||
<Result
|
||||
status="info"
|
||||
title="申请待审批"
|
||||
subTitle={`您已提交加入「${info.teamName}」的申请,请等待团队管理人审批。`}
|
||||
extra={
|
||||
<Space>
|
||||
<Button type="primary" onClick={() => navigate('/projects')}>返回首页</Button>
|
||||
<Button onClick={() => navigate('/team-management')}>团队管理</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
<>
|
||||
<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>
|
||||
|
||||
{user ? (
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
loading={submitting}
|
||||
onClick={() => setConfirmModalOpen(true)}
|
||||
>
|
||||
申请加入
|
||||
</Button>
|
||||
) : (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={12}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
icon={<LoginOutlined />}
|
||||
onClick={handleLogin}
|
||||
>
|
||||
登录后申请加入
|
||||
</Button>
|
||||
<Button
|
||||
size="large"
|
||||
block
|
||||
icon={<UserAddOutlined />}
|
||||
onClick={handleRegister}
|
||||
>
|
||||
注册新账号
|
||||
</Button>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
登录或注册后将自动提交加入申请
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<TeamOutlined style={{ color: '#6366f1' }} />
|
||||
确认加入团队
|
||||
</Space>
|
||||
}
|
||||
open={confirmModalOpen}
|
||||
onOk={handleJoin}
|
||||
onCancel={() => setConfirmModalOpen(false)}
|
||||
okText="确认加入"
|
||||
cancelText="取消"
|
||||
confirmLoading={submitting}
|
||||
width={420}
|
||||
>
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
您确定要加入团队 <strong style={{ color: '#6366f1' }}>「{info.teamName}」</strong> 吗?
|
||||
</p>
|
||||
<p style={{ marginTop: 8, color: '#64748b', fontSize: 13 }}>
|
||||
提交后需等待团队管理人审批,审批通过后将自动加入团队。
|
||||
</p>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
PlayCircleOutlined, BulbOutlined, HistoryOutlined,
|
||||
MobileOutlined, SafetyOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/useAuthStore';
|
||||
import { sendSms,phonelogin, getSiteInfo, register } from '../api';
|
||||
import './LoginPage.css';
|
||||
@@ -17,6 +17,9 @@ const LoginPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [mode, setMode] = useState<'password' | 'phone' | 'register'>('password');
|
||||
const [tab, setTab] = useState<'password' | 'phone'>('password');
|
||||
const [searchParams] = useSearchParams();
|
||||
const redirect = searchParams.get('redirect');
|
||||
const tabParam = searchParams.get('tab');
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [regCountdown, setRegCountdown] = useState(0);
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
@@ -62,6 +65,23 @@ const LoginPage: React.FC = () => {
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (tabParam === 'register') {
|
||||
setMode('register');
|
||||
}
|
||||
}, [tabParam]);
|
||||
|
||||
const goToRedirect = () => {
|
||||
if (redirect) {
|
||||
try {
|
||||
const decoded = decodeURIComponent(redirect);
|
||||
navigate(decoded);
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
navigate('/home');
|
||||
};
|
||||
|
||||
const checkAgreed = (): boolean => {
|
||||
if (!agreed) {
|
||||
message.warning('请先阅读并同意用户协议及隐私政策');
|
||||
@@ -77,7 +97,7 @@ const LoginPage: React.FC = () => {
|
||||
await login(values.phone, values.password, undefined, values.rememberMe);
|
||||
message.success('登录成功,欢迎回来');
|
||||
await checkAuth();
|
||||
navigate('/home');
|
||||
goToRedirect();
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '登录失败';
|
||||
message.error(errorMsg);
|
||||
@@ -97,7 +117,7 @@ const LoginPage: React.FC = () => {
|
||||
await phonelogin(values.phone, values.code);
|
||||
message.success('登录成功,欢迎回来');
|
||||
await checkAuth();
|
||||
navigate('/home');
|
||||
goToRedirect();
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '登录失败';
|
||||
message.error(errorMsg);
|
||||
@@ -114,7 +134,7 @@ const LoginPage: React.FC = () => {
|
||||
const user = await register(values.phone, values.regCode, values.password);
|
||||
message.success('注册成功');
|
||||
await checkAuth();
|
||||
navigate('/home');
|
||||
goToRedirect();
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '注册失败';
|
||||
message.error(errorMsg);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Table, Tag, Input, Pagination, Typography, Select, App, Modal } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { FolderOpenOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import { FolderOpenOutlined, EyeOutlined, RobotOutlined } from '@ant-design/icons';
|
||||
import { getResourcesMaterialList, getPreTestList, submitPreTest, getDefaultPreTest } from '../api';
|
||||
import PreResultDisplay from '../components/PreResultDisplay';
|
||||
|
||||
@@ -436,6 +436,19 @@ const MaterialListPage: React.FC = () => {
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<Button
|
||||
icon={<RobotOutlined />}
|
||||
onClick={() => navigate('/materials/private-portrait-virtual')}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
fontSize: 14,
|
||||
borderColor: '#8b5cf6',
|
||||
color: '#7c3aed',
|
||||
background: '#f5f3ff',
|
||||
}}
|
||||
>
|
||||
私域虚拟人像库
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={pushTemplatesLoading}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Button, Card, Result, Typography } from 'antd';
|
||||
|
||||
const successStatuses = new Set(['group_active', 'callback_success']);
|
||||
|
||||
const PrivatePortraitAuthorizeResult: React.FC = () => {
|
||||
const params = useMemo(() => new URLSearchParams(window.location.search), []);
|
||||
const status = params.get('status') || '';
|
||||
const resultCode = params.get('resultCode') || '';
|
||||
const isSuccess = successStatuses.has(status) || resultCode === '10000';
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f8fafc', padding: 20 }}>
|
||||
<Card style={{ width: '100%', maxWidth: 520, borderRadius: 18 }}>
|
||||
<Result
|
||||
status={isSuccess ? 'success' : 'error'}
|
||||
title={isSuccess ? '真人认证已完成' : '真人认证未完成'}
|
||||
subTitle={isSuccess ? '请回到电脑端查看,项目组已创建成功。' : '请回到电脑端重新发起创建项目组。'}
|
||||
extra={[
|
||||
<Button key="close" type="primary" onClick={() => window.close()}>关闭页面</Button>,
|
||||
]}
|
||||
/>
|
||||
<Typography.Paragraph type="secondary" style={{ textAlign: 'center', marginBottom: 0 }}>
|
||||
当前状态:{status || '-'},结果码:{resultCode || '-'}
|
||||
</Typography.Paragraph>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrivatePortraitAuthorizeResult;
|
||||
@@ -0,0 +1,599 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Pagination,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Upload,
|
||||
} from 'antd';
|
||||
import type { UploadFile } from 'antd/es/upload/interface';
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
CloudSyncOutlined,
|
||||
DeleteOutlined,
|
||||
EyeOutlined,
|
||||
PictureOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
UploadOutlined,
|
||||
VideoCameraOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
createPrivatePortraitVirtualAsset,
|
||||
createPrivatePortraitVirtualProject,
|
||||
deletePrivatePortraitVirtualAsset,
|
||||
deletePrivatePortraitVirtualProject,
|
||||
getPrivatePortraitVirtualAssets,
|
||||
getPrivatePortraitVirtualConfig,
|
||||
getPrivatePortraitVirtualProjects,
|
||||
syncPrivatePortraitVirtualAsset,
|
||||
uploadImage,
|
||||
uploadVideo,
|
||||
} from '../api';
|
||||
import type { PrivatePortraitAsset, PrivatePortraitConfig, PrivatePortraitProject } from '../types';
|
||||
|
||||
const { Text, Title, Paragraph } = Typography;
|
||||
|
||||
type AssetTypeFilter = 'Image' | 'Video' | undefined;
|
||||
|
||||
const statusConfig: Record<string, { label: string; color: string }> = {
|
||||
creating: { label: '本地创建中', color: 'processing' },
|
||||
Processing: { label: '火山处理中', color: 'processing' },
|
||||
Active: { label: '可用于生成', color: 'success' },
|
||||
Failed: { label: '入库失败', color: 'error' },
|
||||
local_deleted: { label: '本地已删', color: 'default' },
|
||||
remote_deleted: { label: '远端已删', color: 'default' },
|
||||
delete_failed: { label: '远端删除失败', color: 'error' },
|
||||
};
|
||||
|
||||
const assetTypeConfig: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
|
||||
Image: { label: '图片', color: 'green', icon: <PictureOutlined /> },
|
||||
Video: { label: '视频', color: 'blue', icon: <VideoCameraOutlined /> },
|
||||
};
|
||||
|
||||
const formatDateTime = (dateStr?: string | null) => {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
if (Number.isNaN(date.getTime())) return '-';
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
};
|
||||
|
||||
const formatSize = (size?: number | null) => {
|
||||
const value = Number(size || 0);
|
||||
if (!value) return '-';
|
||||
if (value >= 1024 * 1024 * 1024) return `${(value / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(2)} MB`;
|
||||
if (value >= 1024) return `${(value / 1024).toFixed(2)} KB`;
|
||||
return `${value} B`;
|
||||
};
|
||||
|
||||
const buildPreviewUrl = (url?: string | null) => {
|
||||
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 || 'http://localhost:8000').replace(/\/$/, '');
|
||||
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||
};
|
||||
|
||||
const getAssetPreviewUrl = (asset: PrivatePortraitAsset) => {
|
||||
return buildPreviewUrl(asset.previewUrl || asset.displayUrl || asset.videoCoverUrl || asset.remoteUrl || asset.sourceUrl);
|
||||
};
|
||||
|
||||
const guessAssetType = (file?: File | null): 'Image' | 'Video' => {
|
||||
if (!file) return 'Image';
|
||||
if (file.type.startsWith('video/')) return 'Video';
|
||||
const name = file.name.toLowerCase();
|
||||
if (/\.(mp4|mov|webm|m4v|avi|mkv)$/.test(name)) return 'Video';
|
||||
return 'Image';
|
||||
};
|
||||
|
||||
const getVideoDuration = (file: File): Promise<number | null> => {
|
||||
return new Promise((resolve) => {
|
||||
if (!file.type.startsWith('video/')) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(file);
|
||||
const video = document.createElement('video');
|
||||
video.preload = 'metadata';
|
||||
video.onloadedmetadata = () => {
|
||||
const duration = Number.isFinite(video.duration) ? video.duration : null;
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(duration);
|
||||
};
|
||||
video.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(null);
|
||||
};
|
||||
video.src = url;
|
||||
});
|
||||
};
|
||||
|
||||
const StatusTag: React.FC<{ status?: string | null }> = ({ status }) => {
|
||||
const value = status || '-';
|
||||
const config = statusConfig[value];
|
||||
return <Tag color={config?.color || 'default'}>{config?.label || value}</Tag>;
|
||||
};
|
||||
|
||||
const TypeTag: React.FC<{ type?: string | null }> = ({ type }) => {
|
||||
const value = type || '-';
|
||||
const config = assetTypeConfig[value];
|
||||
return <Tag color={config?.color || 'default'} icon={config?.icon}>{config?.label || value}</Tag>;
|
||||
};
|
||||
|
||||
const PrivatePortraitVirtualMaterialPage: React.FC = () => {
|
||||
const { message } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const [config, setConfig] = useState<PrivatePortraitConfig | null>(null);
|
||||
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string>();
|
||||
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
|
||||
const [projectLoading, setProjectLoading] = useState(false);
|
||||
const [assetLoading, setAssetLoading] = useState(false);
|
||||
const [assetPage, setAssetPage] = useState(1);
|
||||
const [assetPageSize, setAssetPageSize] = useState(20);
|
||||
const [assetTotal, setAssetTotal] = useState(0);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [assetStatus, setAssetStatus] = useState<string>();
|
||||
const [assetType, setAssetType] = useState<AssetTypeFilter>();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [creatingProject, setCreatingProject] = useState(false);
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
const [assetName, setAssetName] = useState('');
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [previewUrl, setPreviewUrl] = useState('');
|
||||
const [previewType, setPreviewType] = useState<'Image' | 'Video'>('Image');
|
||||
const [createForm] = Form.useForm<{ name: string; description?: string }>();
|
||||
|
||||
const selectedProject = useMemo(
|
||||
() => projects.find((item) => item.id === selectedProjectId) || null,
|
||||
[projects, selectedProjectId],
|
||||
);
|
||||
|
||||
const quotaText = useMemo(() => {
|
||||
if (!config) return '额度加载中';
|
||||
return `已用 ${config.usedAssetCount || 0} / ${config.assetLimit || 0} 个素材,剩余 ${config.remainingAssetCount || 0}`;
|
||||
}, [config]);
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const next = await getPrivatePortraitVirtualConfig();
|
||||
setConfig(next);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '加载私域素材额度失败');
|
||||
}
|
||||
};
|
||||
|
||||
const loadProjects = async () => {
|
||||
setProjectLoading(true);
|
||||
try {
|
||||
const res = await getPrivatePortraitVirtualProjects({ page: 1, pageSize: 100, status: 'active' });
|
||||
const next = res.items || [];
|
||||
setProjects(next);
|
||||
setSelectedProjectId((prev) => prev && next.some((item) => item.id === prev) ? prev : next[0]?.id);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '加载虚拟人像项目组失败');
|
||||
} finally {
|
||||
setProjectLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAssets = async (page = assetPage, pageSize = assetPageSize) => {
|
||||
if (!selectedProjectId) {
|
||||
setAssets([]);
|
||||
setAssetTotal(0);
|
||||
return;
|
||||
}
|
||||
setAssetLoading(true);
|
||||
try {
|
||||
const res = await getPrivatePortraitVirtualAssets(selectedProjectId, {
|
||||
page,
|
||||
pageSize,
|
||||
keyword: keyword.trim() || undefined,
|
||||
status: assetStatus,
|
||||
assetType,
|
||||
});
|
||||
setAssets(res.items || []);
|
||||
setAssetTotal(res.total || 0);
|
||||
setAssetPage(page);
|
||||
setAssetPageSize(pageSize);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '加载虚拟人像素材失败');
|
||||
} finally {
|
||||
setAssetLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const reloadAll = async () => {
|
||||
await Promise.all([loadConfig(), loadProjects()]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reloadAll();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProjectId) loadAssets(1, assetPageSize);
|
||||
}, [selectedProjectId]);
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
const values = await createForm.validateFields();
|
||||
setCreatingProject(true);
|
||||
try {
|
||||
const project = await createPrivatePortraitVirtualProject(values);
|
||||
message.success('虚拟人像项目组已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
await loadProjects();
|
||||
setSelectedProjectId(project.id);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '创建项目组失败');
|
||||
} finally {
|
||||
setCreatingProject(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedProjectId) {
|
||||
message.warning('请先创建或选择项目组');
|
||||
return;
|
||||
}
|
||||
const file = fileList[0]?.originFileObj as File | undefined;
|
||||
if (!file) {
|
||||
message.warning('请先选择图片或视频素材');
|
||||
return;
|
||||
}
|
||||
const currentType = guessAssetType(file);
|
||||
setUploading(true);
|
||||
try {
|
||||
const uploaded = currentType === 'Video' ? await uploadVideo(file) : await uploadImage(file);
|
||||
const duration = currentType === 'Video' ? await getVideoDuration(file) : null;
|
||||
await createPrivatePortraitVirtualAsset(selectedProjectId, {
|
||||
url: uploaded.url,
|
||||
assetType: currentType,
|
||||
name: assetName.trim() || file.name,
|
||||
videoDuration: duration,
|
||||
fileSize: file.size,
|
||||
mimeType: file.type || null,
|
||||
});
|
||||
message.success(currentType === 'Video' ? '视频素材已提交入库,处理中' : '图片素材已提交入库,处理中');
|
||||
setUploadOpen(false);
|
||||
setFileList([]);
|
||||
setAssetName('');
|
||||
await Promise.all([loadConfig(), loadProjects(), loadAssets(1, assetPageSize)]);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '上传素材失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncAsset = async (assetId: string) => {
|
||||
try {
|
||||
await syncPrivatePortraitVirtualAsset(assetId);
|
||||
message.success('素材状态已刷新');
|
||||
await Promise.all([loadConfig(), loadProjects(), loadAssets(assetPage, assetPageSize)]);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '刷新素材状态失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAsset = async (assetId: string) => {
|
||||
try {
|
||||
await deletePrivatePortraitVirtualAsset(assetId);
|
||||
message.success('素材已删除,远端删除将异步执行');
|
||||
await Promise.all([loadConfig(), loadProjects(), loadAssets(assetPage, assetPageSize)]);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '删除素材失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProject = async () => {
|
||||
if (!selectedProjectId) return;
|
||||
try {
|
||||
await deletePrivatePortraitVirtualProject(selectedProjectId);
|
||||
message.success('项目组已删除,远端资产组将异步删除');
|
||||
setSelectedProjectId(undefined);
|
||||
await reloadAll();
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '删除项目组失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openPreview = (asset: PrivatePortraitAsset) => {
|
||||
const url = getAssetPreviewUrl(asset);
|
||||
if (!url) {
|
||||
message.warning('暂无可预览地址');
|
||||
return;
|
||||
}
|
||||
setPreviewUrl(url);
|
||||
setPreviewType(asset.assetType === 'Video' ? 'Video' : 'Image');
|
||||
setPreviewOpen(true);
|
||||
};
|
||||
|
||||
const renderAssetCard = (asset: PrivatePortraitAsset) => {
|
||||
const preview = getAssetPreviewUrl(asset);
|
||||
const isVideo = asset.assetType === 'Video';
|
||||
return (
|
||||
<Card
|
||||
key={asset.id}
|
||||
hoverable
|
||||
bodyStyle={{ padding: 12 }}
|
||||
style={{ borderRadius: 16, overflow: 'hidden', borderColor: '#eef2f7' }}
|
||||
cover={(
|
||||
<div style={{ height: 170, background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
|
||||
{preview && !isVideo ? (
|
||||
<img src={preview} alt={asset.name || '虚拟人像素材'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : preview && isVideo && asset.videoCoverUrl ? (
|
||||
<img src={preview} alt={asset.name || '视频封面'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : isVideo ? (
|
||||
<VideoCameraOutlined style={{ fontSize: 42, color: '#64748b' }} />
|
||||
) : (
|
||||
<PictureOutlined style={{ fontSize: 42, color: '#64748b' }} />
|
||||
)}
|
||||
{isVideo && <Tag color="blue" style={{ position: 'absolute', left: 10, top: 10 }}>视频</Tag>}
|
||||
<Button size="small" shape="circle" icon={<EyeOutlined />} style={{ position: 'absolute', right: 10, top: 10 }} onClick={() => openPreview(asset)} />
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Tooltip title={asset.name || asset.remoteAssetId || asset.id}>
|
||||
<Text strong ellipsis style={{ display: 'block' }}>{asset.name || asset.remoteAssetId || '未命名素材'}</Text>
|
||||
</Tooltip>
|
||||
<Space wrap size={4}>
|
||||
<TypeTag type={asset.assetType} />
|
||||
<StatusTag status={asset.status} />
|
||||
</Space>
|
||||
<div style={{ color: '#64748b', fontSize: 12, lineHeight: 1.7 }}>
|
||||
<div>大小:{formatSize(asset.fileSize)}</div>
|
||||
<div>轮询:{asset.pollCount || 0} 次</div>
|
||||
<div>创建:{formatDateTime(asset.createdAt)}</div>
|
||||
</div>
|
||||
{asset.errorMessage && <div style={{ color: '#ef4444', fontSize: 12 }}>{asset.errorMessage}</div>}
|
||||
<Space size={6} wrap>
|
||||
<Button size="small" icon={<CloudSyncOutlined />} onClick={() => handleSyncAsset(asset.id)}>同步</Button>
|
||||
<Popconfirm title="确认删除这个虚拟人像素材吗?" onConfirm={() => handleDeleteAsset(asset.id)}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '94vh' }}>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/materials')}>返回素材云</Button>
|
||||
<Title level={4} style={{ margin: 0 }}>私域虚拟人像素材库</Title>
|
||||
</Space>
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} md={8}>
|
||||
<Card style={{ borderRadius: 16, background: 'linear-gradient(135deg,#f5f3ff,#fff)' }}>
|
||||
<Text type="secondary">素材总额度</Text>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: '#4f46e5', marginTop: 8 }}>{quotaText}</div>
|
||||
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>真人/虚拟共用,图片/视频共用;音频暂不开放。</Paragraph>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Card style={{ borderRadius: 16 }}>
|
||||
<Text type="secondary">项目组</Text>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: '#1e293b', marginTop: 8 }}>{projects.length}</div>
|
||||
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>虚拟人像项目会同步创建火山 AIGC Asset Group。</Paragraph>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Card style={{ borderRadius: 16 }}>
|
||||
<Text type="secondary">当前项目素材</Text>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: '#1e293b', marginTop: 8 }}>{selectedProject?.assetCount || 0}</div>
|
||||
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>仅 Active 状态素材可在 AI 创作中引用。</Paragraph>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} lg={7}>
|
||||
<Card
|
||||
title="虚拟人像项目组"
|
||||
extra={<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>创建项目组</Button>}
|
||||
style={{ borderRadius: 16, minHeight: 520 }}
|
||||
>
|
||||
<Spin spinning={projectLoading}>
|
||||
{projects.length === 0 ? (
|
||||
<Empty description="暂无虚拟人像项目组" />
|
||||
) : (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={10}>
|
||||
{projects.map((project) => {
|
||||
const active = selectedProjectId === project.id;
|
||||
return (
|
||||
<div
|
||||
key={project.id}
|
||||
onClick={() => setSelectedProjectId(project.id)}
|
||||
style={{
|
||||
padding: 14,
|
||||
borderRadius: 14,
|
||||
cursor: 'pointer',
|
||||
border: active ? '1px solid #8b5cf6' : '1px solid #e2e8f0',
|
||||
background: active ? '#f5f3ff' : '#fff',
|
||||
}}
|
||||
>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }} align="start">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text strong ellipsis style={{ display: 'block' }}>{project.name}</Text>
|
||||
{project.description && <Text type="secondary" ellipsis style={{ display: 'block', fontSize: 12 }}>{project.description}</Text>}
|
||||
</div>
|
||||
<StatusTag status={project.status} />
|
||||
</Space>
|
||||
<Space wrap size={4} style={{ marginTop: 10 }}>
|
||||
<Tag>总 {project.assetCount || 0}</Tag>
|
||||
<Tag color="green">图 {project.imageAssetCount || 0}</Tag>
|
||||
<Tag color="blue">视频 {project.videoAssetCount || 0}</Tag>
|
||||
<Tag color="success">Active {project.activeAssetCount || 0}</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
)}
|
||||
</Spin>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={17}>
|
||||
<Card
|
||||
title={selectedProject ? selectedProject.name : '素材资产'}
|
||||
extra={(
|
||||
<Space wrap>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => { loadProjects(); loadAssets(assetPage, assetPageSize); }} loading={assetLoading}>刷新</Button>
|
||||
<Button type="primary" icon={<UploadOutlined />} disabled={!selectedProjectId} onClick={() => setUploadOpen(true)}>上传图片/视频</Button>
|
||||
{selectedProjectId && (
|
||||
<Popconfirm title="确认删除当前虚拟人像项目组吗?" onConfirm={handleDeleteProject}>
|
||||
<Button danger icon={<DeleteOutlined />}>删除项目组</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
style={{ borderRadius: 16, minHeight: 520 }}
|
||||
>
|
||||
<Space style={{ width: '100%', marginBottom: 16 }} wrap>
|
||||
<Input.Search
|
||||
allowClear
|
||||
placeholder="搜索素材名称"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onSearch={() => loadAssets(1, assetPageSize)}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="素材状态"
|
||||
value={assetStatus}
|
||||
onChange={(value) => setAssetStatus(value)}
|
||||
style={{ width: 150 }}
|
||||
options={[
|
||||
{ value: 'Processing', label: '处理中' },
|
||||
{ value: 'Active', label: '可用' },
|
||||
{ value: 'Failed', label: '失败' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="素材类型"
|
||||
value={assetType}
|
||||
onChange={(value) => setAssetType(value)}
|
||||
style={{ width: 130 }}
|
||||
options={[
|
||||
{ value: 'Image', label: '图片' },
|
||||
{ value: 'Video', label: '视频' },
|
||||
]}
|
||||
/>
|
||||
<Button onClick={() => loadAssets(1, assetPageSize)}>筛选</Button>
|
||||
</Space>
|
||||
|
||||
<Spin spinning={assetLoading}>
|
||||
{!selectedProjectId ? (
|
||||
<Empty description="请先创建或选择项目组" style={{ marginTop: 80 }} />
|
||||
) : assets.length === 0 ? (
|
||||
<Empty description="暂无素材,上传图片/视频后会异步入库" style={{ marginTop: 80 }} />
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 14 }}>
|
||||
{assets.map(renderAssetCard)}
|
||||
</div>
|
||||
<div style={{ textAlign: 'right', marginTop: 16 }}>
|
||||
<Pagination
|
||||
current={assetPage}
|
||||
pageSize={assetPageSize}
|
||||
total={assetTotal}
|
||||
showSizeChanger
|
||||
showTotal={(value) => `共 ${value} 个素材`}
|
||||
onChange={(page, size) => loadAssets(page, size)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Spin>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Modal
|
||||
title="创建虚拟人像项目组"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={handleCreateProject}
|
||||
confirmLoading={creatingProject}
|
||||
okText="创建并同步火山 Asset Group"
|
||||
>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="name" label="项目组名称" rules={[{ required: true, message: '请输入项目组名称' }]}>
|
||||
<Input placeholder="例如:虚拟主播A / 品牌代言人B" maxLength={128} />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea placeholder="可填写人物设定、服装风格、素材要求等" maxLength={2000} rows={4} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="上传虚拟人像素材"
|
||||
open={uploadOpen}
|
||||
onCancel={() => setUploadOpen(false)}
|
||||
onOk={handleUpload}
|
||||
confirmLoading={uploading}
|
||||
okText="提交入库"
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={14}>
|
||||
<Input value={assetName} onChange={(e) => setAssetName(e.target.value)} placeholder="素材名称,默认使用文件名" maxLength={256} />
|
||||
<Upload
|
||||
accept="image/*,video/*"
|
||||
maxCount={1}
|
||||
fileList={fileList}
|
||||
beforeUpload={() => false}
|
||||
onChange={({ fileList: next }) => setFileList(next)}
|
||||
listType="picture"
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>选择图片或视频</Button>
|
||||
</Upload>
|
||||
<div style={{ padding: 12, background: '#f8fafc', borderRadius: 12, color: '#64748b', fontSize: 13 }}>
|
||||
当前开放图片和视频,音频暂不接入。提交后会调用火山 CreateAsset 异步处理,状态变为 Active 后才可用于 AI 创作。
|
||||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
<Modal title="素材预览" open={previewOpen} onCancel={() => setPreviewOpen(false)} footer={null} width={760} destroyOnClose>
|
||||
<div style={{ minHeight: 420, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0f172a', borderRadius: 12, overflow: 'hidden' }}>
|
||||
{previewType === 'Video' ? (
|
||||
<video src={previewUrl} controls autoPlay style={{ maxWidth: '100%', maxHeight: 520 }} />
|
||||
) : (
|
||||
<img src={previewUrl} alt="素材预览" style={{ maxWidth: '100%', maxHeight: 520, objectFit: 'contain' }} />
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrivatePortraitVirtualMaterialPage;
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import React, { useEffect, useState, useCallback, useRef } 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,
|
||||
CopyOutlined, DownloadOutlined, PlusOutlined, ReloadOutlined, UserOutlined, HistoryOutlined, WalletOutlined, BellOutlined, ClockCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
@@ -134,8 +134,7 @@ const TeamManagementPage: React.FC = () => {
|
||||
try {
|
||||
const values = await invForm.validateFields();
|
||||
setInvSaving(true);
|
||||
const expiresAt = values.expiresAt ? new Date(values.expiresAt).toISOString() : null;
|
||||
await createTeamInvitation(values.maxUses || null, expiresAt);
|
||||
await createTeamInvitation(values.maxUses || null, null);
|
||||
message.success('邀请码已生成');
|
||||
setInvModal(false);
|
||||
invForm.resetFields();
|
||||
@@ -159,22 +158,80 @@ const TeamManagementPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const copyInviteLink = (link: string) => {
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
message.success('邀请链接已复制');
|
||||
}).catch(() => {
|
||||
fallbackCopy(link);
|
||||
});
|
||||
} else {
|
||||
fallbackCopy(link);
|
||||
}
|
||||
};
|
||||
|
||||
const fallbackCopy = (text: string) => {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-9999px';
|
||||
textArea.style.top = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
message.success('邀请链接已复制');
|
||||
}).catch(() => {
|
||||
} catch {
|
||||
message.warning('复制失败,请手动复制');
|
||||
});
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
};
|
||||
|
||||
// ── Tab 3: 加入申请 ──
|
||||
const [requests, setRequests] = useState<TeamJoinRequest[]>([]);
|
||||
const [reqLoading, setReqLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('members');
|
||||
const initialNoticeShownRef = useRef(false);
|
||||
const lastRequestCountRef = useRef(0);
|
||||
|
||||
const loadRequests = useCallback(async () => {
|
||||
const loadRequests = useCallback(async (isInitial = false) => {
|
||||
setReqLoading(true);
|
||||
try {
|
||||
const data = await getPendingJoinRequests();
|
||||
const currentCount = data?.length || 0;
|
||||
setRequests(data || []);
|
||||
|
||||
if (currentCount > 0) {
|
||||
if (isInitial && !initialNoticeShownRef.current) {
|
||||
initialNoticeShownRef.current = true;
|
||||
Modal.confirm({
|
||||
title: (
|
||||
<Space>
|
||||
<BellOutlined style={{ color: '#f59e0b' }} />
|
||||
待处理的加入申请
|
||||
</Space>
|
||||
),
|
||||
content: (
|
||||
<div>
|
||||
<p>您有 <strong style={{ color: '#ef4444' }}>{currentCount}</strong> 条待处理的团队加入申请。</p>
|
||||
<p style={{ color: '#64748b', fontSize: 13, marginBottom: 0 }}>请及时处理新成员的加入申请。</p>
|
||||
</div>
|
||||
),
|
||||
okText: '立即处理',
|
||||
cancelText: '稍后处理',
|
||||
onOk: () => {
|
||||
setActiveTab('requests');
|
||||
},
|
||||
});
|
||||
} else if (!isInitial && currentCount > lastRequestCountRef.current) {
|
||||
message.info({
|
||||
content: `有 ${currentCount - lastRequestCountRef.current} 条新的加入申请待处理`,
|
||||
duration: 5,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
lastRequestCountRef.current = currentCount;
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载申请失败');
|
||||
} finally {
|
||||
@@ -182,7 +239,17 @@ const TeamManagementPage: React.FC = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadRequests(); }, [loadRequests]);
|
||||
useEffect(() => {
|
||||
loadRequests(true);
|
||||
}, [loadRequests]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!team) return;
|
||||
const interval = setInterval(() => {
|
||||
loadRequests(false);
|
||||
}, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, [team, loadRequests]);
|
||||
|
||||
const handleRequest = async (requestId: string, action: 'approve' | 'reject', note?: string) => {
|
||||
try {
|
||||
@@ -283,18 +350,41 @@ const TeamManagementPage: React.FC = () => {
|
||||
},
|
||||
];
|
||||
|
||||
const buildInviteLink = (code: string) => {
|
||||
const base = window.location.origin;
|
||||
return `${base}/join-team?code=${code}`;
|
||||
};
|
||||
|
||||
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: 'code',
|
||||
render: (v: string) => {
|
||||
const link = buildInviteLink(v);
|
||||
return (
|
||||
<Space style={{ width: '100%' }}>
|
||||
<Typography.Text
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 12,
|
||||
wordBreak: 'break-all',
|
||||
fontFamily: 'monospace',
|
||||
color: '#64748b',
|
||||
}}
|
||||
>
|
||||
{link}
|
||||
</Typography.Text>
|
||||
<Tooltip title="复制链接">
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => copyInviteLink(link)}
|
||||
/>
|
||||
</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}` : ''}` },
|
||||
@@ -517,7 +607,7 @@ const TeamManagementPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* 标签页 */}
|
||||
<Tabs items={tabItems} defaultActiveKey="members" size="large" />
|
||||
<Tabs items={tabItems} activeKey={activeTab} onChange={setActiveTab} size="large" />
|
||||
|
||||
{/* 调整积分弹窗 */}
|
||||
<Modal
|
||||
@@ -607,9 +697,19 @@ const TeamManagementPage: React.FC = () => {
|
||||
<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>
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
background: '#eef2ff',
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
color: '#4f46e5',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}>
|
||||
<ClockCircleOutlined />
|
||||
<span>邀请码自生成起 <strong>24 小时</strong> 内有效,过期自动失效。</span>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -77,6 +77,7 @@ export interface JoinTeamInfo {
|
||||
teamId: string;
|
||||
valid: boolean;
|
||||
alreadyInTeam: boolean;
|
||||
hasPendingRequest: boolean;
|
||||
}
|
||||
|
||||
export interface CreditRecord {
|
||||
@@ -134,6 +135,9 @@ export interface MediaReference {
|
||||
source?: string;
|
||||
private_asset_id?: string;
|
||||
remote_asset_id?: string;
|
||||
providerUrl?: string;
|
||||
displayUrl?: string;
|
||||
previewUrl?: string;
|
||||
}
|
||||
|
||||
export interface GenerationRecord {
|
||||
@@ -253,14 +257,23 @@ export interface AdminNotification {
|
||||
|
||||
export interface PrivatePortraitConfig {
|
||||
enabled: boolean;
|
||||
imageLimit: number;
|
||||
usedImageCount: number;
|
||||
remainingImageCount: number;
|
||||
assetLimit: number;
|
||||
usedAssetCount: number;
|
||||
remainingAssetCount: number;
|
||||
supportedAssetTypes?: string[];
|
||||
unsupportedAssetTypes?: string[];
|
||||
imageLimit?: number;
|
||||
usedImageCount?: number;
|
||||
remainingImageCount?: number;
|
||||
}
|
||||
|
||||
export type PrivatePortraitLibraryType = 'real_person' | 'aigc_virtual';
|
||||
export type PrivatePortraitAssetType = 'Image' | 'Video' | 'Audio';
|
||||
|
||||
export interface PrivatePortraitProject {
|
||||
id: string;
|
||||
userId?: string | null;
|
||||
libraryType?: PrivatePortraitLibraryType | string;
|
||||
name: string;
|
||||
nameSlug?: string | null;
|
||||
remoteProjectName?: string | null;
|
||||
@@ -268,7 +281,11 @@ export interface PrivatePortraitProject {
|
||||
status: string;
|
||||
assetGroupCount: number;
|
||||
assetCount: number;
|
||||
imageAssetCount?: number;
|
||||
videoAssetCount?: number;
|
||||
activeAssetCount: number;
|
||||
activeImageAssetCount?: number;
|
||||
activeVideoAssetCount?: number;
|
||||
lastUsedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
@@ -297,23 +314,42 @@ export interface PrivatePortraitValidateSession {
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface PrivatePortraitProjectCreateWithValidateOut {
|
||||
project: PrivatePortraitProject;
|
||||
validateSession: PrivatePortraitValidateSession;
|
||||
pollIntervalMs: number;
|
||||
}
|
||||
|
||||
export interface PrivatePortraitAsset {
|
||||
id: string;
|
||||
userId?: string | null;
|
||||
projectId: string;
|
||||
projectName?: string | null;
|
||||
groupId: string;
|
||||
libraryType?: PrivatePortraitLibraryType | string;
|
||||
remoteGroupId: string;
|
||||
remoteAssetId?: string | null;
|
||||
remoteProjectName?: string | null;
|
||||
assetType: string;
|
||||
assetType: PrivatePortraitAssetType | string;
|
||||
name?: string | null;
|
||||
sourceUrl: string;
|
||||
previewUrl?: string | null;
|
||||
displayUrl?: string | null;
|
||||
providerUrl?: string | null;
|
||||
remoteUrl?: string | null;
|
||||
remoteUrlExpiredAt?: string | null;
|
||||
videoDuration?: number | null;
|
||||
videoCoverUrl?: string | null;
|
||||
fileSize?: number | null;
|
||||
mimeType?: string | null;
|
||||
status: string;
|
||||
moderation?: unknown;
|
||||
lastPollAt?: string | null;
|
||||
nextPollAt?: string | null;
|
||||
pollCount: number;
|
||||
remoteDeleteStatus: string;
|
||||
remoteDeletedAt?: string | null;
|
||||
remoteDeleteError?: string | null;
|
||||
errorMessage?: string | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
@@ -330,9 +366,14 @@ export interface PrivatePortraitSelectableAsset {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
libraryType?: PrivatePortraitLibraryType | string;
|
||||
name?: string | null;
|
||||
assetType: string;
|
||||
assetType: PrivatePortraitAssetType | string;
|
||||
previewUrl?: string | null;
|
||||
displayUrl?: string | null;
|
||||
providerUrl?: string | null;
|
||||
videoDuration?: number | null;
|
||||
videoCoverUrl?: string | null;
|
||||
status: string;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
@@ -343,3 +384,4 @@ export interface PrivatePortraitSelectableAssetListOut {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user