feat(products): 成片中心 UI 4 大功能升级
1. 封面预览 — 卡片已支持 thumbnailUrl 展示
2. 复核状态标签 — 右上角显示待复核(灰)/已通过(绿)/需修改(红),点击循环切换
3. 批量操作 — 批量下载改用 batch-download API + job_id 轮询
4. 筛选栏 — 新增按项目、按复核状态筛选 Select
API 层新增:
- updateReviewStatus: PATCH /products/{id}/review
- batchDownload: POST /products/batch-download
- getBatchDownloadStatus: GET /products/batch-download/{jobId}
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
/**
|
||||
* 成品相关 API
|
||||
* Phase 1 重构:去掉 projectId,成品直接归属用户
|
||||
* 成品 / 视频相关 API
|
||||
* 包含:列表查询、复核状态、批量下载
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
|
||||
/** 复核状态 */
|
||||
export type ReviewStatus = "pending_review" | "approved" | "rejected";
|
||||
|
||||
/** 成品条目 */
|
||||
export interface ProductItem {
|
||||
id: string;
|
||||
@@ -14,15 +17,49 @@ export interface ProductItem {
|
||||
file_size?: number;
|
||||
resolution?: string;
|
||||
status: "processing" | "completed" | "failed";
|
||||
/** 复核状态 */
|
||||
review_status?: ReviewStatus;
|
||||
/** 所属项目 ID */
|
||||
project_id?: string;
|
||||
/** 所属项目名称 */
|
||||
project_name?: string;
|
||||
/** 查重率(百分比) */
|
||||
duplicate_rate?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/** 获取当前用户的所有成品 */
|
||||
export const getProducts = async (): Promise<ProductItem[]> => {
|
||||
const response = await apiClient.get("/products");
|
||||
/** 列表查询参数 */
|
||||
export interface ProductListParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
project_id?: string;
|
||||
review_status?: ReviewStatus | "all";
|
||||
}
|
||||
|
||||
/** 分页响应 */
|
||||
export interface ProductListResponse {
|
||||
items: ProductItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
/** 批量下载任务状态 */
|
||||
export interface BatchDownloadStatus {
|
||||
job_id: string;
|
||||
status: "processing" | "completed" | "failed";
|
||||
/** 完成后返回的下载 URL */
|
||||
download_url?: string;
|
||||
/** 进度百分比 */
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
/** 获取成品列表(支持分页和筛选) */
|
||||
export const getProducts = async (
|
||||
params?: ProductListParams,
|
||||
): Promise<ProductItem[]> => {
|
||||
const response = await apiClient.get("/products", { params });
|
||||
return response.data.items || response.data || [];
|
||||
};
|
||||
|
||||
@@ -44,3 +81,32 @@ export const getProductDownloadUrl = async (
|
||||
const response = await apiClient.get(`/products/${productId}/download-url`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 更新复核状态 */
|
||||
export const updateReviewStatus = async (
|
||||
productId: string,
|
||||
status: ReviewStatus,
|
||||
): Promise<ProductItem> => {
|
||||
const response = await apiClient.patch(`/products/${productId}/review`, {
|
||||
status,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 发起批量下载 */
|
||||
export const batchDownload = async (
|
||||
videoIds: string[],
|
||||
): Promise<{ job_id: string }> => {
|
||||
const response = await apiClient.post("/products/batch-download", {
|
||||
video_ids: videoIds,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 查询批量下载状态 */
|
||||
export const getBatchDownloadStatus = async (
|
||||
jobId: string,
|
||||
): Promise<BatchDownloadStatus> => {
|
||||
const response = await apiClient.get(`/products/batch-download/${jobId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -31,7 +31,11 @@ import {
|
||||
getProducts,
|
||||
deleteProduct,
|
||||
getProductDownloadUrl,
|
||||
updateReviewStatus,
|
||||
batchDownload,
|
||||
getBatchDownloadStatus,
|
||||
type ProductItem as ApiProductItem,
|
||||
type ReviewStatus,
|
||||
} from "@/api/products";
|
||||
import "./products.css";
|
||||
|
||||
@@ -53,6 +57,12 @@ interface ProductItem {
|
||||
fileSize: number; // MB
|
||||
videoUrl?: string;
|
||||
thumbnailUrl?: string;
|
||||
/** 复核状态 */
|
||||
reviewStatus?: ReviewStatus;
|
||||
/** 所属项目 ID */
|
||||
projectId?: string;
|
||||
/** 所属项目名称 */
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
/** 将后端 status 映射为前端 ProductStatus */
|
||||
@@ -108,6 +118,9 @@ const mapApiProduct = (item: ApiProductItem): ProductItem => ({
|
||||
fileSize: item.file_size ? +(item.file_size / (1024 * 1024)).toFixed(1) : 0,
|
||||
videoUrl: item.video_url,
|
||||
thumbnailUrl: item.thumbnail_url,
|
||||
reviewStatus: item.review_status,
|
||||
projectId: item.project_id,
|
||||
projectName: item.project_name,
|
||||
});
|
||||
|
||||
/* ============================================================
|
||||
@@ -135,6 +148,30 @@ const formatSize = (mb: number): string => {
|
||||
return `${mb.toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
/** 复核状态配置 */
|
||||
const reviewStatusConfig: Record<
|
||||
ReviewStatus,
|
||||
{ text: string; className: string }
|
||||
> = {
|
||||
pending_review: { text: "待复核", className: "review-pending" },
|
||||
approved: { text: "已通过", className: "review-approved" },
|
||||
rejected: { text: "需修改", className: "review-rejected" },
|
||||
};
|
||||
|
||||
/** 复核状态循环顺序 */
|
||||
const REVIEW_STATUS_CYCLE: ReviewStatus[] = [
|
||||
"pending_review",
|
||||
"approved",
|
||||
"rejected",
|
||||
];
|
||||
|
||||
/** 获取下一个复核状态 */
|
||||
const getNextReviewStatus = (current?: ReviewStatus): ReviewStatus => {
|
||||
if (!current) return "approved";
|
||||
const idx = REVIEW_STATUS_CYCLE.indexOf(current);
|
||||
return REVIEW_STATUS_CYCLE[(idx + 1) % REVIEW_STATUS_CYCLE.length];
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
* ProductCard 组件
|
||||
* ============================================================ */
|
||||
@@ -148,6 +185,7 @@ const ProductCard: React.FC<{
|
||||
onShare: (product: ProductItem) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onPublish: (product: ProductItem) => void;
|
||||
onReviewStatusChange: (id: string) => void;
|
||||
}> = ({
|
||||
product,
|
||||
isSelected,
|
||||
@@ -158,6 +196,7 @@ const ProductCard: React.FC<{
|
||||
onShare,
|
||||
onDelete,
|
||||
onPublish,
|
||||
onReviewStatusChange,
|
||||
}) => {
|
||||
const st = statusConfig[product.status];
|
||||
|
||||
@@ -201,6 +240,33 @@ const ProductCard: React.FC<{
|
||||
{/* 已发布徽章(右上角) */}
|
||||
{product.isPublished && <div className="xx-product-badge">✅ 已发布</div>}
|
||||
|
||||
{/* 复核状态标签(右上角) */}
|
||||
{product.reviewStatus && (
|
||||
<div
|
||||
className={`xx-product-review-tag ${reviewStatusConfig[product.reviewStatus].className}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onReviewStatusChange(product.id);
|
||||
}}
|
||||
title={`点击切换复核状态(当前:${reviewStatusConfig[product.reviewStatus].text})`}
|
||||
>
|
||||
{reviewStatusConfig[product.reviewStatus].text}
|
||||
</div>
|
||||
)}
|
||||
{/* 无复核状态时显示"待复核"入口 */}
|
||||
{!product.reviewStatus && (
|
||||
<div
|
||||
className="xx-product-review-tag review-pending"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onReviewStatusChange(product.id);
|
||||
}}
|
||||
title="点击设置复核状态"
|
||||
>
|
||||
待复核
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 缩略图 */}
|
||||
<div className="xx-product-thumb">
|
||||
{product.thumbnailUrl ? (
|
||||
@@ -517,7 +583,7 @@ const ProductLibrary: React.FC = () => {
|
||||
refetch,
|
||||
} = useQuery<ApiProductItem[], Error>({
|
||||
queryKey: ["products"],
|
||||
queryFn: getProducts,
|
||||
queryFn: () => getProducts(),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
@@ -536,11 +602,26 @@ const ProductLibrary: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
/* ── 复核状态 mutation ── */
|
||||
const reviewMutation = useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: ReviewStatus }) =>
|
||||
updateReviewStatus(id, status),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] });
|
||||
message.success("复核状态已更新");
|
||||
},
|
||||
onError: () => {
|
||||
message.error("更新复核状态失败");
|
||||
},
|
||||
});
|
||||
|
||||
/* 筛选 */
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [filterStatus, setFilterStatus] = useState<string>("all");
|
||||
const [filterTime, setFilterTime] = useState<string>("all");
|
||||
const [filterDuration, setFilterDuration] = useState<string>("all");
|
||||
const [filterProject, setFilterProject] = useState<string>("all");
|
||||
const [filterReviewStatus, setFilterReviewStatus] = useState<string>("all");
|
||||
|
||||
/* 批量操作 */
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
@@ -596,6 +677,20 @@ const ProductLibrary: React.FC = () => {
|
||||
});
|
||||
}
|
||||
|
||||
/* 项目筛选 */
|
||||
if (filterProject !== "all") {
|
||||
list = list.filter((p) => p.projectId === filterProject);
|
||||
}
|
||||
|
||||
/* 复核状态筛选 */
|
||||
if (filterReviewStatus !== "all") {
|
||||
if (filterReviewStatus === "none") {
|
||||
list = list.filter((p) => !p.reviewStatus);
|
||||
} else {
|
||||
list = list.filter((p) => p.reviewStatus === filterReviewStatus);
|
||||
}
|
||||
}
|
||||
|
||||
/* 搜索 */
|
||||
if (searchText.trim()) {
|
||||
const q = searchText.trim().toLowerCase();
|
||||
@@ -603,7 +698,15 @@ const ProductLibrary: React.FC = () => {
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [products, filterStatus, filterTime, filterDuration, searchText]);
|
||||
}, [
|
||||
products,
|
||||
filterStatus,
|
||||
filterTime,
|
||||
filterDuration,
|
||||
filterProject,
|
||||
filterReviewStatus,
|
||||
searchText,
|
||||
]);
|
||||
|
||||
/* 全选 */
|
||||
const allSelected =
|
||||
@@ -679,24 +782,56 @@ const ProductLibrary: React.FC = () => {
|
||||
message.info("发布功能待后端 API 补齐");
|
||||
};
|
||||
|
||||
/* 批量下载 */
|
||||
/* 切换复核状态 */
|
||||
const handleReviewStatusChange = (id: string) => {
|
||||
const current = products.find((p) => p.id === id)?.reviewStatus;
|
||||
const nextStatus = getNextReviewStatus(current);
|
||||
reviewMutation.mutate({ id, status: nextStatus });
|
||||
};
|
||||
|
||||
/* 批量下载 — 使用 batch-download API + 轮询 */
|
||||
const [batchDownloading, setBatchDownloading] = useState(false);
|
||||
|
||||
const handleBatchDownload = async () => {
|
||||
const ids = Array.from(selectedIds);
|
||||
let successCount = 0;
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const { url } = await getProductDownloadUrl(id);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "";
|
||||
a.click();
|
||||
successCount++;
|
||||
} catch {
|
||||
// 忽略单个失败
|
||||
}
|
||||
if (ids.length === 0) return;
|
||||
setBatchDownloading(true);
|
||||
try {
|
||||
// 发起批量下载任务
|
||||
const { job_id } = await batchDownload(ids);
|
||||
message.info(`批量下载任务已创建,正在打包 ${ids.length} 个视频...`);
|
||||
|
||||
// 轮询下载状态(最多 60 次,每次 2 秒)
|
||||
let attempts = 0;
|
||||
const maxAttempts = 60;
|
||||
const poll = async (): Promise<void> => {
|
||||
if (attempts >= maxAttempts) {
|
||||
message.warning("打包超时,请稍后在消息中心查看");
|
||||
return;
|
||||
}
|
||||
attempts++;
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
const status = await getBatchDownloadStatus(job_id);
|
||||
if (status.status === "completed" && status.download_url) {
|
||||
const a = document.createElement("a");
|
||||
a.href = status.download_url;
|
||||
a.download = "";
|
||||
a.click();
|
||||
message.success(`已打包下载 ${ids.length} 个视频`);
|
||||
setSelectedIds(new Set());
|
||||
} else if (status.status === "failed") {
|
||||
message.error("批量下载失败,请重试");
|
||||
} else {
|
||||
// 继续轮询
|
||||
await poll();
|
||||
}
|
||||
};
|
||||
await poll();
|
||||
} catch {
|
||||
message.error("发起批量下载失败");
|
||||
} finally {
|
||||
setBatchDownloading(false);
|
||||
}
|
||||
message.success(`已下载 ${successCount}/${ids.length} 个视频`);
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
/* 批量删除 */
|
||||
@@ -823,8 +958,9 @@ const ProductLibrary: React.FC = () => {
|
||||
buttonSize="sm"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleBatchDownload}
|
||||
disabled={batchDownloading}
|
||||
>
|
||||
批量下载
|
||||
{batchDownloading ? "打包中..." : "批量下载"}
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
@@ -904,6 +1040,33 @@ const ProductLibrary: React.FC = () => {
|
||||
{ value: "long", label: ">3分钟" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={filterProject}
|
||||
onChange={setFilterProject}
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ value: "all", label: "全部项目" },
|
||||
...Array.from(
|
||||
new Map(
|
||||
products
|
||||
.filter((p) => p.projectId && p.projectName)
|
||||
.map((p) => [p.projectId!, p.projectName!] as const),
|
||||
),
|
||||
).map(([id, name]) => ({ value: id as string, label: name as string })),
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={filterReviewStatus}
|
||||
onChange={setFilterReviewStatus}
|
||||
style={{ width: 130 }}
|
||||
options={[
|
||||
{ value: "all", label: "全部复核" },
|
||||
{ value: "none", label: "未设置" },
|
||||
{ value: "pending_review", label: "待复核" },
|
||||
{ value: "approved", label: "已通过" },
|
||||
{ value: "rejected", label: "需修改" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-products-filters-right">
|
||||
<span
|
||||
@@ -932,6 +1095,7 @@ const ProductLibrary: React.FC = () => {
|
||||
onShare={handleShare}
|
||||
onDelete={handleDelete}
|
||||
onPublish={handlePublish}
|
||||
onReviewStatusChange={handleReviewStatusChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -222,6 +222,42 @@
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* 复核状态标签(右上角,位于已发布徽章下方) */
|
||||
.xx-product-review-tag {
|
||||
position: absolute;
|
||||
top: 34px;
|
||||
right: 10px;
|
||||
z-index: 2;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
backdrop-filter: blur(4px);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-all);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.xx-product-review-tag:hover {
|
||||
transform: scale(1.05);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.xx-product-review-tag.review-pending {
|
||||
background: rgba(156, 163, 175, 0.9);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.xx-product-review-tag.review-approved {
|
||||
background: rgba(16, 185, 129, 0.9);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.xx-product-review-tag.review-rejected {
|
||||
background: rgba(239, 68, 68, 0.9);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
/* 缩略图区域 */
|
||||
.xx-product-thumb {
|
||||
aspect-ratio: 9 / 16;
|
||||
|
||||
Reference in New Issue
Block a user