diff --git a/video-gen-admin/src/App.tsx b/video-gen-admin/src/App.tsx index 43e75890..92eaf1d6 100644 --- a/video-gen-admin/src/App.tsx +++ b/video-gen-admin/src/App.tsx @@ -37,6 +37,7 @@ import AdminHomeMaterials from './pages/AdminHomeMaterials'; import AdminPreTestTemplates from './pages/AdminPreTestTemplates'; import AdminOAuthList from './pages/AdminOAuthList'; import AdminMaterialList from './pages/AdminMaterialList'; +import AdminPrivatePortraitProjects from './pages/AdminPrivatePortraitProjects'; import { useAdminStore } from './store'; @@ -115,6 +116,7 @@ const App = () => { } /> } /> } /> + } /> } /> diff --git a/video-gen-admin/src/api/index.ts b/video-gen-admin/src/api/index.ts index b32559e2..adf8c53b 100644 --- a/video-gen-admin/src/api/index.ts +++ b/video-gen-admin/src/api/index.ts @@ -15,6 +15,7 @@ import type { AdminCreditRecordListResponse, AdminCreditRecordQueryParams, ResourceCapacityConfigOut, ResourceCapacityConfigPayload, AdminUserResourceCapacityOut, AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams, + PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut, } from '../types'; import type { @@ -434,7 +435,7 @@ export async function deleteMenuConfig(id: string): Promise { // ── User Creation ─────────────────────────────────────── -export async function createUser(data: { username?: string; password: string; email?: string; phone?: string; credits: number; user_type: string; frontend_user_kind?: string; allowed_menus?: string[] | null }): Promise { +export async function createUser(data: { username?: string; password: string; email?: string; phone?: string; credits: number; user_type: string; frontend_user_kind?: string; allowed_menus?: string[] | null; private_portrait_image_limit?: number }): Promise { return api.post('/admin/users', data); } @@ -983,4 +984,35 @@ export async function getMaterialList(params: MaterialListParams): Promise if (params.page !== undefined) query.set('page', String(params.page)); if (params.page_size !== undefined) query.set('page_size', String(params.page_size)); return api.get(`/material-admin/material-list?${query.toString()}`); -} \ No newline at end of file +} + +// ── Private Portrait Admin ──────────────────────────────── +export async function adminGetPrivatePortraitProjects(params: { userId?: string; keyword?: string; status?: string; page?: number; pageSize?: number } = {}): Promise { + const query = new URLSearchParams(); + query.set('page', String(params.page || 1)); + query.set('page_size', String(params.pageSize || 20)); + if (params.userId) query.set('user_id', params.userId); + if (params.keyword) query.set('keyword', params.keyword); + if (params.status) query.set('status', params.status); + return api.get(`/admin/private-portrait/projects?${query.toString()}`); +} + +export async function adminGetPrivatePortraitAssets(params: { userId?: string; projectId?: string; keyword?: string; status?: string; page?: number; pageSize?: number } = {}): Promise { + const query = new URLSearchParams(); + query.set('page', String(params.page || 1)); + query.set('page_size', String(params.pageSize || 20)); + if (params.userId) query.set('user_id', params.userId); + if (params.projectId) query.set('project_id', params.projectId); + if (params.keyword) query.set('keyword', params.keyword); + if (params.status) query.set('status', params.status); + return api.get(`/admin/private-portrait/assets?${query.toString()}`); +} + + +export async function adminGetPrivatePortraitConfig(userId: string): Promise { + return api.get(`/admin/private-portrait/users/${userId}/config`); +} + +export async function adminUpdatePrivatePortraitConfig(userId: string, limit: number): Promise { + return api.put(`/admin/private-portrait/users/${userId}/config`, { private_portrait_image_limit: limit }); +} diff --git a/video-gen-admin/src/pages/AdminPrivatePortraitProjects.tsx b/video-gen-admin/src/pages/AdminPrivatePortraitProjects.tsx new file mode 100644 index 00000000..64caa200 --- /dev/null +++ b/video-gen-admin/src/pages/AdminPrivatePortraitProjects.tsx @@ -0,0 +1,133 @@ +import React, { useEffect, useState } from 'react'; +import { Button, Card, Input, Space, Table, Tag, Typography, message } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'; +import { adminGetPrivatePortraitAssets, adminGetPrivatePortraitProjects } from '../api'; +import type { PrivatePortraitAsset, PrivatePortraitProject } from '../types'; + +const { Title, Text } = Typography; + +const statusColor = (status?: string) => { + if (status === 'Active' || status === 'active') return 'green'; + if (status === 'Processing') return 'blue'; + if (status === 'Failed' || status === 'failed' || status === 'delete_failed') return 'red'; + if (status?.includes('deleted')) return 'default'; + return 'default'; +}; + +const AdminPrivatePortraitProjects: React.FC = () => { + const [projects, setProjects] = useState([]); + const [assets, setAssets] = useState([]); + const [projectTotal, setProjectTotal] = useState(0); + const [assetTotal, setAssetTotal] = useState(0); + const [loadingProjects, setLoadingProjects] = useState(false); + const [loadingAssets, setLoadingAssets] = useState(false); + const [keyword, setKeyword] = useState(''); + const [selectedProjectId, setSelectedProjectId] = useState(); + const [projectPage, setProjectPage] = useState(1); + const [assetPage, setAssetPage] = useState(1); + + const loadProjects = async () => { + setLoadingProjects(true); + try { + const res = await adminGetPrivatePortraitProjects({ keyword: keyword.trim() || undefined, page: projectPage, pageSize: 20 }); + setProjects(res.items || []); + setProjectTotal(res.total || 0); + } catch (err: any) { + message.error(err?.message || '加载真人素材项目失败'); + } finally { + setLoadingProjects(false); + } + }; + + const loadAssets = async () => { + setLoadingAssets(true); + try { + const res = await adminGetPrivatePortraitAssets({ projectId: selectedProjectId, keyword: keyword.trim() || undefined, page: assetPage, pageSize: 20 }); + setAssets(res.items || []); + setAssetTotal(res.total || 0); + } catch (err: any) { + message.error(err?.message || '加载真人素材失败'); + } finally { + setLoadingAssets(false); + } + }; + + useEffect(() => { loadProjects(); }, [projectPage]); + useEffect(() => { loadAssets(); }, [selectedProjectId, assetPage]); + + const projectColumns: ColumnsType = [ + { title: '项目名称', dataIndex: 'name', width: 180, render: (v, r) => }, + { title: '用户ID', dataIndex: 'userId', width: 170, render: (v) => {v || '-'} }, + { title: '状态', dataIndex: 'status', width: 100, render: (v) => {v} }, + { title: 'ProjectName', dataIndex: 'remoteProjectName', width: 240, render: (v) => {v || '-'} }, + { title: '素材数', dataIndex: 'assetCount', width: 100 }, + { title: 'Active', dataIndex: 'activeAssetCount', width: 100 }, + { title: '创建时间', dataIndex: 'createdAt', width: 170, render: (v) => v || '-' }, + ]; + + const assetColumns: ColumnsType = [ + { title: '素材', dataIndex: 'name', width: 180, render: (v, r) => v || r.remoteAssetId || '-' }, + { title: '用户ID', dataIndex: 'userId', width: 170, render: (v) => {v || '-'} }, + { title: '本地项目', dataIndex: 'projectName', width: 160 }, + { title: 'AssetId', dataIndex: 'remoteAssetId', width: 240, render: (v) => {v} }, + { title: 'GroupId', dataIndex: 'remoteGroupId', width: 240, render: (v) => {v} }, + { title: 'ProjectName', dataIndex: 'remoteProjectName', width: 240, render: (v) => {v || '-'} }, + { title: '类型', dataIndex: 'assetType', width: 90 }, + { title: '状态', dataIndex: 'status', width: 110, render: (v) => {v} }, + { title: '错误', dataIndex: 'errorMessage', width: 240, ellipsis: true, render: (v) => v || '-' }, + { title: '创建时间', dataIndex: 'createdAt', width: 170, render: (v) => v || '-' }, + ]; + + return ( +
+ +
+ 真人素材库 + 只读排查页:查看用户项目组、Asset Group 与 Asset 状态。 +
+ + } + placeholder="搜索项目/素材" + value={keyword} + onChange={(e) => setKeyword(e.target.value)} + onPressEnter={() => { setProjectPage(1); setAssetPage(1); loadProjects(); loadAssets(); }} + style={{ width: 260 }} + /> + + +
+ + + + + + setSelectedProjectId(undefined)}>查看全部 : null} + > +
+ + + ); +}; + +export default AdminPrivatePortraitProjects; diff --git a/video-gen-admin/src/pages/AdminUsers.tsx b/video-gen-admin/src/pages/AdminUsers.tsx index 644f3ab3..1bbefd5c 100644 --- a/video-gen-admin/src/pages/AdminUsers.tsx +++ b/video-gen-admin/src/pages/AdminUsers.tsx @@ -3,10 +3,12 @@ import { Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Select, Space, Switch, Table, Tabs, Tag, Typography, } from 'antd'; import { - UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined, + UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined, PictureOutlined, } from '@ant-design/icons'; import { adjustCredits, + adminGetPrivatePortraitConfig, + adminUpdatePrivatePortraitConfig, createUser, deleteUserResourceCapacity, getAdminUsers, @@ -22,7 +24,7 @@ import { updateSystemConfig, updateUserMenus, } from '../api'; -import type { AdminTeamOption, AdminUser, AdminUserResourceCapacityOut, ResourceCapacityUnit, ResourceCapacityUsage, SystemConfig } from '../types'; +import type { AdminTeamOption, AdminUser, AdminUserResourceCapacityOut, PrivatePortraitConfig, ResourceCapacityUnit, ResourceCapacityUsage, SystemConfig } from '../types'; import { formatDate } from '../utils/formatDate'; const TEAM_UNASSIGNED_VALUE = '__none__'; @@ -71,14 +73,18 @@ const AdminUsers: React.FC = () => { const [resetPwdModal, setResetPwdModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null }); const [capacityModal, setCapacityModal] = useState<{ open: boolean; user: AdminUser | null; detail: AdminUserResourceCapacityOut | null }>({ open: false, user: null, detail: null }); const [teamModal, setTeamModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null }); + const [portraitModal, setPortraitModal] = useState<{ open: boolean; user: AdminUser | null; config: PrivatePortraitConfig | null }>({ open: false, user: null, config: null }); const [capacityLoading, setCapacityLoading] = useState(false); const [capacitySaving, setCapacitySaving] = useState(false); const [teamSaving, setTeamSaving] = useState(false); + const [portraitLoading, setPortraitLoading] = useState(false); + const [portraitSaving, setPortraitSaving] = useState(false); const [form] = Form.useForm(); const [createForm] = Form.useForm(); const [resetPwdForm] = Form.useForm(); const [capacityForm] = Form.useForm(); const [teamForm] = Form.useForm(); + const [portraitForm] = Form.useForm(); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); @@ -183,6 +189,7 @@ const AdminUsers: React.FC = () => { credits: values.credits || 0, user_type: userType, frontend_user_kind: values.frontend_user_kind || 'external', + private_portrait_image_limit: userType === 'frontend' ? Number(values.private_portrait_image_limit ?? 5) : 0, }); message.success('用户创建成功'); setCreateModal(false); @@ -266,6 +273,42 @@ const AdminUsers: React.FC = () => { } }; + const openPortraitModal = async (user: AdminUser) => { + setPortraitLoading(true); + setPortraitModal({ open: true, user, config: null }); + portraitForm.setFieldsValue({ privatePortraitImageLimit: user.privatePortraitImageLimit ?? 5 }); + try { + const config = await adminGetPrivatePortraitConfig(user.id); + portraitForm.setFieldsValue({ privatePortraitImageLimit: config.imageLimit }); + setPortraitModal({ open: true, user, config }); + } catch (e: any) { + message.error(e?.message || '加载真人素材库配置失败'); + setPortraitModal({ open: false, user: null, config: null }); + } finally { + setPortraitLoading(false); + } + }; + + const handleSavePortraitConfig = async () => { + const { user } = portraitModal; + if (!user) return; + try { + const values = await portraitForm.validateFields(); + const limit = Number(values.privatePortraitImageLimit ?? 0); + setPortraitSaving(true); + const config = await adminUpdatePrivatePortraitConfig(user.id, limit); + message.success(limit > 0 ? `真人素材库已开启,限制 ${limit} 张` : '真人素材库已关闭'); + setPortraitModal({ open: false, user: null, config }); + portraitForm.resetFields(); + load(); + } catch (e: any) { + if (e?.errorFields) return; + message.error(e?.message || '保存真人素材库配置失败'); + } finally { + setPortraitSaving(false); + } + }; + const openTeamModal = (user: AdminUser) => { teamForm.setFieldsValue({ teamId: user.teamId || '' }); setTeamModal({ open: true, user }); @@ -380,6 +423,13 @@ const AdminUsers: React.FC = () => { title: '团队', dataIndex: 'teamName', width: 140, render: (v: string | null | undefined) => v ? {v} : 未分配, }] : []), + ...(!isAdminTab ? [{ + title: '真人素材库', dataIndex: 'privatePortraitImageLimit', width: 150, + render: (v: number) => { + const limit = Number(v || 0); + return limit > 0 ? 开启:{limit} 张 : 未开启; + }, + }] : []), ...(!isAdminTab ? [{ title: '资源容量', dataIndex: 'resourceCapacity', width: 230, render: (capacity: ResourceCapacityUsage | null | undefined) => { @@ -425,7 +475,7 @@ const AdminUsers: React.FC = () => { render: (v: string) => {formatDate(v)}, }, { - title: '操作', key: 'action', width: 460, fixed: 'right' as const, + title: '操作', key: 'action', width: 540, fixed: 'right' as const, render: (_: any, r: AdminUser) => ( {!isAdminTab && ( @@ -440,6 +490,12 @@ const AdminUsers: React.FC = () => { 容量设置 )} + {!isAdminTab && ( + + )} {!isAdminTab && ( + onDelete(item.id)}> + + + + + + ))} + + ); +}; + +export default PrivatePortraitAssetGrid; diff --git a/video-gen-app/src/components/privatePortrait/library/AssetUpload.tsx b/video-gen-app/src/components/privatePortrait/library/AssetUpload.tsx new file mode 100644 index 00000000..2664aae4 --- /dev/null +++ b/video-gen-app/src/components/privatePortrait/library/AssetUpload.tsx @@ -0,0 +1,61 @@ +import React, { useState } from 'react'; +import { Button, Input, Modal, Upload, message } from 'antd'; +import { UploadOutlined } from '@ant-design/icons'; +import type { UploadFile } from 'antd/es/upload/interface'; +import { createPrivatePortraitAsset, uploadImage } from '../../../api'; + +interface Props { + projectId: string; + open: boolean; + onClose: () => void; + onSuccess: () => void; +} + +const PrivatePortraitAssetUpload: React.FC = ({ projectId, open, onClose, onSuccess }) => { + const [fileList, setFileList] = useState([]); + const [name, setName] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async () => { + const file = fileList[0]?.originFileObj as File | undefined; + if (!file) { + message.warning('请先选择图片素材'); + return; + } + setLoading(true); + try { + const uploaded = await uploadImage(file); + await createPrivatePortraitAsset(projectId, { url: uploaded.url, assetType: 'Image', name: name || file.name }); + message.success('素材已提交入库,处理中'); + setFileList([]); + setName(''); + onSuccess(); + onClose(); + } catch (e: any) { + message.error(e?.message || '上传素材失败'); + } finally { + setLoading(false); + } + }; + + return ( + +
+ setName(e.target.value)} placeholder="素材名称,默认使用文件名" /> + false} + onChange={({ fileList }) => setFileList(fileList)} + listType="picture" + > + + +
建议上传真人正脸、全身或同人妆造图。入库后会经过火山真人一致性校验,Active 后才可用于 AI 创作。
+
+
+ ); +}; + +export default PrivatePortraitAssetUpload; diff --git a/video-gen-app/src/components/privatePortrait/library/LibraryPanel.tsx b/video-gen-app/src/components/privatePortrait/library/LibraryPanel.tsx new file mode 100644 index 00000000..2cc2c805 --- /dev/null +++ b/video-gen-app/src/components/privatePortrait/library/LibraryPanel.tsx @@ -0,0 +1,85 @@ +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 PrivatePortraitProjectList from './ProjectList'; +import PrivatePortraitProjectDetail from './ProjectDetail'; + +const PrivatePortraitLibraryPanel: React.FC = () => { + const [projects, setProjects] = useState([]); + const [selected, setSelected] = useState(null); + const [loading, setLoading] = useState(false); + const [createOpen, setCreateOpen] = useState(false); + const [form] = Form.useForm(); + + const loadProjects = async () => { + setLoading(true); + try { + const res = await getPrivatePortraitProjects({ pageSize: 100 }); + 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) { + message.error(e?.message || '加载真人素材项目组失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { loadProjects(); }, []); + + const handleCreate = async () => { + const values = await form.validateFields(); + try { + const project = await createPrivatePortraitProject(values); + message.success('项目组已创建'); + setCreateOpen(false); + form.resetFields(); + await loadProjects(); + setSelected(project); + } catch (e: any) { + message.error(e?.message || '创建项目组失败'); + } + }; + + return ( +
+
+
+ 真人素材库 + 管理真人授权项目组和已入库 Active 素材,AI 创作添加参考内容时可直接选择。 +
+ + + + +
+ +
+ + + + + + {selected ? ( + { setSelected(null); loadProjects(); }} onChanged={loadProjects} /> + ) : ( + 请先创建或选择一个真人素材项目组 + )} + + + setCreateOpen(false)} onOk={handleCreate} okText="创建"> +
+ + + + + + + +
+ + ); +}; + +export default PrivatePortraitLibraryPanel; diff --git a/video-gen-app/src/components/privatePortrait/library/ProjectDetail.tsx b/video-gen-app/src/components/privatePortrait/library/ProjectDetail.tsx new file mode 100644 index 00000000..a904c447 --- /dev/null +++ b/video-gen-app/src/components/privatePortrait/library/ProjectDetail.tsx @@ -0,0 +1,91 @@ +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 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; + onDeleted: () => void; + onChanged: () => void; +} + +const PrivatePortraitProjectDetail: React.FC = ({ project, onDeleted, onChanged }) => { + const [assets, setAssets] = useState([]); + const [loading, setLoading] = useState(false); + const [uploadOpen, setUploadOpen] = useState(false); + const [validateOpen, setValidateOpen] = useState(false); + + const loadAssets = async () => { + setLoading(true); + try { + const res = await getPrivatePortraitAssets(project.id, { pageSize: 100 }); + setAssets(res.items); + } catch (e: any) { + message.error(e?.message || '加载素材失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { loadAssets(); }, [project.id]); + + const handleSync = async (assetId: string) => { + try { + await syncPrivatePortraitAsset(assetId); + await loadAssets(); + onChanged(); + message.success('素材状态已刷新'); + } catch (e: any) { + message.error(e?.message || '刷新失败'); + } + }; + + const handleDeleteAsset = async (assetId: string) => { + try { + await deletePrivatePortraitAsset(assetId); + await loadAssets(); + onChanged(); + message.success('素材已删除'); + } catch (e: any) { + message.error(e?.message || '删除素材失败'); + } + }; + + const handleDeleteProject = async () => { + try { + await deletePrivatePortraitProject(project.id); + message.success('项目组已删除'); + onDeleted(); + } catch (e: any) { + message.error(e?.message || '删除项目组失败'); + } + }; + + return ( + {project.name}} + extra={( + + + + + + + + + )} + style={{ borderRadius: 12 }} + > + {project.description || '暂无描述'} + + setUploadOpen(false)} onSuccess={() => { loadAssets(); onChanged(); }} /> + setValidateOpen(false)} onCreated={() => onChanged()} /> + + ); +}; + +export default PrivatePortraitProjectDetail; diff --git a/video-gen-app/src/components/privatePortrait/library/ProjectList.tsx b/video-gen-app/src/components/privatePortrait/library/ProjectList.tsx new file mode 100644 index 00000000..47fa8935 --- /dev/null +++ b/video-gen-app/src/components/privatePortrait/library/ProjectList.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { Button, Empty, List, Tag } from 'antd'; +import type { PrivatePortraitProject } from '../../../types'; + +interface Props { + items: PrivatePortraitProject[]; + selectedId?: string | null; + onSelect: (project: PrivatePortraitProject) => void; +} + +const PrivatePortraitProjectList: React.FC = ({ items, selectedId, onSelect }) => { + if (!items.length) return ; + return ( + ( + + + + )} + /> + ); +}; + +export default PrivatePortraitProjectList; diff --git a/video-gen-app/src/components/privatePortrait/library/ValidateModal.tsx b/video-gen-app/src/components/privatePortrait/library/ValidateModal.tsx new file mode 100644 index 00000000..c2571f2a --- /dev/null +++ b/video-gen-app/src/components/privatePortrait/library/ValidateModal.tsx @@ -0,0 +1,49 @@ +import React, { useState } from 'react'; +import { Button, Modal, Space, Typography, message } from 'antd'; +import { createPrivatePortraitValidateSession } from '../../../api'; +import type { PrivatePortraitValidateSession } from '../../../types'; + +interface Props { + projectId: string; + open: boolean; + onClose: () => void; + onCreated: (session: PrivatePortraitValidateSession) => void; +} + +const PrivatePortraitValidateModal: React.FC = ({ projectId, open, onClose, onCreated }) => { + const [loading, setLoading] = useState(false); + const [session, setSession] = useState(null); + + const handleCreate = async () => { + setLoading(true); + try { + const redirect = `${window.location.origin}${window.location.pathname}#/private-portrait-authorized`; + const next = await createPrivatePortraitValidateSession(projectId, redirect); + setSession(next); + onCreated(next); + if (next.h5Link) window.open(next.h5Link, '_blank'); + } catch (e: any) { + message.error(e?.message || '创建真人授权链接失败'); + } finally { + setLoading(false); + } + }; + + return ( + + + + 点击生成授权链接后,终端用户需要在火山 H5 页面完成人脸授权认证。认证成功后,本项目组会绑定火山 Asset Group,后续可上传同一真人的素材。 + + + {session?.h5Link && ( +
+ {session.h5Link} +
+ )} +
+
+ ); +}; + +export default PrivatePortraitValidateModal; diff --git a/video-gen-app/src/components/privatePortrait/picker/AssetPicker.tsx b/video-gen-app/src/components/privatePortrait/picker/AssetPicker.tsx new file mode 100644 index 00000000..1fe81fe2 --- /dev/null +++ b/video-gen-app/src/components/privatePortrait/picker/AssetPicker.tsx @@ -0,0 +1,222 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Button, Empty, Input, List, Modal, Space, Spin, Tag, Typography, message } from 'antd'; +import { CheckOutlined, PictureOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'; +import { getPrivatePortraitProjects, getPrivatePortraitSelectableAssets } from '../../../api'; +import type { PrivatePortraitProject, PrivatePortraitSelectableAsset } from '../../../types'; + +const { Text } = Typography; + +interface PrivatePortraitAssetPickerProps { + open: boolean; + selectedIds?: string[]; + maxCount?: number; + onClose: () => void; + onSelect: (assets: PrivatePortraitSelectableAsset[]) => void; +} + +const getPreviewUrl = (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 PrivatePortraitAssetPicker: React.FC = ({ + open, + selectedIds = [], + maxCount = 20, + onClose, + onSelect, +}) => { + const [projects, setProjects] = useState([]); + const [projectId, setProjectId] = useState(); + const [keyword, setKeyword] = useState(''); + const [assets, setAssets] = useState([]); + const [checked, setChecked] = useState>(new Set(selectedIds)); + const [loadingProjects, setLoadingProjects] = useState(false); + const [loadingAssets, setLoadingAssets] = useState(false); + + const selectedMap = useMemo(() => { + const map = new Map(); + assets.forEach((item) => { + if (checked.has(item.id)) map.set(item.id, item); + }); + return map; + }, [assets, checked]); + + const loadProjects = async () => { + setLoadingProjects(true); + try { + const res = await getPrivatePortraitProjects({ page: 1, pageSize: 100, status: 'active' }); + setProjects(res.items || []); + if (!projectId && res.items?.length) { + setProjectId(res.items[0].id); + } + } catch (err: any) { + message.error(err?.message || '加载真人素材项目失败'); + } finally { + setLoadingProjects(false); + } + }; + + const loadAssets = async () => { + setLoadingAssets(true); + try { + const res = await getPrivatePortraitSelectableAssets({ projectId, keyword: keyword.trim() || undefined, page: 1, pageSize: 100 }); + setAssets(res.items || []); + } catch (err: any) { + message.error(err?.message || '加载真人素材失败'); + } finally { + setLoadingAssets(false); + } + }; + + useEffect(() => { + if (!open) return; + setChecked(new Set(selectedIds)); + loadProjects(); + }, [open]); + + useEffect(() => { + if (!open) return; + loadAssets(); + }, [open, projectId]); + + const toggle = (asset: PrivatePortraitSelectableAsset) => { + setChecked((prev) => { + const next = new Set(prev); + if (next.has(asset.id)) { + next.delete(asset.id); + return next; + } + if (next.size >= maxCount) { + message.warning(`最多选择 ${maxCount} 个参考素材`); + return prev; + } + next.add(asset.id); + return next; + }); + }; + + const confirm = () => { + const selected = assets.filter((item) => checked.has(item.id)); + if (!selected.length) { + message.warning('请选择至少一个真人素材'); + return; + } + onSelect(selected); + onClose(); + }; + + return ( + 取消, + , + ]} + > +
+
+ + 项目组 +
+ +
+ + } + placeholder="搜索素材名称" + value={keyword} + onChange={(e) => setKeyword(e.target.value)} + onPressEnter={loadAssets} + /> + + + + {assets.length === 0 ? ( + + ) : ( +
+ {assets.map((asset) => { + const active = checked.has(asset.id); + return ( +
toggle(asset)} + style={{ + cursor: 'pointer', + border: active ? '2px solid #8b5cf6' : '1px solid #edf0f5', + borderRadius: 12, + overflow: 'hidden', + background: '#fff', + boxShadow: active ? '0 8px 20px rgba(139,92,246,0.18)' : '0 4px 12px rgba(15,23,42,0.04)', + position: 'relative', + }} + > +
+ {asset.previewUrl ? ( + {asset.name + ) : ( + + )} +
+ {active && ( +
+ +
+ )} +
+ {asset.name || '未命名素材'} + + Active + {asset.projectName} + +
+
+ ); + })} +
+ )} +
+
+
+
+ ); +}; + +export default PrivatePortraitAssetPicker; diff --git a/video-gen-app/src/pages/GenerateConver.tsx b/video-gen-app/src/pages/GenerateConver.tsx index 1739cc5a..b1cea645 100644 --- a/video-gen-app/src/pages/GenerateConver.tsx +++ b/video-gen-app/src/pages/GenerateConver.tsx @@ -30,6 +30,8 @@ import { } from '../api'; import { useAppStore } from '../store/useAppStore'; +import { PrivatePortraitAssetPicker } from '../components/privatePortrait'; +import type { PrivatePortraitSelectableAsset } from '../types'; import { PlusOutlined, @@ -71,6 +73,9 @@ interface MediaReference { duration?: number; role?: string; label?: string; + source?: string; + private_asset_id?: string; + remote_asset_id?: string; } interface Message { @@ -167,6 +172,7 @@ const AIChatPage: React.FC = () => { const [lastFrame, setLastFrame] = useState(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(null); const openMediaStackTray = useCallback(() => { @@ -1298,6 +1304,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); @@ -2407,6 +2448,15 @@ const AIChatPage: React.FC = () => { )} + {mediaType === 'video' && currentMedia.length === 0 && ( + + )} {/* 层叠附件展示 - 鼠标移入向右排列展开 */} {currentMedia.length > 0 && ( @@ -2432,7 +2482,7 @@ const AIChatPage: React.FC = () => {
{media.type === 'image' ? ( {media.name} { setAttachmentPreviewUrl(media.url); @@ -2444,7 +2494,7 @@ const AIChatPage: React.FC = () => { /> ) : media.type === 'video' ? (
)} @@ -3906,20 +3984,28 @@ const AIChatPage: React.FC = () => {
{attachmentPreviewType === 'image' ? ( 预览 ) : (
+ setPrivateAssetPickerOpen(false)} + onSelect={handlePrivatePortraitAssetsSelected} + selectedIds={currentMedia.map((m) => m.private_asset_id).filter(Boolean) as string[]} + maxCount={maxImage} + /> + ); }; diff --git a/video-gen-app/src/pages/GeneratedRecord.tsx b/video-gen-app/src/pages/GeneratedRecord.tsx index b0a87501..4019321e 100644 --- a/video-gen-app/src/pages/GeneratedRecord.tsx +++ b/video-gen-app/src/pages/GeneratedRecord.tsx @@ -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([]); const [Pagebreak, setPagebreak] = useState({ @@ -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 = () => { > 拆镜复刻 + + {filterType !== 'private_portrait' && (
{/* 多选模式按钮 */} {isSelectionMode ? ( @@ -1085,7 +1113,12 @@ const GeneratedRecord: React.FC = () => { )}
+ )} + {filterType === 'private_portrait' ? ( + + ) : ( + <> {/* Second row filter: 视频 / 图片 */}
{ )}
)} + + )} {/* 推送任务历史弹窗 */}