fix(P0): 修复素材库删除后仍显示 + 成片库排序和时间显示 #528

Merged
auto-approve-bot merged 1 commits from fix/p0-delete-and-product-library into develop 2026-07-18 18:28:46 +08:00
4 changed files with 99 additions and 40 deletions
Executable → Regular
+3 -1
View File
@@ -161,7 +161,9 @@ export const getAssets = async (libraryId: string): Promise<AssetItem[]> => {
const response = await apiClient.get("/assets", {
params: { library_id: libraryId },
})
return response.data.items || []
const items: AssetItem[] = response.data.items || []
// 过滤掉已删除的素材(后端列表可能返回 deleted 状态,前端兜底)
return items.filter((item) => item.status !== "deleted")
}
/** 按类型获取素材(如 voice/video/image),支持可选筛选 */
Regular → Executable
+71 -36
View File
@@ -1,11 +1,8 @@
/**
* 成品 / 视频相关 API
* 包含:列表查询、复核状态、批量下载
* 后端无 /products 路由,实际从 /generation/tasks 端点获取数据
* 后端实际接口:/videos
*/
import apiClient from "./client"
import { getGenerationTaskResults } from "./editPlans"
import type { GeneratedVideo } from "./editPlans"
/** 复核状态 */
export type ReviewStatus = "pending_review" | "approved" | "rejected"
@@ -58,57 +55,95 @@ export interface BatchDownloadStatus {
progress?: number
}
/** 后端 /videos 接口返回的原始视频条目 */
interface VideoItem {
id: string
project_id: string
generation_task_id: string
name: string
file_url: string
file_size: number
duration: number
thumbnail_url: string | null
width: number
height: number
fps: number
status: string
review_status: ReviewStatus
generation_params: Record<string, unknown>
download_url: string
generated_at: string
}
/**
* 将 generation task 数据映射为 ProductItem 格式
* 将后端 VideoItem 映射为 ProductItem 格式
*/
function mapTaskToProductItem(task: GeneratedVideo): ProductItem {
function mapVideoToProductItem(video: VideoItem): ProductItem {
return {
id: task.id,
title: task.name || "未命名视频",
video_url: task.file_url,
thumbnail_url: task.thumbnail_url,
duration_seconds: task.duration,
file_size: task.file_size,
resolution: task.width && task.height ? `${task.width}x${task.height}` : undefined,
id: video.id,
title: video.name || "未命名视频",
// 优先用 download_url(带签名)播放,file_url 无签名无法访问
video_url: video.download_url || video.file_url,
thumbnail_url: video.thumbnail_url || undefined,
duration_seconds: video.duration,
file_size: video.file_size,
resolution: video.width && video.height ? `${video.width}x${video.height}` : undefined,
status:
task.status === "completed"
video.status === "completed"
? "completed"
: task.status === "failed"
: video.status === "failed"
? "failed"
: "processing",
review_status: task.review_status as ReviewStatus | undefined,
project_id: task.project_id,
created_at: task.created_at,
updated_at: task.updated_at,
review_status: video.review_status,
project_id: video.project_id,
// 后端 /videos 接口暂无 project_name 字段
project_name: undefined,
// 后端字段名为 generated_at,映射为 created_at 供前端统一使用
created_at: video.generated_at,
updated_at: video.generated_at,
// 后端 /videos 接口暂无 duplicate_rate 字段
duplicate_rate: undefined,
}
}
/** 获取成品列表(支持分页和筛选)— 实际从 generation tasks 获取 */
/** 获取成品列表(支持分页和筛选) */
export const getProducts = async (params?: ProductListParams): Promise<ProductItem[]> => {
const response = await apiClient.get("/generation/tasks", { params })
const tasks = response.data.items || response.data || []
return tasks.map(mapTaskToProductItem)
const response = await apiClient.get("/videos", { params })
const data = response.data
const videos: VideoItem[] = Array.isArray(data?.items)
? data.items
: Array.isArray(data)
? data
: []
return videos.map(mapVideoToProductItem)
}
/** 获取单个成品详情 — 通过 task ID 获取结果 */
/** 获取单个成品详情 */
export const getProduct = async (productId: string): Promise<ProductItem> => {
const response = await apiClient.get(`/generation/tasks/${productId}`)
return mapTaskToProductItem(response.data)
const response = await apiClient.get(`/videos/${productId}`)
return mapVideoToProductItem(response.data as VideoItem)
}
/** 删除成品 — 删除 generation task */
/**
* 删除成品
* 注意:后端暂未实现 /videos DELETE 接口,调用会返回 405
* 待后端实现后自动生效
*/
export const deleteProduct = async (productId: string): Promise<void> => {
await apiClient.delete(`/generation/tasks/${productId}`)
await apiClient.delete(`/videos/${productId}`)
}
/** 获取成品下载链接 — 从 generation task results 获取 */
/**
* 获取成品下载链接
* 直接使用列表返回的 download_url(带OSS签名)
*/
export const getProductDownloadUrl = async (
productId: string,
): Promise<{ url: string; expires_at: string }> => {
const videos = await getGenerationTaskResults(productId)
const video = videos[0]
if (!video?.download_url) throw new Error("下载链接不可用")
return { url: video.download_url, expires_at: "" }
// 优先从列表缓存取;如果没有则调详情接口
const product = await getProduct(productId)
if (!product.video_url) throw new Error("下载链接不可用")
return { url: product.video_url, expires_at: "" }
}
/** 更新复核状态 — TODO: 后端暂无对应端点,暂存本地状态 */
@@ -116,7 +151,7 @@ export const updateReviewStatus = async (
productId: string,
status: ReviewStatus,
): Promise<ProductItem> => {
// 后端暂无 /generation/tasks/{id}/review 端点
// 后端暂无 /videos/{id}/review 端点
// 暂时返回当前状态,后续可扩展
const product = await getProduct(productId)
return { ...product, review_status: status }
@@ -124,7 +159,7 @@ export const updateReviewStatus = async (
/** 发起批量下载 — TODO: 后端暂无对应端点 */
export const batchDownload = async (videoIds: string[]): Promise<{ job_id: string }> => {
// 后端暂无 /generation/tasks/batch-download 端点
// 后端暂无 /videos/batch-download 端点
// 暂时返回模拟 job_id,后续可扩展
console.warn("[batchDownload] 后端暂无批量下载端点", videoIds)
return { job_id: `mock-${Date.now()}` }
@@ -132,7 +167,7 @@ export const batchDownload = async (videoIds: string[]): Promise<{ job_id: strin
/** 查询批量下载状态 — TODO: 后端暂无对应端点 */
export const getBatchDownloadStatus = async (jobId: string): Promise<BatchDownloadStatus> => {
// 后端暂无 /generation/tasks/batch-download/{jobId} 端点
// 后端暂无 /videos/batch-download/{jobId} 端点
// 暂时返回模拟状态,后续可扩展
console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId)
return { job_id: jobId, status: "processing", progress: 0 }
@@ -95,6 +95,10 @@ const inferStatus = (
classificationStatus?: string,
assetStatus?: string,
): { status: StatusType; label: string } => {
// 已删除素材(正常情况列表已过滤,这里是防御性处理)
if (assetStatus === "deleted") {
return { status: "bad", label: "已删除" }
}
// 素材已就绪(status=ready)时,不应因 classification 未执行而显示"处理中"
if (assetStatus === "ready") {
if (score == null) return { status: "info", label: "待诊断" }
+21 -3
View File
@@ -102,7 +102,7 @@ const mapApiProduct = (item: ApiProductItem): ProductItem => ({
name: item.title,
status: normalizeStatus(item.status),
duration: item.duration_seconds ?? 0,
date: item.created_at ? new Date(item.created_at).toISOString().slice(0, 10) : "—",
date: item.created_at ? formatDateTime(item.created_at) : "—",
gradient: gradientForId(item.id),
duplicateRate: item.duplicate_rate ?? 0,
isPublished: false, // TODO: 后端发布状态字段待补齐
@@ -139,6 +139,16 @@ const formatSize = (mb: number): string => {
return `${mb.toFixed(1)} MB`
}
/** 格式化日期时间(MM-DD HH:mm */
const formatDateTime = (isoString: string): string => {
const d = new Date(isoString)
const month = (d.getMonth() + 1).toString().padStart(2, "0")
const day = d.getDate().toString().padStart(2, "0")
const hour = d.getHours().toString().padStart(2, "0")
const minute = d.getMinutes().toString().padStart(2, "0")
return `${month}-${day} ${hour}:${minute}`
}
/** 复核状态配置 */
const reviewStatusConfig: Record<ReviewStatus, { text: string; className: string }> = {
pending_review: { text: "待复核", className: "review-pending" },
@@ -541,8 +551,16 @@ const ProductLibrary: React.FC = () => {
staleTime: 30_000,
})
// 映射为前端类型
const products = useMemo(() => apiProducts.map(mapApiProduct), [apiProducts])
// 映射为前端类型,按创建时间倒序排列,防御非数组返回
const products = useMemo(() => {
const list = (Array.isArray(apiProducts) ? apiProducts : []).map(mapApiProduct)
// 按创建时间倒序(最新的在最前面),无时间的排最后
return list.sort((a, b) => {
if (!a.date || a.date === "—") return 1
if (!b.date || b.date === "—") return -1
return new Date(b.date).getTime() - new Date(a.date).getTime()
})
}, [apiProducts])
/* ── 删除 mutation ── */
const deleteMutation = useMutation({