真人素材库V3
This commit is contained in:
@@ -448,7 +448,7 @@ export async function deleteMenuConfig(id: string): Promise<void> {
|
||||
|
||||
// ── 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; private_portrait_image_limit?: number }): Promise<any> {
|
||||
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_asset_limit?: number }): Promise<any> {
|
||||
return api.post('/admin/users', data);
|
||||
}
|
||||
|
||||
@@ -1017,22 +1017,25 @@ export async function getPreTestTemplateList(params: PreTestTemplateListParams):
|
||||
}
|
||||
|
||||
// ── Private Portrait Admin ────────────────────────────────
|
||||
export async function adminGetPrivatePortraitProjects(params: { userId?: string; keyword?: string; status?: string; page?: number; pageSize?: number } = {}): Promise<PrivatePortraitProjectListOut> {
|
||||
export async function adminGetPrivatePortraitProjects(params: { userId?: string; libraryType?: string; keyword?: string; status?: string; page?: number; pageSize?: number } = {}): Promise<PrivatePortraitProjectListOut> {
|
||||
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.libraryType) query.set('library_type', params.libraryType);
|
||||
if (params.keyword) query.set('keyword', params.keyword);
|
||||
if (params.status) query.set('status', params.status);
|
||||
return api.get<PrivatePortraitProjectListOut>(`/admin/private-portrait/projects?${query.toString()}`);
|
||||
}
|
||||
|
||||
export async function adminGetPrivatePortraitAssets(params: { userId?: string; projectId?: string; keyword?: string; status?: string; page?: number; pageSize?: number } = {}): Promise<PrivatePortraitAssetListOut> {
|
||||
export async function adminGetPrivatePortraitAssets(params: { userId?: string; projectId?: string; libraryType?: string; assetType?: string; keyword?: string; status?: string; page?: number; pageSize?: number } = {}): Promise<PrivatePortraitAssetListOut> {
|
||||
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.libraryType) query.set('library_type', params.libraryType);
|
||||
if (params.assetType) query.set('asset_type', params.assetType);
|
||||
if (params.keyword) query.set('keyword', params.keyword);
|
||||
if (params.status) query.set('status', params.status);
|
||||
return api.get<PrivatePortraitAssetListOut>(`/admin/private-portrait/assets?${query.toString()}`);
|
||||
@@ -1044,5 +1047,5 @@ export async function adminGetPrivatePortraitConfig(userId: string): Promise<Pri
|
||||
}
|
||||
|
||||
export async function adminUpdatePrivatePortraitConfig(userId: string, limit: number): Promise<PrivatePortraitConfig> {
|
||||
return api.put<PrivatePortraitConfig>(`/admin/private-portrait/users/${userId}/config`, { private_portrait_image_limit: limit });
|
||||
return api.put<PrivatePortraitConfig>(`/admin/private-portrait/users/${userId}/config`, { private_portrait_asset_limit: limit });
|
||||
}
|
||||
|
||||
@@ -1,20 +1,102 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Card, Input, Space, Table, Tag, Typography, message } from 'antd';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Card, Image, Input, Select, Space, Statistic, Table, Tag, Tooltip, Typography, message } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { CopyOutlined, PlayCircleOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { adminGetPrivatePortraitAssets, adminGetPrivatePortraitProjects } from '../api';
|
||||
import type { PrivatePortraitAsset, PrivatePortraitProject } from '../types';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const LIBRARY_OPTIONS = [
|
||||
{ label: '全部素材库', value: '' },
|
||||
{ label: '真人认证', value: 'real_person' },
|
||||
{ label: '虚拟人像', value: 'aigc_virtual' },
|
||||
];
|
||||
|
||||
const ASSET_TYPE_OPTIONS = [
|
||||
{ label: '全部类型', value: '' },
|
||||
{ label: '图片', value: 'Image' },
|
||||
{ label: '视频', value: 'Video' },
|
||||
];
|
||||
|
||||
const PROJECT_STATUS_OPTIONS = [
|
||||
{ label: '全部项目状态', value: '' },
|
||||
{ label: '可用', value: 'active' },
|
||||
{ label: '认证中', value: 'validating' },
|
||||
{ label: '认证失败', value: 'validate_failed' },
|
||||
{ label: '创建远端组中', value: 'creating_remote_group' },
|
||||
{ label: '创建远端组失败', value: 'create_group_failed' },
|
||||
{ label: '已删除', value: 'deleted' },
|
||||
];
|
||||
|
||||
const ASSET_STATUS_OPTIONS = [
|
||||
{ label: '全部素材状态', value: '' },
|
||||
{ label: '创建中', value: 'creating' },
|
||||
{ label: '处理中', value: 'Processing' },
|
||||
{ label: '可用', value: 'Active' },
|
||||
{ label: '失败', value: 'Failed' },
|
||||
{ label: '本地已删', value: 'local_deleted' },
|
||||
{ label: '远端已删', value: 'remote_deleted' },
|
||||
{ label: '删除失败', value: 'delete_failed' },
|
||||
];
|
||||
|
||||
const libraryLabel = (value?: string | null) => {
|
||||
if (value === 'real_person') return '真人认证';
|
||||
if (value === 'aigc_virtual') return '虚拟人像';
|
||||
return value || '-';
|
||||
};
|
||||
|
||||
const libraryColor = (value?: string | null) => {
|
||||
if (value === 'real_person') return 'purple';
|
||||
if (value === 'aigc_virtual') return 'cyan';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
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 === 'Processing' || status === 'creating_remote_group' || status === 'validating' || status === 'creating') return 'blue';
|
||||
if (status === 'Failed' || status === 'failed' || status === 'delete_failed' || status === 'validate_failed' || status === 'create_group_failed') return 'red';
|
||||
if (status?.includes('deleted')) return 'default';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const assetTypeLabel = (value?: string | null) => {
|
||||
if (value === 'Image') return '图片';
|
||||
if (value === 'Video') return '视频';
|
||||
return value || '-';
|
||||
};
|
||||
|
||||
const shortId = (value?: string | null, keep = 10) => {
|
||||
if (!value) return '-';
|
||||
if (value.length <= keep * 2 + 3) return value;
|
||||
return `${value.slice(0, keep)}...${value.slice(-keep)}`;
|
||||
};
|
||||
|
||||
const copyText = async (value?: string | null) => {
|
||||
if (!value) return;
|
||||
await navigator.clipboard?.writeText(value);
|
||||
message.success('已复制');
|
||||
};
|
||||
|
||||
const PreviewCell: React.FC<{ asset: PrivatePortraitAsset }> = ({ asset }) => {
|
||||
const url = asset.displayUrl || asset.previewUrl || asset.remoteUrl || asset.sourceUrl;
|
||||
const cover = asset.videoCoverUrl || asset.previewUrl || asset.displayUrl;
|
||||
if (asset.assetType === 'Video') {
|
||||
return (
|
||||
<div style={{ width: 72, height: 52, borderRadius: 10, overflow: 'hidden', background: '#f5f3ff', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
|
||||
{cover ? <img src={cover} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : <PlayCircleOutlined style={{ fontSize: 24, color: '#8b5cf6' }} />}
|
||||
{url ? <a href={url} target="_blank" rel="noreferrer" style={{ position: 'absolute', inset: 0 }} /> : null}
|
||||
<PlayCircleOutlined style={{ position: 'absolute', color: '#fff', fontSize: 22, filter: 'drop-shadow(0 1px 3px rgba(0,0,0,.45))' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (url) return <Image width={72} height={52} src={url} style={{ objectFit: 'cover', borderRadius: 10 }} />;
|
||||
return <div style={{ width: 72, height: 52, borderRadius: 10, background: '#f5f5f5' }} />;
|
||||
};
|
||||
|
||||
const AdminPrivatePortraitProjects: React.FC = () => {
|
||||
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
|
||||
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
|
||||
@@ -23,18 +105,36 @@ const AdminPrivatePortraitProjects: React.FC = () => {
|
||||
const [loadingProjects, setLoadingProjects] = useState(false);
|
||||
const [loadingAssets, setLoadingAssets] = useState(false);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [libraryType, setLibraryType] = useState('');
|
||||
const [assetType, setAssetType] = useState('');
|
||||
const [projectStatus, setProjectStatus] = useState('');
|
||||
const [assetStatus, setAssetStatus] = useState('');
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | undefined>();
|
||||
const [projectPage, setProjectPage] = useState(1);
|
||||
const [assetPage, setAssetPage] = useState(1);
|
||||
|
||||
const filters = useMemo(() => ({
|
||||
keyword: keyword.trim() || undefined,
|
||||
libraryType: libraryType || undefined,
|
||||
assetType: assetType || undefined,
|
||||
projectStatus: projectStatus || undefined,
|
||||
assetStatus: assetStatus || undefined,
|
||||
}), [keyword, libraryType, assetType, projectStatus, assetStatus]);
|
||||
|
||||
const loadProjects = async () => {
|
||||
setLoadingProjects(true);
|
||||
try {
|
||||
const res = await adminGetPrivatePortraitProjects({ keyword: keyword.trim() || undefined, page: projectPage, pageSize: 20 });
|
||||
const res = await adminGetPrivatePortraitProjects({
|
||||
keyword: filters.keyword,
|
||||
libraryType: filters.libraryType,
|
||||
status: filters.projectStatus,
|
||||
page: projectPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
setProjects(res.items || []);
|
||||
setProjectTotal(res.total || 0);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '加载真人素材项目失败');
|
||||
message.error(err?.message || '加载私域人像素材项目失败');
|
||||
} finally {
|
||||
setLoadingProjects(false);
|
||||
}
|
||||
@@ -43,88 +143,111 @@ const AdminPrivatePortraitProjects: React.FC = () => {
|
||||
const loadAssets = async () => {
|
||||
setLoadingAssets(true);
|
||||
try {
|
||||
const res = await adminGetPrivatePortraitAssets({ projectId: selectedProjectId, keyword: keyword.trim() || undefined, page: assetPage, pageSize: 20 });
|
||||
const res = await adminGetPrivatePortraitAssets({
|
||||
projectId: selectedProjectId,
|
||||
keyword: filters.keyword,
|
||||
libraryType: filters.libraryType,
|
||||
assetType: filters.assetType,
|
||||
status: filters.assetStatus,
|
||||
page: assetPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
setAssets(res.items || []);
|
||||
setAssetTotal(res.total || 0);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '加载真人素材失败');
|
||||
message.error(err?.message || '加载私域人像素材失败');
|
||||
} finally {
|
||||
setLoadingAssets(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { loadProjects(); }, [projectPage]);
|
||||
useEffect(() => { loadAssets(); }, [selectedProjectId, assetPage]);
|
||||
useEffect(() => { loadProjects(); }, [projectPage, filters.libraryType, filters.projectStatus]);
|
||||
useEffect(() => { loadAssets(); }, [selectedProjectId, assetPage, filters.libraryType, filters.assetType, filters.assetStatus]);
|
||||
|
||||
const searchAll = () => {
|
||||
setProjectPage(1);
|
||||
setAssetPage(1);
|
||||
setTimeout(() => { loadProjects(); loadAssets(); }, 0);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setKeyword('');
|
||||
setLibraryType('');
|
||||
setAssetType('');
|
||||
setProjectStatus('');
|
||||
setAssetStatus('');
|
||||
setSelectedProjectId(undefined);
|
||||
setProjectPage(1);
|
||||
setAssetPage(1);
|
||||
};
|
||||
|
||||
const projectSummary = useMemo(() => {
|
||||
return projects.reduce((acc, item) => {
|
||||
acc.asset += item.assetCount || 0;
|
||||
acc.image += item.imageAssetCount || 0;
|
||||
acc.video += item.videoAssetCount || 0;
|
||||
acc.active += item.activeAssetCount || 0;
|
||||
return acc;
|
||||
}, { asset: 0, image: 0, video: 0, active: 0 });
|
||||
}, [projects]);
|
||||
|
||||
const projectColumns: ColumnsType<PrivatePortraitProject> = [
|
||||
{ title: '项目名称', dataIndex: 'name', width: 180, render: (v, r) => <Button type="link" onClick={() => { setSelectedProjectId(r.id); setAssetPage(1); }}>{v}</Button> },
|
||||
{ title: '用户ID', dataIndex: 'userId', width: 170, render: (v) => <Text code>{v || '-'}</Text> },
|
||||
{ title: '状态', dataIndex: 'status', width: 100, render: (v) => <Tag color={statusColor(v)}>{v}</Tag> },
|
||||
{ title: 'ProjectName', dataIndex: 'remoteProjectName', width: 240, render: (v) => <Text code copyable>{v || '-'}</Text> },
|
||||
{ title: '素材数', dataIndex: 'assetCount', width: 100 },
|
||||
{ title: 'Active', dataIndex: 'activeAssetCount', width: 100 },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: (v) => v || '-' },
|
||||
{ title: '项目名称', dataIndex: 'name', width: 200, fixed: 'left', render: (v, r) => <Button type="link" onClick={() => { setSelectedProjectId(r.id); setAssetPage(1); }}>{v}</Button> },
|
||||
{ title: '类型', dataIndex: 'libraryType', width: 110, render: (v) => <Tag color={libraryColor(v)}>{libraryLabel(v)}</Tag> },
|
||||
{ title: '用户ID', dataIndex: 'userId', width: 170, render: (v) => <Text code copyable={!!v}>{v || '-'}</Text> },
|
||||
{ title: '状态', dataIndex: 'status', width: 130, render: (v) => <Tag color={statusColor(v)}>{v}</Tag> },
|
||||
{ title: '素材', width: 170, render: (_, r) => <Space size={4}><Tag>总 {r.assetCount || 0}</Tag><Tag color="blue">图 {r.imageAssetCount || 0}</Tag><Tag color="geekblue">视 {r.videoAssetCount || 0}</Tag></Space> },
|
||||
{ title: 'Active', dataIndex: 'activeAssetCount', width: 90 },
|
||||
{ title: 'ProjectName', dataIndex: 'remoteProjectName', width: 180, render: (v) => <Text code copyable={!!v}>{v || '-'}</Text> },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: formatDate },
|
||||
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: formatDate },
|
||||
];
|
||||
|
||||
const assetColumns: ColumnsType<PrivatePortraitAsset> = [
|
||||
{ title: '素材', dataIndex: 'name', width: 180, render: (v, r) => v || r.remoteAssetId || '-' },
|
||||
{ title: '用户ID', dataIndex: 'userId', width: 170, render: (v) => <Text code>{v || '-'}</Text> },
|
||||
{ title: '本地项目', dataIndex: 'projectName', width: 160 },
|
||||
{ title: 'AssetId', dataIndex: 'remoteAssetId', width: 240, render: (v) => <Text code copyable>{v}</Text> },
|
||||
{ title: 'GroupId', dataIndex: 'remoteGroupId', width: 240, render: (v) => <Text code copyable>{v}</Text> },
|
||||
{ title: 'ProjectName', dataIndex: 'remoteProjectName', width: 240, render: (v) => <Text code copyable>{v || '-'}</Text> },
|
||||
{ title: '类型', dataIndex: 'assetType', width: 90 },
|
||||
{ title: '状态', dataIndex: 'status', width: 110, render: (v) => <Tag color={statusColor(v)}>{v}</Tag> },
|
||||
{ title: '错误', dataIndex: 'errorMessage', width: 240, ellipsis: true, render: (v) => v || '-' },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: (v) => v || '-' },
|
||||
{ title: '预览', width: 100, fixed: 'left', render: (_, r) => <PreviewCell asset={r} /> },
|
||||
{ title: '素材名称', dataIndex: 'name', width: 180, render: (v, r) => <div><Text strong>{v || '未命名素材'}</Text><br /><Text type="secondary">{assetTypeLabel(r.assetType)}</Text></div> },
|
||||
{ title: '库类型', dataIndex: 'libraryType', width: 110, render: (v) => <Tag color={libraryColor(v)}>{libraryLabel(v)}</Tag> },
|
||||
{ title: '用户ID', dataIndex: 'userId', width: 170, render: (v) => <Text code copyable={!!v}>{v || '-'}</Text> },
|
||||
{ title: '本地项目', dataIndex: 'projectName', width: 160, render: (v) => v || '-' },
|
||||
{ title: '状态', dataIndex: 'status', width: 120, render: (v) => <Tag color={statusColor(v)}>{v}</Tag> },
|
||||
{ title: 'AssetId', dataIndex: 'remoteAssetId', width: 190, render: (v) => <Space><Text code>{shortId(v)}</Text>{v ? <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyText(v)} /> : null}</Space> },
|
||||
{ title: 'GroupId', dataIndex: 'remoteGroupId', width: 190, render: (v) => <Space><Text code>{shortId(v)}</Text>{v ? <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyText(v)} /> : null}</Space> },
|
||||
{ title: '轮询', width: 160, render: (_, r) => <div><Text>次数:{r.pollCount || 0}</Text><br /><Text type="secondary">下次:{formatDate((r as any).nextPollAt)}</Text></div> },
|
||||
{ title: '错误', dataIndex: 'errorMessage', width: 240, ellipsis: true, render: (v) => v ? <Tooltip title={v}><Text type="danger">{v}</Text></Tooltip> : '-' },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: formatDate },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<div style={{ padding: 24, background: '#f7f7fb', minHeight: '100%' }}>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }} align="start">
|
||||
<div>
|
||||
<Title level={3} style={{ margin: 0 }}>真人素材库</Title>
|
||||
<Text type="secondary">只读排查页:查看用户项目组、Asset Group 与 Asset 状态。</Text>
|
||||
<Title level={3} style={{ margin: 0 }}>私域人像素材库</Title>
|
||||
<Text type="secondary">统一查看真人认证素材与虚拟人像素材,支持图片/视频资产状态排查。</Text>
|
||||
</div>
|
||||
<Space>
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索项目/素材"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onPressEnter={() => { setProjectPage(1); setAssetPage(1); loadProjects(); loadAssets(); }}
|
||||
style={{ width: 260 }}
|
||||
/>
|
||||
<Space wrap>
|
||||
<Input allowClear prefix={<SearchOutlined />} placeholder="搜索项目/素材" value={keyword} onChange={(e) => setKeyword(e.target.value)} onPressEnter={searchAll} style={{ width: 240 }} />
|
||||
<Select value={libraryType} onChange={(v) => { setLibraryType(v); setProjectPage(1); setAssetPage(1); }} options={LIBRARY_OPTIONS} style={{ width: 150 }} />
|
||||
<Select value={assetType} onChange={(v) => { setAssetType(v); setAssetPage(1); }} options={ASSET_TYPE_OPTIONS} style={{ width: 130 }} />
|
||||
<Button icon={<SearchOutlined />} type="primary" onClick={searchAll}>查询</Button>
|
||||
<Button onClick={resetFilters}>重置</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => { loadProjects(); loadAssets(); }}>刷新</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
<Card title="真人素材项目组" style={{ marginBottom: 16 }}>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={projectColumns}
|
||||
dataSource={projects}
|
||||
loading={loadingProjects}
|
||||
pagination={{ current: projectPage, pageSize: 20, total: projectTotal, onChange: setProjectPage }}
|
||||
size="small"
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
<Space size={16} wrap style={{ marginBottom: 16 }}>
|
||||
<Card style={{ width: 180 }}><Statistic title="项目数" value={projectTotal} /></Card>
|
||||
<Card style={{ width: 180 }}><Statistic title="当前页素材" value={projectSummary.asset} /></Card>
|
||||
<Card style={{ width: 180 }}><Statistic title="图片" value={projectSummary.image} /></Card>
|
||||
<Card style={{ width: 180 }}><Statistic title="视频" value={projectSummary.video} /></Card>
|
||||
<Card style={{ width: 180 }}><Statistic title="Active" value={projectSummary.active} /></Card>
|
||||
</Space>
|
||||
|
||||
<Card title="项目组" style={{ marginBottom: 16, borderRadius: 14 }} extra={<Select value={projectStatus} onChange={(v) => { setProjectStatus(v); setProjectPage(1); }} options={PROJECT_STATUS_OPTIONS} style={{ width: 180 }} />}>
|
||||
<Table rowKey="id" columns={projectColumns} dataSource={projects} loading={loadingProjects} pagination={{ current: projectPage, pageSize: PAGE_SIZE, total: projectTotal, onChange: setProjectPage }} size="small" scroll={{ x: 1350 }} />
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={selectedProjectId ? '项目素材明细' : '全部素材明细'}
|
||||
extra={selectedProjectId ? <Button size="small" onClick={() => setSelectedProjectId(undefined)}>查看全部</Button> : null}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={assetColumns}
|
||||
dataSource={assets}
|
||||
loading={loadingAssets}
|
||||
pagination={{ current: assetPage, pageSize: 20, total: assetTotal, onChange: setAssetPage }}
|
||||
size="small"
|
||||
scroll={{ x: 1600 }}
|
||||
/>
|
||||
<Card title={selectedProjectId ? '项目素材明细' : '全部素材明细'} style={{ borderRadius: 14 }} extra={<Space><Select value={assetStatus} onChange={(v) => { setAssetStatus(v); setAssetPage(1); }} options={ASSET_STATUS_OPTIONS} style={{ width: 170 }} />{selectedProjectId ? <Button size="small" onClick={() => setSelectedProjectId(undefined)}>查看全部</Button> : null}</Space>}>
|
||||
<Table rowKey="id" columns={assetColumns} dataSource={assets} loading={loadingAssets} pagination={{ current: assetPage, pageSize: PAGE_SIZE, total: assetTotal, onChange: setAssetPage }} size="small" scroll={{ x: 1700 }} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -189,7 +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,
|
||||
private_portrait_asset_limit: userType === 'frontend' ? Number(values.private_portrait_asset_limit ?? 5) : 0,
|
||||
});
|
||||
message.success('用户创建成功');
|
||||
setCreateModal(false);
|
||||
@@ -276,13 +276,13 @@ const AdminUsers: React.FC = () => {
|
||||
const openPortraitModal = async (user: AdminUser) => {
|
||||
setPortraitLoading(true);
|
||||
setPortraitModal({ open: true, user, config: null });
|
||||
portraitForm.setFieldsValue({ privatePortraitImageLimit: user.privatePortraitImageLimit ?? 5 });
|
||||
portraitForm.setFieldsValue({ privatePortraitAssetLimit: user.privatePortraitAssetLimit ?? 5 });
|
||||
try {
|
||||
const config = await adminGetPrivatePortraitConfig(user.id);
|
||||
portraitForm.setFieldsValue({ privatePortraitImageLimit: config.imageLimit });
|
||||
portraitForm.setFieldsValue({ privatePortraitAssetLimit: config.assetLimit });
|
||||
setPortraitModal({ open: true, user, config });
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载真人素材库配置失败');
|
||||
message.error(e?.message || '加载私域人像素材库配置失败');
|
||||
setPortraitModal({ open: false, user: null, config: null });
|
||||
} finally {
|
||||
setPortraitLoading(false);
|
||||
@@ -294,16 +294,16 @@ const AdminUsers: React.FC = () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
const values = await portraitForm.validateFields();
|
||||
const limit = Number(values.privatePortraitImageLimit ?? 0);
|
||||
const limit = Number(values.privatePortraitAssetLimit ?? 0);
|
||||
setPortraitSaving(true);
|
||||
const config = await adminUpdatePrivatePortraitConfig(user.id, limit);
|
||||
message.success(limit > 0 ? `真人素材库已开启,限制 ${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 || '保存真人素材库配置失败');
|
||||
message.error(e?.message || '保存私域人像素材库配置失败');
|
||||
} finally {
|
||||
setPortraitSaving(false);
|
||||
}
|
||||
@@ -424,10 +424,10 @@ const AdminUsers: React.FC = () => {
|
||||
render: (v: string | null | undefined) => v ? <Tag color="blue">{v}</Tag> : <Typography.Text type="secondary">未分配</Typography.Text>,
|
||||
}] : []),
|
||||
...(!isAdminTab ? [{
|
||||
title: '真人素材库', dataIndex: 'privatePortraitImageLimit', width: 150,
|
||||
title: '私域人像素材库', dataIndex: 'privatePortraitAssetLimit', width: 150,
|
||||
render: (v: number) => {
|
||||
const limit = Number(v || 0);
|
||||
return limit > 0 ? <Tag color="purple">开启:{limit} 张</Tag> : <Tag>未开启</Tag>;
|
||||
return limit > 0 ? <Tag color="purple">开启:{limit} 个</Tag> : <Tag>未开启</Tag>;
|
||||
},
|
||||
}] : []),
|
||||
...(!isAdminTab ? [{
|
||||
@@ -493,7 +493,7 @@ const AdminUsers: React.FC = () => {
|
||||
{!isAdminTab && (
|
||||
<Button type="link" size="small" icon={<PictureOutlined />}
|
||||
onClick={() => openPortraitModal(r)}>
|
||||
真人素材
|
||||
私域人像素材
|
||||
</Button>
|
||||
)}
|
||||
{!isAdminTab && (
|
||||
@@ -784,7 +784,7 @@ const AdminUsers: React.FC = () => {
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={<Space><PictureOutlined />真人素材库设置 - {portraitModal.user?.username}</Space>}
|
||||
title={<Space><PictureOutlined />私域人像素材库设置 - {portraitModal.user?.username}</Space>}
|
||||
open={portraitModal.open}
|
||||
confirmLoading={portraitSaving}
|
||||
onOk={handleSavePortraitConfig}
|
||||
@@ -797,17 +797,17 @@ const AdminUsers: React.FC = () => {
|
||||
当前状态:{portraitModal.config?.enabled ? <Tag color="purple">已开启</Tag> : <Tag>未开启</Tag>}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
已用:{portraitModal.config?.usedImageCount ?? '-'} 张;
|
||||
剩余:{portraitModal.config?.enabled ? portraitModal.config.remainingImageCount : 0} 张
|
||||
已用:{portraitModal.config?.usedAssetCount ?? '-'} 个;
|
||||
剩余:{portraitModal.config?.enabled ? portraitModal.config.remainingAssetCount : 0} 个
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Card>
|
||||
<Form form={portraitForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="privatePortraitImageLimit"
|
||||
label="真人素材库图片上限"
|
||||
extra="0 表示关闭真人素材库;大于 0 表示开启,并限制该用户所有真人素材图片总量。"
|
||||
rules={[{ required: true, message: '请输入真人素材库图片上限' }]}
|
||||
name="privatePortraitAssetLimit"
|
||||
label="私域人像素材总量上限"
|
||||
extra="0 表示关闭私域人像素材库;大于 0 表示开启,并限制该用户所有私域人像素材总量。"
|
||||
rules={[{ required: true, message: '请输入私域人像素材总量上限' }]}
|
||||
>
|
||||
<InputNumber min={0} max={9999} precision={0} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
@@ -857,11 +857,11 @@ const AdminUsers: React.FC = () => {
|
||||
)}
|
||||
{createType === 'frontend' && (
|
||||
<Form.Item
|
||||
name="private_portrait_image_limit"
|
||||
label="真人素材库图片上限"
|
||||
name="private_portrait_asset_limit"
|
||||
label="私域人像素材总量上限"
|
||||
initialValue={5}
|
||||
extra="0 表示关闭真人素材库;大于 0 表示开启并限制该用户所有真人素材图片总量。"
|
||||
rules={[{ required: true, message: '请输入真人素材库图片上限' }]}
|
||||
extra="0 表示关闭私域人像素材库;大于 0 表示开启并限制该用户所有私域人像素材总量。"
|
||||
rules={[{ required: true, message: '请输入私域人像素材总量上限' }]}
|
||||
>
|
||||
<InputNumber min={0} max={9999} precision={0} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface User {
|
||||
userType: string;
|
||||
allowedMenus?: string[] | null;
|
||||
resourceCapacity?: ResourceCapacityUsage | null;
|
||||
privatePortraitImageLimit: number;
|
||||
privatePortraitAssetLimit: number;
|
||||
}
|
||||
|
||||
export interface CreditRecord {
|
||||
@@ -194,7 +194,7 @@ export interface AdminUser {
|
||||
lastLoginAt?: string;
|
||||
allowedMenus?: string[] | null;
|
||||
resourceCapacity?: ResourceCapacityUsage | null;
|
||||
privatePortraitImageLimit: number;
|
||||
privatePortraitAssetLimit: number;
|
||||
}
|
||||
|
||||
export interface AdminStats {
|
||||
@@ -1125,14 +1125,20 @@ export interface HomeMaterialTextWatermarkPreviewResponse {
|
||||
|
||||
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 interface PrivatePortraitProject {
|
||||
id: string;
|
||||
userId?: string | null;
|
||||
libraryType: string;
|
||||
name: string;
|
||||
nameSlug?: string | null;
|
||||
remoteProjectName?: string | null;
|
||||
@@ -1140,7 +1146,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;
|
||||
@@ -1174,6 +1184,7 @@ export interface PrivatePortraitAsset {
|
||||
projectId: string;
|
||||
projectName?: string | null;
|
||||
groupId: string;
|
||||
libraryType: string;
|
||||
remoteGroupId: string;
|
||||
remoteAssetId?: string | null;
|
||||
remoteProjectName?: string | null;
|
||||
@@ -1181,7 +1192,13 @@ export interface PrivatePortraitAsset {
|
||||
name?: string | null;
|
||||
sourceUrl: string;
|
||||
previewUrl?: string | null;
|
||||
displayUrl?: string | null;
|
||||
providerUrl?: string | null;
|
||||
remoteUrl?: string | null;
|
||||
videoDuration?: number | null;
|
||||
videoCoverUrl?: string | null;
|
||||
fileSize?: number | null;
|
||||
mimeType?: string | null;
|
||||
status: string;
|
||||
pollCount: number;
|
||||
remoteDeleteStatus: string;
|
||||
@@ -1201,9 +1218,14 @@ export interface PrivatePortraitSelectableAsset {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
libraryType: string;
|
||||
name?: string | null;
|
||||
assetType: string;
|
||||
previewUrl?: string | null;
|
||||
displayUrl?: string | null;
|
||||
providerUrl?: string | null;
|
||||
videoDuration?: number | null;
|
||||
videoCoverUrl?: string | null;
|
||||
status: string;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user