feat(P1): 素材库状态视觉区分 + 上传后立即可见 #536
Regular → Executable
+17
-7
@@ -157,13 +157,23 @@ export const deleteAssetLibrary = async (libraryId: string): Promise<void> => {
|
||||
// ─── 素材 ──────────────────────────────────────────────────
|
||||
|
||||
/** 获取素材库下的所有素材 */
|
||||
export const getAssets = async (libraryId: string): Promise<AssetItem[]> => {
|
||||
const response = await apiClient.get("/assets", {
|
||||
params: { library_id: libraryId },
|
||||
})
|
||||
const items: AssetItem[] = response.data.items || []
|
||||
// 过滤掉已删除的素材(后端列表可能返回 deleted 状态,前端兜底)
|
||||
return items.filter((item) => item.status !== "deleted")
|
||||
export const getAssets = async (
|
||||
libraryId: string,
|
||||
options?: { status?: string; page?: number; page_size?: number },
|
||||
): Promise<{ items: AssetItem[]; total: number }> => {
|
||||
const params: Record<string, string | number> = { library_id: libraryId }
|
||||
// 默认拉取所有非删除状态的素材(ready/ingesting/processing/uploading/error/failed)
|
||||
// 让用户能看到"处理中"的素材,不会以为上传失败了
|
||||
if (options?.status) {
|
||||
params.status = options.status
|
||||
}
|
||||
if (options?.page) params.page = options.page
|
||||
if (options?.page_size) params.page_size = options.page_size
|
||||
const response = await apiClient.get("/assets", { params })
|
||||
const data = response.data || {}
|
||||
const items: AssetItem[] = data.items || []
|
||||
const total: number = typeof data.total === "number" ? data.total : items.length
|
||||
return { items, total }
|
||||
}
|
||||
|
||||
/** 按类型获取素材(如 voice/video/image),支持可选筛选 */
|
||||
|
||||
@@ -74,6 +74,8 @@ interface AssetItem {
|
||||
fileUrl?: string
|
||||
status: StatusType
|
||||
statusLabel: string
|
||||
/** 是否处于处理中状态(上传中/入库中/诊断中) */
|
||||
loading?: boolean
|
||||
duration?: string
|
||||
size: number
|
||||
createdAt: string
|
||||
@@ -94,11 +96,24 @@ const inferStatus = (
|
||||
score?: number,
|
||||
classificationStatus?: string,
|
||||
assetStatus?: string,
|
||||
): { status: StatusType; label: string } => {
|
||||
): { status: StatusType; label: string; loading?: boolean } => {
|
||||
// 已删除素材(正常情况列表已过滤,这里是防御性处理)
|
||||
if (assetStatus === "deleted") {
|
||||
return { status: "bad", label: "已删除" }
|
||||
}
|
||||
// 处理中状态:上传中 / 入库中 / 处理中
|
||||
if (
|
||||
assetStatus === "uploading" ||
|
||||
assetStatus === "ingesting" ||
|
||||
assetStatus === "processing" ||
|
||||
assetStatus === "pending"
|
||||
) {
|
||||
return { status: "info", label: "处理中", loading: true }
|
||||
}
|
||||
// 失败状态
|
||||
if (assetStatus === "error" || assetStatus === "failed") {
|
||||
return { status: "bad", label: "处理失败" }
|
||||
}
|
||||
// 素材已就绪(status=ready)时,不应因 classification 未执行而显示"处理中"
|
||||
if (assetStatus === "ready") {
|
||||
if (score == null) return { status: "info", label: "待诊断" }
|
||||
@@ -108,7 +123,7 @@ const inferStatus = (
|
||||
}
|
||||
// 素材未就绪:classification 正在处理中
|
||||
if (classificationStatus === "processing" || classificationStatus === "pending") {
|
||||
return { status: "info", label: "处理中" }
|
||||
return { status: "info", label: "处理中", loading: true }
|
||||
}
|
||||
if (score == null) return { status: "info", label: "待诊断" }
|
||||
if (score >= 70) return { status: "ok", label: "合格" }
|
||||
@@ -133,7 +148,7 @@ const mapLibrary = (item: AssetLibraryItem): LibraryItem => ({
|
||||
|
||||
/** 将后端 ApiAssetItem 映射为前端 AssetItem */
|
||||
const mapAsset = (item: ApiAssetItem): AssetItem => {
|
||||
const { status, label } = inferStatus(
|
||||
const { status, label, loading } = inferStatus(
|
||||
item.quality_score ?? undefined,
|
||||
item.classification_status ?? undefined,
|
||||
item.status ?? undefined,
|
||||
@@ -145,13 +160,16 @@ const mapAsset = (item: ApiAssetItem): AssetItem => {
|
||||
name: item.name,
|
||||
kind,
|
||||
// 视频类型不能用 file_url 做缩略图(是视频文件,<img> 无法渲染)
|
||||
thumbUrl:
|
||||
(item.thumbnail_url as string | undefined) ||
|
||||
(metadata.thumbnail_url as string | undefined) ||
|
||||
(kind !== "video" ? (item.file_url as string | undefined) : undefined),
|
||||
// 处理中的素材没有缩略图,显示占位符
|
||||
thumbUrl: loading
|
||||
? undefined
|
||||
: (item.thumbnail_url as string | undefined) ||
|
||||
(metadata.thumbnail_url as string | undefined) ||
|
||||
(kind !== "video" ? (item.file_url as string | undefined) : undefined),
|
||||
fileUrl: (item.file_url as string | undefined) || (metadata.file_url as string | undefined),
|
||||
status,
|
||||
statusLabel: label,
|
||||
loading,
|
||||
duration: metadata.duration != null ? formatDuration(metadata.duration as number) : undefined,
|
||||
size: item.file_size ? +(item.file_size / (1024 * 1024)).toFixed(1) : 0,
|
||||
createdAt: item.created_at ? new Date(item.created_at).toISOString().slice(0, 10) : "—",
|
||||
@@ -242,11 +260,29 @@ const AssetCard: React.FC<{
|
||||
{asset.thumbUrl ? (
|
||||
<img src={asset.thumbUrl} alt={asset.name} />
|
||||
) : (
|
||||
<span className="xx-asset-thumb-placeholder">{kindIcon(asset.kind)}</span>
|
||||
<span className="xx-asset-thumb-placeholder">
|
||||
{asset.loading ? <LoadingOutlined /> : kindIcon(asset.kind)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 视频/配音类显示播放按钮 */}
|
||||
{asset.kind === "video" && (
|
||||
{/* 处理中遮罩 */}
|
||||
{asset.loading && (
|
||||
<div className="xx-asset-thumb-overlay xx-asset-thumb-processing">
|
||||
<LoadingOutlined />
|
||||
<span>处理中</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 失败状态标识 */}
|
||||
{asset.status === "bad" && asset.statusLabel === "处理失败" && (
|
||||
<div className="xx-asset-thumb-overlay xx-asset-thumb-failed">
|
||||
<CloseCircleOutlined />
|
||||
<span>处理失败</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 视频/配音类显示播放按钮(处理中/失败不显示) */}
|
||||
{asset.kind === "video" && !asset.loading && asset.status !== "bad" && (
|
||||
<span
|
||||
className="xx-asset-play"
|
||||
onClick={(e) => {
|
||||
@@ -295,7 +331,7 @@ const AssetCard: React.FC<{
|
||||
</div>
|
||||
<button
|
||||
className={`xx-asset-diagnose-btn${diagnosing ? " xx-asset-diagnose-btn-loading" : ""}`}
|
||||
disabled={diagnosing}
|
||||
disabled={diagnosing || asset.loading || asset.status === "bad"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDiagnose()
|
||||
@@ -334,20 +370,24 @@ const AssetLibrary: React.FC = () => {
|
||||
|
||||
/* ── 获取当前库的素材列表 ── */
|
||||
const {
|
||||
data: apiAssets = [],
|
||||
data: apiAssets = { items: [], total: 0 },
|
||||
isLoading: assetsLoading,
|
||||
isError: assetsError,
|
||||
error: assetsErrorObj,
|
||||
refetch: refetchAssets,
|
||||
} = useQuery<ApiAssetItem[], Error>({
|
||||
} = useQuery<{ items: ApiAssetItem[]; total: number }, Error>({
|
||||
queryKey: ["assets", effectiveLibId],
|
||||
queryFn: () => getAssets(effectiveLibId),
|
||||
queryFn: () =>
|
||||
getAssets(effectiveLibId, {
|
||||
// 拉取所有非删除状态的素材,让用户上传后立刻能看到"处理中"的素材
|
||||
status: "ready,uploading,ingesting,processing,pending,error,failed",
|
||||
}),
|
||||
enabled: !!effectiveLibId,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const assets = useMemo(
|
||||
() => (Array.isArray(apiAssets) ? apiAssets : []).map(mapAsset),
|
||||
() => (Array.isArray(apiAssets?.items) ? apiAssets.items : []).map(mapAsset),
|
||||
[apiAssets],
|
||||
)
|
||||
|
||||
|
||||
Regular → Executable
+43
@@ -282,6 +282,49 @@
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* 缩略图遮罩(处理中/失败等状态) */
|
||||
.xx-asset-thumb-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-xs, 4px);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xx-asset-thumb-processing {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
color: var(--text-inverse, #fff);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.xx-asset-thumb-processing :first-child {
|
||||
font-size: var(--font-size-2xl);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.xx-asset-thumb-failed {
|
||||
background: rgba(239, 68, 68, 0.25);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.xx-asset-thumb-failed :first-child {
|
||||
font-size: var(--font-size-2xl);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 删除按钮 */
|
||||
.xx-asset-delete {
|
||||
position: absolute;
|
||||
|
||||
@@ -252,7 +252,10 @@ const GeneratePage: React.FC = () => {
|
||||
}
|
||||
}, [libraries, selectedLibraryId])
|
||||
|
||||
const { data: materials = [], isLoading: materialsLoading } = useQuery<AssetItem[]>({
|
||||
const { data: materials = { items: [], total: 0 }, isLoading: materialsLoading } = useQuery<{
|
||||
items: AssetItem[]
|
||||
total: number
|
||||
}>({
|
||||
queryKey: ["generate-assets", selectedLibraryId],
|
||||
queryFn: () => getAssets(selectedLibraryId),
|
||||
enabled: !!selectedLibraryId,
|
||||
@@ -892,13 +895,13 @@ const GeneratePage: React.FC = () => {
|
||||
<div style={{ marginTop: 14 }}>
|
||||
{materialsLoading ? (
|
||||
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>加载素材中…</Text>
|
||||
) : materials.length === 0 ? (
|
||||
) : materials.items.length === 0 ? (
|
||||
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>
|
||||
暂无素材,请先在素材库中上传
|
||||
</Text>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{materials.map((m) => {
|
||||
{materials.items.map((m) => {
|
||||
const checked = selectedMaterials.includes(m.id)
|
||||
return (
|
||||
<label
|
||||
@@ -989,7 +992,7 @@ const GeneratePage: React.FC = () => {
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
当前素材库共 {materials.length} 个素材可供匹配
|
||||
当前素材库共 {materials.items.length} 个素材可供匹配
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user