196 lines
8.1 KiB
TypeScript
196 lines
8.1 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import { Button, Card, Col, Empty, 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 RealPersonLibraryPanel: 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, 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) {
|
|
message.error(e?.message || '加载真人素材项目组失败');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
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 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 || '创建项目组认证二维码失败');
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
};
|
|
|
|
const h5Link = validateSession?.h5Link || '';
|
|
const isSuccess = validateSession?.status === VALIDATE_SUCCESS_STATUS;
|
|
|
|
return (
|
|
<div>
|
|
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<div>
|
|
<Typography.Title level={4} style={{ margin: 0 }}>真人素材库</Typography.Title>
|
|
<Typography.Text type="secondary">创建项目组时先完成真人认证,认证成功后项目组才会正式创建并可上传素材。</Typography.Text>
|
|
</div>
|
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>创建项目组</Button>
|
|
</div>
|
|
<Row gutter={16}>
|
|
<Col xs={24} lg={5}>
|
|
<Card
|
|
// title="真人素材项目组"
|
|
style={{ borderRadius: 16, minHeight: 520 }}
|
|
>
|
|
<Spin spinning={loading}>
|
|
{projects.length === 0 ? (
|
|
<Empty description="暂无真人项目组" />
|
|
) : (
|
|
<PrivatePortraitProjectList items={projects} selectedId={selected?.id} onSelect={setSelected} />
|
|
)}
|
|
</Spin>
|
|
</Card>
|
|
</Col>
|
|
<Col xs={24} lg={19}>
|
|
{selected ? (
|
|
<PrivatePortraitProjectDetail project={selected} onDeleted={() => { setSelected(null); loadProjects(); }} onChanged={loadProjects} />
|
|
) : (
|
|
<Card style={{ borderRadius: 16, minHeight: 520, textAlign: 'center', color: '#94a3b8' }}>请先创建或选择一个真人素材项目组</Card>
|
|
)}
|
|
</Col>
|
|
</Row>
|
|
<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>
|
|
);
|
|
};
|
|
|
|
export default RealPersonLibraryPanel;
|