真人素材库修复相关BUG

This commit is contained in:
2026-07-07 10:48:52 +08:00
parent ab995ce3cf
commit 126754c933
20 changed files with 630 additions and 79 deletions
+2
View File
@@ -34,6 +34,7 @@ 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 { useAuthStore } from './store/useAuthStore';
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const { user, loading, checkAuth } = useAuthStore();
@@ -92,6 +93,7 @@ const App = () => {
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/private-portrait-authorized" element={<PrivatePortraitAuthorizeResult />} />
<Route
path="/"
element={
+7 -3
View File
@@ -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> {
@@ -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>
);
};
@@ -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;
+10
View File
@@ -134,6 +134,9 @@ export interface MediaReference {
source?: string;
private_asset_id?: string;
remote_asset_id?: string;
providerUrl?: string;
displayUrl?: string;
previewUrl?: string;
}
export interface GenerationRecord {
@@ -297,6 +300,13 @@ export interface PrivatePortraitValidateSession {
updatedAt?: string | null;
}
export interface PrivatePortraitProjectCreateWithValidateOut {
project: PrivatePortraitProject;
validateSession: PrivatePortraitValidateSession;
pollIntervalMs: number;
}
export interface PrivatePortraitAsset {
id: string;
userId?: string | null;