feat(P1): 素材库状态视觉区分 + 上传后立即可见 #536

Merged
auto-approve-bot merged 1 commits from feat/asset-status-visual into develop 2026-07-18 19:33:07 +08:00
4 changed files with 122 additions and 26 deletions
Regular → Executable
+17 -7
View File
@@ -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),支持可选筛选 */
+55 -15
View File
@@ -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],
)
+43
View File
@@ -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;
+7 -4
View File
@@ -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>