diff --git a/apps/web/src/pages/assets/AssetLibrary.tsx b/apps/web/src/pages/assets/AssetLibrary.tsx
index 4bee329b0..de1193f9d 100644
--- a/apps/web/src/pages/assets/AssetLibrary.tsx
+++ b/apps/web/src/pages/assets/AssetLibrary.tsx
@@ -52,140 +52,26 @@ import {
type BatchOperationResult,
} from "@/api/assets"
import { Button, Input, Select } from "@/components/ui"
+import {
+ type AssetItem,
+ type AssetKind,
+ type StatusType,
+ mapLibrary,
+ mapAsset,
+} from "@/pages/assets/types"
+import {
+ MAX_FILE_SIZE,
+ LARGE_FILE_THRESHOLD,
+ TYPE_FILTER_OPTIONS,
+ TIME_FILTER_OPTIONS,
+ LIBRARY_KIND_OPTIONS,
+ CATEGORY_OPTIONS,
+} from "@/pages/assets/constants"
+import { kindLabel, thumbGradient } from "@/pages/assets/utils/asset"
import "./assets.css"
/* ============================================================
- * 类型
- * ============================================================ */
-type AssetKind = "video" | "image" | "voice"
-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
- /** 是否处于处理中状态(上传中/入库中/诊断中) */
- loading?: boolean
- 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 / classification_status / asset status 推断前端状态 */
-const inferStatus = (
- score?: number,
- classificationStatus?: string,
- assetStatus?: 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: "待诊断" }
- if (score >= 70) return { status: "ok", label: "合格" }
- if (score >= 40) return { status: "warn", label: "待优化" }
- return { status: "bad", label: "不合格" }
- }
- // 素材未就绪:classification 正在处理中
- if (classificationStatus === "processing" || classificationStatus === "pending") {
- return { status: "info", label: "处理中", loading: true }
- }
- 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, loading } = inferStatus(
- item.quality_score ?? undefined,
- item.classification_status ?? undefined,
- item.status ?? undefined,
- )
- const metadata = item.metadata || {}
- const kind = inferKind(item.mime_type || "")
- return {
- id: item.id,
- name: item.name,
- kind,
- // 视频类型不能用 file_url 做缩略图(是视频文件,
无法渲染)
- // 处理中的素材没有缩略图,显示占位符
- 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) : "—",
- }
-}
-
-/* ============================================================
- * 常量
- * ============================================================ */
-const MAX_FILE_SIZE = 2048 * 1024 * 1024
-const LARGE_FILE_THRESHOLD = 100 * 1024 * 1024
-
-/* ============================================================
- * 工具函数
+ * 工具函数(UI 相关)
* ============================================================ */
const kindIcon = (kind: AssetKind) => {
switch (kind) {
@@ -198,29 +84,6 @@ const kindIcon = (kind: AssetKind) => {
}
}
-const kindLabel = (kind: AssetKind) => {
- switch (kind) {
- case "video":
- return "视频"
- case "image":
- return "图片"
- case "voice":
- return "配音"
- }
-}
-
-/** 根据素材类型返回渐变背景 */
-const thumbGradient = (kind: AssetKind): string => {
- switch (kind) {
- case "video":
- return "linear-gradient(135deg, #312e81 0%, #4f46e5 50%, #6366f1 100%)"
- case "image":
- return "linear-gradient(135deg, #78350f 0%, #d97706 50%, #f59e0b 100%)"
- case "voice":
- return "linear-gradient(135deg, #064e3b 0%, #059669 50%, #10b981 100%)"
- }
-}
-
/* ============================================================
* StatusPill 组件
* ============================================================ */
@@ -922,22 +785,13 @@ const AssetLibrary: React.FC = () => {
value={filterType}
onChange={setFilterType}
style={{ width: 120 }}
- options={[
- { value: "all", label: "全部类型" },
- { value: "video", label: "视频" },
- { value: "image", label: "图片" },
- ]}
+ options={TYPE_FILTER_OPTIONS}
/>
@@ -1062,10 +916,7 @@ const AssetLibrary: React.FC = () => {
value={newLibKind}
onChange={(v) => setNewLibKind(v)}
style={{ width: "100%" }}
- options={[
- { value: "video", label: "视频" },
- { value: "image", label: "图片" },
- ]}
+ options={LIBRARY_KIND_OPTIONS}
/>
@@ -1160,15 +1011,7 @@ const AssetLibrary: React.FC = () => {
onChange={(v) => setBatchCategory(v)}
placeholder="请选择分类"
style={{ width: "100%" }}
- options={[
- { value: "person", label: "人物" },
- { value: "scenic", label: "风景" },
- { value: "product", label: "产品" },
- { value: "food", label: "美食" },
- { value: "animal", label: "动物" },
- { value: "architecture", label: "建筑" },
- { value: "other", label: "其他" },
- ]}
+ options={CATEGORY_OPTIONS}
/>
diff --git a/apps/web/src/pages/assets/constants.ts b/apps/web/src/pages/assets/constants.ts
new file mode 100644
index 000000000..2e3d3e13b
--- /dev/null
+++ b/apps/web/src/pages/assets/constants.ts
@@ -0,0 +1,66 @@
+/**
+ * 素材库常量
+ */
+import type { AssetKind } from "./types"
+
+/** 文件大小限制 */
+export const MAX_FILE_SIZE = 2048 * 1024 * 1024
+export const LARGE_FILE_THRESHOLD = 100 * 1024 * 1024
+
+/** 素材类型标签 */
+export const KIND_LABELS: Record = {
+ video: "视频",
+ image: "图片",
+ voice: "配音",
+}
+
+/** 类型筛选选项 */
+export const TYPE_FILTER_OPTIONS: { value: string; label: string }[] = [
+ { value: "all", label: "全部类型" },
+ { value: "video", label: "视频" },
+ { value: "image", label: "图片" },
+]
+
+/** 时间筛选选项 */
+export const TIME_FILTER_OPTIONS: { value: string; label: string }[] = [
+ { value: "all", label: "全部时间" },
+ { value: "today", label: "今天" },
+ { value: "week", label: "近一周" },
+ { value: "month", label: "近一月" },
+]
+
+/** 新建库类型选项(当前仅支持视频和图片) */
+export const LIBRARY_KIND_OPTIONS: { value: AssetKind; label: string }[] = [
+ { value: "video", label: "视频" },
+ { value: "image", label: "图片" },
+]
+
+/** 分类选项 */
+export const CATEGORY_OPTIONS: { value: string; label: string }[] = [
+ { value: "person", label: "人物" },
+ { value: "scenic", label: "风景" },
+ { value: "product", label: "产品" },
+ { value: "food", label: "美食" },
+ { value: "animal", label: "动物" },
+ { value: "architecture", label: "建筑" },
+ { value: "other", label: "其他" },
+]
+
+/** 智能标记视图 */
+export const SMART_VIEW_OPTIONS: {
+ value: "recommended" | "caution" | "high_risk"
+ label: string
+ color: string
+}[] = [
+ { value: "recommended", label: "推荐", color: "success" },
+ { value: "caution", label: "慎用", color: "warning" },
+ { value: "high_risk", label: "高风险", color: "error" },
+]
+
+/** 状态配置 */
+export const STATUS_CONFIG: Record = {
+ ok: { label: "合格", className: "xx-status-pill xx-status-pill-ok" },
+ warn: { label: "待优化", className: "xx-status-pill xx-status-pill-warn" },
+ bad: { label: "不合格", className: "xx-status-pill xx-status-pill-bad" },
+ info: { label: "待诊断", className: "xx-status-pill xx-status-pill-info" },
+}
diff --git a/apps/web/src/pages/assets/types.ts b/apps/web/src/pages/assets/types.ts
new file mode 100644
index 000000000..d500644cc
--- /dev/null
+++ b/apps/web/src/pages/assets/types.ts
@@ -0,0 +1,115 @@
+/**
+ * 素材库类型定义
+ */
+import type { AssetLibraryItem, AssetItem as ApiAssetItem } from "@/api/assets"
+import { formatDuration } from "./utils/format"
+
+export type AssetKind = "video" | "image" | "voice"
+export type StatusType = "ok" | "warn" | "bad" | "info"
+
+export interface LibraryItem {
+ id: string
+ name: string
+ kind: AssetKind
+ count: number
+}
+
+export interface AssetItem {
+ id: string
+ name: string
+ kind: AssetKind
+ thumbUrl?: string
+ fileUrl?: string
+ status: StatusType
+ statusLabel: string
+ /** 是否处于处理中状态(上传中/入库中/诊断中) */
+ loading?: boolean
+ duration?: string
+ size: number
+ createdAt: string
+}
+
+/** 根据 mime_type 推断前端 AssetKind */
+export const inferKind = (mimeType: string): AssetKind => {
+ if (mimeType.startsWith("video/")) return "video"
+ if (mimeType.startsWith("audio/")) return "voice"
+ return "image"
+}
+
+/** 根据 quality_score / classification_status / asset status 推断前端状态 */
+export const inferStatus = (
+ score?: number,
+ classificationStatus?: string,
+ assetStatus?: 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: "待诊断" }
+ if (score >= 70) return { status: "ok", label: "合格" }
+ if (score >= 40) return { status: "warn", label: "待优化" }
+ return { status: "bad", label: "不合格" }
+ }
+ // 素材未就绪:classification 正在处理中
+ if (classificationStatus === "processing" || classificationStatus === "pending") {
+ return { status: "info", label: "处理中", loading: true }
+ }
+ 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: "不合格" }
+}
+
+/** 将后端 AssetLibraryItem 映射为前端 LibraryItem */
+export const mapLibrary = (item: AssetLibraryItem): LibraryItem => ({
+ id: item.id,
+ name: item.name,
+ kind: item.kind || inferKind("video"),
+ count: item.asset_count ?? 0,
+})
+
+/** 将后端 ApiAssetItem 映射为前端 AssetItem */
+export const mapAsset = (item: ApiAssetItem): AssetItem => {
+ const { status, label, loading } = inferStatus(
+ item.quality_score ?? undefined,
+ item.classification_status ?? undefined,
+ item.status ?? undefined,
+ )
+ const metadata = item.metadata || {}
+ const kind = inferKind(item.mime_type || "")
+ return {
+ id: item.id,
+ name: item.name,
+ kind,
+ // 视频类型不能用 file_url 做缩略图(是视频文件,
无法渲染)
+ // 处理中的素材没有缩略图,显示占位符
+ 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) : "—",
+ }
+}
diff --git a/apps/web/src/pages/assets/utils/asset.ts b/apps/web/src/pages/assets/utils/asset.ts
new file mode 100644
index 000000000..36ddb56af
--- /dev/null
+++ b/apps/web/src/pages/assets/utils/asset.ts
@@ -0,0 +1,19 @@
+/**
+ * 素材相关工具函数
+ */
+import type { AssetKind } from "../types"
+import { KIND_LABELS } from "../constants"
+
+export const kindLabel = (kind: AssetKind): string => KIND_LABELS[kind] ?? kind
+
+/** 根据素材类型返回渐变背景 */
+export const thumbGradient = (kind: AssetKind): string => {
+ switch (kind) {
+ case "video":
+ return "linear-gradient(135deg, #312e81 0%, #4f46e5 50%, #6366f1 100%)"
+ case "image":
+ return "linear-gradient(135deg, #78350f 0%, #d97706 50%, #f59e0b 100%)"
+ case "voice":
+ return "linear-gradient(135deg, #064e3b 0%, #059669 50%, #10b981 100%)"
+ }
+}
diff --git a/apps/web/src/pages/assets/utils/format.ts b/apps/web/src/pages/assets/utils/format.ts
new file mode 100644
index 000000000..9adf52445
--- /dev/null
+++ b/apps/web/src/pages/assets/utils/format.ts
@@ -0,0 +1,22 @@
+/**
+ * 格式化工具函数
+ */
+
+export 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")}`
+}
+
+export const formatFileSize = (bytes: number): string => {
+ if (bytes < 1024) return `${bytes} B`
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
+}
+
+export const formatDate = (iso: string): string =>
+ new Date(iso).toLocaleDateString("zh-CN", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ })
diff --git a/apps/web/src/test/pages/assets/smoke.test.tsx b/apps/web/src/test/pages/assets/smoke.test.tsx
new file mode 100644
index 000000000..d5c4d985b
--- /dev/null
+++ b/apps/web/src/test/pages/assets/smoke.test.tsx
@@ -0,0 +1,24 @@
+/**
+ * AssetLibrary 模块 smoke test
+ * 建立完整依赖链,确保 vitest related 模式能匹配到
+ * assets 目录下所有文件的改动(包括子组件和工具函数)
+ */
+import { describe, it, expect } from "vitest"
+
+// 主组件
+import "@/pages/assets/AssetLibrary"
+
+// 类型与常量
+import "@/pages/assets/types"
+import "@/pages/assets/constants"
+
+// 工具函数
+import "@/pages/assets/utils/format"
+import "@/pages/assets/utils/asset"
+
+describe("AssetLibrary module smoke test", () => {
+ it("should load all asset modules", () => {
+ // 纯模块加载测试,确保所有组件/工具函数能正常 import
+ expect(true).toBe(true)
+ })
+})