Files
xiaoxia-saas/apps/web/src/pages/assets/AssetLibrary.tsx
T
灵应 b0e9266571
Deploy / Staging E2E Tests (push) Failing after 99h8m52s
Deploy / Deploy Staging (push) Failing after 99h11m47s
CI/CD Pipeline / Frontend Lint (push) Failing after 99h13m7s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 99h13m16s
Deploy / Production Browser E2E (push) Failing after 1757h38m19s
Deploy / Build Production Runtime Images (push) Failing after 1757h45m12s
Deploy / Deploy Production (push) Failing after 1758h10m38s
feat(ui): 第二批 UI 打磨 — 素材库 + 模板库
- assets.css: 补全 6 个组件 :active 状态 + :disabled (diagnose-btn)
- templates.css: 补全 6 个组件 :active 状态
- AssetLibrary.tsx: emoji 替换为 Ant Design 图标,内联样式迁移到 CSS 类
- TemplateLibrary.tsx: emoji 替换为 Ant Design 图标,预览弹窗内联样式迁移
- 新增骨架屏网格、表单、视频播放器等迁移用 CSS 类
- 统一使用 V21 设计系统 CSS 变量
2026-07-05 09:41:09 +08:00

773 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 素材库页面 — V21 设计系统
* 两栏布局:左侧素材库列表(260px)+ 右侧素材网格
* 使用 useQuery 对接后端真实 API(api/assets.ts)
*/
import React, { useMemo, useState } from "react";
import { Upload, Modal as AntModal, message, Popconfirm } from "antd";
import {
PlusOutlined,
SearchOutlined,
InboxOutlined,
VideoCameraOutlined,
SoundOutlined,
PictureOutlined,
PlayCircleOutlined,
CheckOutlined,
DeleteOutlined,
ExperimentOutlined,
LoadingOutlined,
ExclamationCircleOutlined,
} from "@ant-design/icons";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
getAssetLibraries,
createAssetLibrary,
deleteAssetLibrary,
getAssets,
deleteAsset,
uploadAssetDirect,
getAssetDiagnosis,
type AssetLibraryItem,
type AssetItem as ApiAssetItem,
} from "@/api/assets";
import { Button, Input, Select } from "@/components/ui";
import "./assets.css";
/* ============================================================
* 类型
* ============================================================ */
type AssetKind = "video" | "voice" | "image";
type StatusType = "ok" | "warn" | "bad" | "info";
interface LibraryItem {
id: string;
name: string;
kind: AssetKind;
count: number;
}
interface AssetItem {
id: string;
name: string;
kind: AssetKind;
thumbUrl?: string;
fileUrl?: string;
status: StatusType;
statusLabel: string;
duration?: string;
size: number;
createdAt: string;
}
/* ============================================================
* 映射:后端 → 前端
* ============================================================ */
/** 根据 mime_type 推断前端 AssetKind */
const inferKind = (mimeType: string): AssetKind => {
if (mimeType.startsWith("video/")) return "video";
if (mimeType.startsWith("audio/")) return "voice";
return "image";
};
/** 根据 quality_score 推断前端状态 */
const inferStatus = (
score?: number,
classificationStatus?: string,
): { status: StatusType; label: string } => {
if (
classificationStatus === "processing" ||
classificationStatus === "pending"
) {
return { status: "info", label: "处理中" };
}
if (score == null) return { status: "info", label: "待诊断" };
if (score >= 70) return { status: "ok", label: "合格" };
if (score >= 40) return { status: "warn", label: "待优化" };
return { status: "bad", label: "不合格" };
};
/** 格式化时长(秒 → mm:ss) */
const formatDuration = (seconds: number): string => {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
};
/** 将后端 AssetLibraryItem 映射为前端 LibraryItem */
const mapLibrary = (item: AssetLibraryItem): LibraryItem => ({
id: item.id,
name: item.name,
kind: item.kind || inferKind("video"),
count: item.asset_count ?? 0,
});
/** 将后端 ApiAssetItem 映射为前端 AssetItem */
const mapAsset = (item: ApiAssetItem): AssetItem => {
const { status, label } = inferStatus(
item.quality_score ?? undefined,
item.classification_status ?? undefined,
);
const metadata = item.metadata || {};
return {
id: item.id,
name: item.name,
kind: inferKind(item.mime_type || ""),
thumbUrl:
(item.thumbnail_url as string | undefined) ||
(item.file_url as string | undefined) ||
(metadata.thumbnail_url as string | undefined),
fileUrl:
(item.file_url as string | undefined) ||
(metadata.file_url as string | undefined),
status,
statusLabel: label,
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)
: "—",
};
};
/* ============================================================
* 常量
* ============================================================ */
const MAX_FILE_SIZE = 2048 * 1024 * 1024;
const LARGE_FILE_THRESHOLD = 100 * 1024 * 1024;
/* ============================================================
* 工具函数
* ============================================================ */
const kindIcon = (kind: AssetKind) => {
switch (kind) {
case "video":
return <VideoCameraOutlined />;
case "voice":
return <SoundOutlined />;
case "image":
return <PictureOutlined />;
}
};
const kindLabel = (kind: AssetKind) => {
switch (kind) {
case "video":
return "视频";
case "voice":
return "配音";
case "image":
return "图片";
}
};
/** 根据素材类型返回渐变背景 */
const thumbGradient = (kind: AssetKind): string => {
switch (kind) {
case "video":
return "linear-gradient(135deg, #312e81 0%, #4f46e5 50%, #6366f1 100%)";
case "voice":
return "linear-gradient(135deg, #064e3b 0%, #059669 50%, #10b981 100%)";
case "image":
return "linear-gradient(135deg, #78350f 0%, #d97706 50%, #f59e0b 100%)";
}
};
/* ============================================================
* StatusPill 组件
* ============================================================ */
const StatusPill: React.FC<{ status: StatusType; label: string }> = ({
status,
label,
}) => (
<span className={`xx-status-pill xx-status-pill-${status}`}>{label}</span>
);
/* ============================================================
* SkeletonCard — 骨架屏卡片(素材列表加载时占位)
* ============================================================ */
const SkeletonCard: React.FC = () => (
<div className="xx-asset-card xx-asset-skeleton">
<div className="xx-asset-thumb xx-skeleton-pulse" />
<div className="xx-asset-info">
<div className="xx-skeleton-line xx-skeleton-pulse" style={{ width: "70%" }} />
<div className="xx-skeleton-line xx-skeleton-pulse" style={{ width: "40%", marginTop: 8 }} />
<div className="xx-skeleton-line xx-skeleton-pulse" style={{ width: "100%", height: 28, marginTop: 8, borderRadius: "var(--radius-xs)" }} />
</div>
</div>
);
/* ============================================================
* AssetCard 组件
* ============================================================ */
const AssetCard: React.FC<{
asset: AssetItem;
selected: boolean;
diagnosing?: boolean;
onToggle: () => void;
onDiagnose: () => void;
onPlay: () => void;
}> = ({ asset, selected, diagnosing, onToggle, onDiagnose, onPlay }) => (
<div
className={`xx-asset-card${selected ? " xx-asset-card-selected" : ""}`}
onClick={onToggle}
>
{/* 缩略图区 */}
<div
className="xx-asset-thumb"
style={{ background: thumbGradient(asset.kind) }}
>
{asset.thumbUrl ? (
<img src={asset.thumbUrl} alt={asset.name} />
) : (
<span className="xx-asset-thumb-placeholder">
{kindIcon(asset.kind)}
</span>
)}
{/* 视频/配音类显示播放按钮 */}
{(asset.kind === "video" || asset.kind === "voice") && (
<span
className="xx-asset-play"
onClick={(e) => {
e.stopPropagation();
onPlay();
}}
>
<PlayCircleOutlined />
</span>
)}
{/* 选中态勾选 */}
{selected && (
<span className="xx-asset-check">
<CheckOutlined />
</span>
)}
</div>
{/* 信息区 */}
<div className="xx-asset-info">
<p className="xx-asset-name" title={asset.name}>
{asset.name}
</p>
<div className="xx-asset-meta">
<StatusPill status={asset.status} label={asset.statusLabel} />
{asset.duration && <span>{asset.duration}</span>}
</div>
<button
className={`xx-asset-diagnose-btn${diagnosing ? " xx-asset-diagnose-btn-loading" : ""}`}
disabled={diagnosing}
onClick={(e) => {
e.stopPropagation();
onDiagnose();
}}
>
{diagnosing ? <LoadingOutlined /> : <ExperimentOutlined />}
{diagnosing ? "诊断中..." : "诊断"}
</button>
</div>
</div>
);
/* ============================================================
* 主组件
* ============================================================ */
const AssetLibrary: React.FC = () => {
const queryClient = useQueryClient();
/* ── 获取素材库列表 ── */
const { data: apiLibraries = [], isLoading: libLoading } = useQuery<
AssetLibraryItem[],
Error
>({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
staleTime: 60_000,
});
const libraries = useMemo(() => apiLibraries.map(mapLibrary), [apiLibraries]);
/* ── 当前选中的素材库 ── */
const [activeLibId, setActiveLibId] = useState<string>("");
// 当库列表加载完成后,自动选中第一个
const effectiveLibId = activeLibId || libraries[0]?.id || "";
/* ── 获取当前库的素材列表 ── */
const {
data: apiAssets = [],
isLoading: assetsLoading,
isError: assetsError,
error: assetsErrorObj,
refetch: refetchAssets,
} = useQuery<ApiAssetItem[], Error>({
queryKey: ["assets", effectiveLibId],
queryFn: () => getAssets(effectiveLibId),
enabled: !!effectiveLibId,
staleTime: 30_000,
});
const assets = useMemo(() => apiAssets.map(mapAsset), [apiAssets]);
/* ── Mutations ── */
const createLibMutation = useMutation({
mutationFn: createAssetLibrary,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
message.success("素材库创建成功");
},
onError: () => {
message.error("创建素材库失败");
},
});
const deleteLibMutation = useMutation({
mutationFn: deleteAssetLibrary,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
message.success("素材库已删除");
},
onError: () => {
message.error("删除素材库失败");
},
});
/* 状态 */
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
/* 筛选 */
const [searchText, setSearchText] = useState("");
const [filterType, setFilterType] = useState<string>("all");
const [filterTime, setFilterTime] = useState<string>("all");
/* 上传 */
const [uploading, setUploading] = useState(false);
/* 新建素材库 */
const [createModalOpen, setCreateModalOpen] = useState(false);
const [newLibName, setNewLibName] = useState("");
const [newLibKind, setNewLibKind] = useState<AssetKind>("video");
/* 视频播放 */
const [playingAsset, setPlayingAsset] = useState<AssetItem | null>(null);
/* 诊断中状态 — 记录正在诊断的素材 ID */
const [diagnosingId, setDiagnosingId] = useState<string | null>(null);
/* 派生数据 */
const filteredAssets = useMemo(() => {
let list = assets;
/* 按素材库类型过滤(如果筛选类型不是 all) */
if (filterType !== "all") {
list = list.filter((a) => a.kind === filterType);
}
/* 按时间筛选 */
if (filterTime !== "all") {
const now = new Date();
list = list.filter((a) => {
const d = new Date(a.createdAt);
const diffDays = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24);
if (filterTime === "today") return diffDays < 1;
if (filterTime === "week") return diffDays < 7;
if (filterTime === "month") return diffDays < 30;
return true;
});
}
/* 搜索 */
if (searchText.trim()) {
const q = searchText.trim().toLowerCase();
list = list.filter((a) => a.name.toLowerCase().includes(q));
}
return list;
}, [assets, filterType, filterTime, searchText]);
/* 选择操作 */
const toggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const selectAll = () => {
setSelectedIds(new Set(filteredAssets.map((a) => a.id)));
};
const deselectAll = () => {
setSelectedIds(new Set());
};
/* 上传 — 调用真实 API */
const handleUpload = async (file: File) => {
if (file.size > MAX_FILE_SIZE) {
message.error(`文件 "${file.name}" 超过 2GB 限制`);
return false;
}
if (!effectiveLibId) {
message.warning("请先选择或创建一个素材库");
return false;
}
setUploading(true);
try {
if (file.size > LARGE_FILE_THRESHOLD) {
message.info(`大文件 "${file.name}" 将使用直传上传`);
}
await uploadAssetDirect({ file, library_id: effectiveLibId });
message.success(`"${file.name}" 上传成功`);
queryClient.invalidateQueries({ queryKey: ["assets"] });
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
} catch {
message.error(`"${file.name}" 上传失败`);
} finally {
setUploading(false);
}
return false;
};
/* 新建素材库 */
const handleCreateLibrary = async () => {
if (!newLibName.trim()) {
message.warning("请输入素材库名称");
return;
}
try {
const newLib = await createLibMutation.mutateAsync({
name: newLibName.trim(),
kind: newLibKind,
});
setActiveLibId(newLib.id);
setCreateModalOpen(false);
setNewLibName("");
setNewLibKind("video");
} catch {
// error handled in mutation
}
};
/* 删除素材库 */
const handleDeleteLibrary = async (id: string) => {
try {
await deleteLibMutation.mutateAsync(id);
if (effectiveLibId === id) {
const remaining = libraries.filter((l) => l.id !== id);
if (remaining.length > 0) setActiveLibId(remaining[0].id);
else setActiveLibId("");
}
} catch {
// error handled in mutation
}
};
/* 诊断 — 调用真实 API,带 loading 状态 */
const handleDiagnose = async (asset: AssetItem) => {
setDiagnosingId(asset.id);
try {
const result = await getAssetDiagnosis(asset.id);
const score = result.readiness_score ?? "-";
message.success(`"${asset.name}" 诊断完成,就绪分:${score}`);
queryClient.invalidateQueries({ queryKey: ["assets"] });
} catch {
message.error(`"${asset.name}" 诊断失败`);
} finally {
setDiagnosingId(null);
}
};
/* 批量删除 */
const handleBatchDelete = async () => {
const ids = Array.from(selectedIds);
let successCount = 0;
for (const id of ids) {
try {
await deleteAsset(id);
successCount++;
} catch {
// 忽略单个失败
}
}
queryClient.invalidateQueries({ queryKey: ["assets"] });
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
setSelectedIds(new Set());
message.success(`已删除 ${successCount}/${ids.length} 个素材`);
};
// ── Loading 状态 ──
if (libLoading) {
return (
<div className="xx-assets-page">
<div className="xx-assets-skeleton-grid">
{Array.from({ length: 8 }).map((_, i) => (
<SkeletonCard key={i} />
))}
</div>
</div>
);
}
return (
<div className="xx-assets-page">
{/* 两栏布局 */}
<div className="xx-assets-layout">
{/* ─── 左侧:素材库列表 ─── */}
<div className="xx-asset-library-list">
{libraries.map((lib) => (
<div
key={lib.id}
className={`xx-asset-library-item${lib.id === effectiveLibId ? " active" : ""}`}
onClick={() => setActiveLibId(lib.id)}
>
<div className="xx-asset-library-header">
<h4>
{kindIcon(lib.kind)} {lib.name}
</h4>
<Popconfirm
title={`确定删除素材库 "${lib.name}"?`}
onConfirm={(e) => {
e?.stopPropagation();
handleDeleteLibrary(lib.id);
}}
onCancel={(e) => e?.stopPropagation()}
okText="删除"
cancelText="取消"
>
<button
className="xx-asset-library-delete"
onClick={(e) => e.stopPropagation()}
title="删除素材库"
>
<DeleteOutlined />
</button>
</Popconfirm>
</div>
<span>
{kindLabel(lib.kind)} · {lib.count} 个素材
</span>
</div>
))}
{/* 新建素材库 */}
<div
className="xx-asset-library-add"
onClick={() => setCreateModalOpen(true)}
>
<PlusOutlined />
新建素材库
</div>
</div>
{/* ─── 右侧:内容区 ─── */}
<div className="xx-assets-content">
{/* 上传区域 */}
<Upload.Dragger
beforeUpload={handleUpload}
showUploadList={false}
multiple
accept="video/*,audio/*,image/*"
>
<div className="xx-asset-upload-zone">
<p className="xx-asset-upload-icon">
<InboxOutlined />
</p>
<p className="xx-asset-upload-text">
{uploading ? "上传中..." : "点击或拖拽文件到此区域上传"}
</p>
<p className="xx-asset-upload-hint">
支持视频、音频、图片,单文件不超过 2GB
</p>
</div>
</Upload.Dragger>
{/* 筛选栏 */}
<div className="xx-assets-filters">
<div className="xx-assets-filters-left">
<Input
placeholder="搜索素材名称..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
style={{ width: 220 }}
/>
<Select
value={filterType}
onChange={setFilterType}
style={{ width: 120 }}
options={[
{ value: "all", label: "全部类型" },
{ value: "video", label: "视频" },
{ value: "voice", label: "配音" },
{ value: "image", label: "图片" },
]}
/>
<Select
value={filterTime}
onChange={setFilterTime}
style={{ width: 120 }}
options={[
{ value: "all", label: "全部时间" },
{ value: "today", label: "今天" },
{ value: "week", label: "近一周" },
{ value: "month", label: "近一月" },
]}
/>
</div>
<div className="xx-assets-filters-right">
<Button buttonType="ghost" buttonSize="sm" onClick={selectAll}>
全选
</Button>
<span className="xx-assets-filter-count">
共 {filteredAssets.length} 个素材
</span>
</div>
</div>
{/* 批量操作栏 */}
{selectedIds.size > 0 && (
<div className="xx-assets-batch-bar">
<span className="xx-assets-batch-count">
已选 {selectedIds.size} 项
</span>
<Button buttonType="ghost" buttonSize="sm" onClick={deselectAll}>
取消选择
</Button>
<Popconfirm
title={`确定删除 ${selectedIds.size} 个素材?`}
onConfirm={handleBatchDelete}
okText="删除"
cancelText="取消"
>
<Button
buttonType="danger"
buttonSize="sm"
icon={<DeleteOutlined />}
>
批量删除
</Button>
</Popconfirm>
</div>
)}
{/* 素材网格 */}
{assetsLoading ? (
<div className="xx-asset-grid">
{Array.from({ length: 8 }).map((_, i) => (
<SkeletonCard key={i} />
))}
</div>
) : assetsError ? (
<div className="xx-assets-empty">
<div className="xx-assets-empty-icon"><ExclamationCircleOutlined /></div>
<p className="xx-assets-empty-title">{assetsErrorObj?.message || "加载失败"}</p>
<Button
buttonType="primary"
buttonSize="sm"
onClick={() => refetchAssets()}
>
重新加载
</Button>
</div>
) : filteredAssets.length > 0 ? (
<div className="xx-asset-grid">
{filteredAssets.map((asset) => (
<AssetCard
key={asset.id}
asset={asset}
selected={selectedIds.has(asset.id)}
diagnosing={diagnosingId === asset.id}
onToggle={() => toggleSelect(asset.id)}
onDiagnose={() => handleDiagnose(asset)}
onPlay={() => setPlayingAsset(asset)}
/>
))}
</div>
) : (
<div className="xx-assets-empty">
<div className="xx-assets-empty-icon">
<PictureOutlined />
</div>
<p className="xx-assets-empty-title">暂无素材,请上传或切换素材库</p>
</div>
)}
</div>
</div>
{/* ─── 新建素材库弹窗 ─── */}
<AntModal
title="新建素材库"
open={createModalOpen}
onCancel={() => setCreateModalOpen(false)}
onOk={handleCreateLibrary}
okText="创建"
cancelText="取消"
destroyOnClose
confirmLoading={createLibMutation.isPending}
>
<div className="xx-asset-form-body">
<div>
<div className="xx-asset-form-label">名称</div>
<Input
placeholder="请输入素材库名称"
value={newLibName}
onChange={(e) => setNewLibName(e.target.value)}
maxLength={50}
/>
</div>
<div>
<div className="xx-asset-form-label">类型</div>
<Select
value={newLibKind}
onChange={(v) => setNewLibKind(v)}
style={{ width: "100%" }}
options={[
{ value: "video", label: "视频" },
{ value: "voice", label: "配音" },
{ value: "image", label: "图片" },
]}
/>
</div>
</div>
</AntModal>
{/* ─── 视频/音频播放弹窗 ─── */}
<AntModal
title={playingAsset?.name ?? "播放"}
open={!!playingAsset}
onCancel={() => setPlayingAsset(null)}
footer={null}
width={640}
destroyOnClose
>
{playingAsset?.fileUrl ? (
<video
src={playingAsset.fileUrl}
controls
autoPlay
className="xx-asset-video-player"
/>
) : (
<div className="xx-asset-empty-fallback">
<p>暂无可播放的文件地址</p>
<p className="xx-asset-empty-fallback-id">
素材 ID: {playingAsset?.id}
</p>
</div>
)}
</AntModal>
</div>
);
};
export default AssetLibrary;