From b8e1ba2aa98066e0b87569d01fbc756f5ac5bbe6 Mon Sep 17 00:00:00 2001 From: SaaS Frontend Date: Sat, 25 Jul 2026 21:23:14 +0800 Subject: [PATCH] refactor(assets): Phase 3 - extract business hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract 5 business hooks from AssetLibrary: - useAssetsData: library/asset queries + filter/search state - useLibraryManagement: create/delete library + modal form state - useAssetUpload: upload logic + progress tracking - useAssetSelection: selection state + select/deselect operations - useAssetOperations: diagnose, single/batch delete, batch tag/classify/mark + all modal state Main component reduced from 663 → 307 lines (54% reduction). Follows the same pattern as VoiceLibrary Phase 3. --- apps/web/src/pages/assets/AssetLibrary.tsx | 524 +++--------------- .../pages/assets/hooks/useAssetOperations.ts | 301 ++++++++++ .../pages/assets/hooks/useAssetSelection.ts | 40 ++ .../src/pages/assets/hooks/useAssetUpload.ts | 65 +++ .../src/pages/assets/hooks/useAssetsData.ts | 118 ++++ .../assets/hooks/useLibraryManagement.ts | 106 ++++ apps/web/src/test/pages/assets/smoke.test.tsx | 36 +- 7 files changed, 735 insertions(+), 455 deletions(-) create mode 100644 apps/web/src/pages/assets/hooks/useAssetOperations.ts create mode 100644 apps/web/src/pages/assets/hooks/useAssetSelection.ts create mode 100644 apps/web/src/pages/assets/hooks/useAssetUpload.ts create mode 100644 apps/web/src/pages/assets/hooks/useAssetsData.ts create mode 100644 apps/web/src/pages/assets/hooks/useLibraryManagement.ts diff --git a/apps/web/src/pages/assets/AssetLibrary.tsx b/apps/web/src/pages/assets/AssetLibrary.tsx index 64f5f09f5..c6adc7683 100644 --- a/apps/web/src/pages/assets/AssetLibrary.tsx +++ b/apps/web/src/pages/assets/AssetLibrary.tsx @@ -3,29 +3,11 @@ * 两栏布局:左侧视频库列表(260px)+ 右侧素材网格 * 使用 useQuery 对接后端真实 API(api/assets.ts) */ -import React, { useMemo, useState } from "react" -import { Upload, message } from "antd" +import React, { useState } from "react" +import { Upload } from "antd" import { InboxOutlined, PictureOutlined, ExclamationCircleOutlined } from "@ant-design/icons" -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" -import { - getAssetLibraries, - createAssetLibrary, - deleteAssetLibrary, - getAssets, - deleteAsset, - uploadAssetDirect, - getAssetDiagnosis, - batchDeleteAssets, - batchTagAssets, - batchClassifyAssets, - batchMarkAssets, - type AssetLibraryItem, - type AssetItem as ApiAssetItem, - type BatchOperationResult, -} from "@/api/assets" import { Button } from "@/components/ui" -import { type AssetItem, type AssetKind, mapLibrary, mapAsset } from "@/pages/assets/types" -import { MAX_FILE_SIZE, LARGE_FILE_THRESHOLD } from "@/pages/assets/constants" +import type { AssetItem } from "@/pages/assets/types" import AssetCard from "@/pages/assets/components/AssetCard" import { SkeletonCard } from "@/pages/assets/components/AssetSkeleton" import LibrarySidebar from "@/pages/assets/components/LibrarySidebar" @@ -36,435 +18,102 @@ import PlayModal from "@/pages/assets/components/PlayModal" import BatchTagModal from "@/pages/assets/components/BatchTagModal" import BatchClassifyModal from "@/pages/assets/components/BatchClassifyModal" import BatchMarkModal from "@/pages/assets/components/BatchMarkModal" -import type { SmartViewType } from "@/pages/assets/components/BatchMarkModal" import ResultDrawer from "@/pages/assets/components/ResultDrawer" import UploadProgressModal from "@/pages/assets/components/UploadProgressModal" +import { useAssetsData } from "@/pages/assets/hooks/useAssetsData" +import { useLibraryManagement } from "@/pages/assets/hooks/useLibraryManagement" +import { useAssetUpload } from "@/pages/assets/hooks/useAssetUpload" +import { useAssetSelection } from "@/pages/assets/hooks/useAssetSelection" +import { useAssetOperations } from "@/pages/assets/hooks/useAssetOperations" import "./assets.css" /* ============================================================ * 主组件 * ============================================================ */ const AssetLibrary: React.FC = () => { - const queryClient = useQueryClient() - - /* ── 获取视频库列表 ── */ - const { data: apiLibraries = [], isLoading: libLoading } = useQuery({ - queryKey: ["asset-libraries"], - queryFn: getAssetLibraries, - staleTime: 60_000, - }) - - const libraries = useMemo( - () => - (Array.isArray(apiLibraries) ? apiLibraries : []) - .map(mapLibrary) - .filter((lib) => lib.kind === "video"), - [apiLibraries], - ) - - /* ── 当前选中的视频库 ── */ - const [activeLibId, setActiveLibId] = useState("") - - // 当库列表加载完成后,自动选中第一个 - const effectiveLibId = activeLibId || libraries[0]?.id || "" - - /* ── 获取当前库的素材列表 ── */ + /* ── 数据查询与筛选 ── */ const { - data: apiAssets = { items: [], total: 0 }, - isLoading: assetsLoading, - isError: assetsError, - error: assetsErrorObj, - refetch: refetchAssets, - } = useQuery<{ items: ApiAssetItem[]; total: number }, Error>({ - queryKey: ["assets", effectiveLibId], - queryFn: () => - getAssets(effectiveLibId, { - // 拉取所有非删除状态的素材,让用户上传后立刻能看到"处理中"的素材 - status: "ready,uploading,ingesting,processing,pending,error,failed", - }), - enabled: !!effectiveLibId, - staleTime: 30_000, + libraries, + libLoading, + activeLibId, + setActiveLibId, + effectiveLibId, + assetsLoading, + assetsError, + assetsErrorObj, + refetchAssets, + searchText, + setSearchText, + filterType, + setFilterType, + filterTime, + setFilterTime, + filteredAssets, + } = useAssetsData() + + /* ── 视频库管理 ── */ + const { + createModalOpen, + setCreateModalOpen, + newLibName, + setNewLibName, + newLibKind, + setNewLibKind, + isCreating, + handleCreateLibrary, + handleDeleteLibrary, + } = useLibraryManagement({ + libraries, + activeLibId, + setActiveLibId, + effectiveLibId, }) - const assets = useMemo( - () => (Array.isArray(apiAssets?.items) ? apiAssets.items : []).map(mapAsset), - [apiAssets], - ) + /* ── 上传 ── */ + const { uploading, uploadProgress, handleUpload } = useAssetUpload({ effectiveLibId }) - /* ── Mutations ── */ - const createLibMutation = useMutation({ - mutationFn: createAssetLibrary, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["asset-libraries"] }) - message.success("视频库创建成功") - }, - onError: () => { - message.error("创建视频库失败") - }, + /* ── 选中态管理 ── */ + const { selectedIds, setSelectedIds, toggleSelect, selectAll, deselectAll } = useAssetSelection({ + filteredAssets, }) - const deleteLibMutation = useMutation({ - mutationFn: deleteAssetLibrary, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["asset-libraries"] }) - message.success("视频库已删除") - }, - onError: () => { - message.error("删除视频库失败") - }, - }) + /* ── 素材操作 ── */ + const { + diagnosingId, + handleDiagnose, + handleSingleDelete, + batchLoading, + tagModalOpen, + setTagModalOpen, + batchTagInput, + setBatchTagInput, + batchTags, + setBatchTags, + tagMode, + setTagMode, + handleBatchTag, + handleTagInputKeyDown, + removeBatchTag, + classifyModalOpen, + setClassifyModalOpen, + batchCategory, + setBatchCategory, + handleBatchClassify, + markModalOpen, + setMarkModalOpen, + batchSmartView, + setBatchSmartView, + handleBatchMark, + handleBatchDelete, + resultDrawerOpen, + operationResult, + operationTitle, + handleResultDrawerClose, + } = useAssetOperations({ selectedIds, setSelectedIds }) - /* 状态 */ - const [selectedIds, setSelectedIds] = useState>(new Set()) - - // 大文件直传由 handleUpload 直接调用 uploadAssetDirect 处理 - - /* 筛选 */ - const [searchText, setSearchText] = useState("") - const [filterType, setFilterType] = useState("all") - const [filterTime, setFilterTime] = useState("all") - - /* 上传 */ - const [uploading, setUploading] = useState(false) - const [uploadProgress, setUploadProgress] = useState(0) - - /* 新建视频库 */ - const [createModalOpen, setCreateModalOpen] = useState(false) - const [newLibName, setNewLibName] = useState("") - const [newLibKind, setNewLibKind] = useState("video") - - /* 视频播放 */ + /* ── 视频播放 ── */ const [playingAsset, setPlayingAsset] = useState(null) - /* 诊断中状态 — 记录正在诊断的素材 ID */ - const [diagnosingId, setDiagnosingId] = useState(null) - - /* ── 批量操作弹窗状态 ── */ - const [tagModalOpen, setTagModalOpen] = useState(false) - const [classifyModalOpen, setClassifyModalOpen] = useState(false) - const [markModalOpen, setMarkModalOpen] = useState(false) - const [resultDrawerOpen, setResultDrawerOpen] = useState(false) - - /* 批量打标签 */ - const [batchTagInput, setBatchTagInput] = useState("") - const [batchTags, setBatchTags] = useState([]) - const [tagMode, setTagMode] = useState<"add" | "replace">("add") - - /* 批量改分类 */ - const [batchCategory, setBatchCategory] = useState("") - - /* 批量智能标记 */ - const [batchSmartView, setBatchSmartView] = useState("recommended") - - /* 操作结果 */ - const [operationResult, setOperationResult] = useState(null) - const [operationTitle, setOperationTitle] = useState("") - - /* 批量操作 loading */ - const [batchLoading, setBatchLoading] = useState(false) - - /* 派生数据 */ - 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 - } - if (!effectiveLibId) { - message.warning("请先选择或创建一个视频库") - return - } - - setUploading(true) - setUploadProgress(0) - try { - if (file.size > LARGE_FILE_THRESHOLD) { - message.info(`大文件 "${file.name}" 将使用直传上传`) - } - await uploadAssetDirect({ - file, - library_id: effectiveLibId, - onProgress: (pct) => setUploadProgress(pct), - }) - message.success(`"${file.name}" 上传成功`) - queryClient.invalidateQueries({ queryKey: ["assets"] }) - queryClient.invalidateQueries({ queryKey: ["asset-libraries"] }) - } catch (err: unknown) { - const detail = err instanceof Error ? err.message : "" - console.error("[handleUpload] 上传失败:", err) - message.error(`"${file.name}" 上传失败${detail ? `:${detail}` : ""}`) - // 错误时延迟关闭弹窗,让用户能看到错误提示 - await new Promise((r) => setTimeout(r, 1500)) - } finally { - setUploading(false) - setUploadProgress(0) - } - } - - /* 新建视频库 */ - 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 handleSingleDelete = async (assetId: string) => { - try { - await deleteAsset(assetId) - queryClient.invalidateQueries({ queryKey: ["assets"] }) - queryClient.invalidateQueries({ queryKey: ["asset-libraries"] }) - // 从选中集合中移除 - setSelectedIds((prev) => { - const next = new Set(prev) - next.delete(assetId) - return next - }) - message.success("素材已删除") - } catch { - message.error("删除失败,请重试") - } - } - - /* 批量删除 */ - const handleBatchDelete = async () => { - const ids = Array.from(selectedIds) - setBatchLoading(true) - try { - const result = await batchDeleteAssets(ids) - setOperationResult(result) - setOperationTitle("批量删除") - setResultDrawerOpen(true) - queryClient.invalidateQueries({ queryKey: ["assets"] }) - queryClient.invalidateQueries({ queryKey: ["asset-libraries"] }) - setSelectedIds(new Set()) - if (result.failure_count === 0) { - message.success(`成功删除 ${result.success_count} 个素材`) - } else { - message.warning( - `删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`, - ) - } - } catch { - message.error("批量删除失败,请重试") - } finally { - setBatchLoading(false) - } - } - - /* 批量打标签 */ - const handleBatchTag = async () => { - if (batchTags.length === 0) { - message.warning("请至少输入一个标签") - return - } - const ids = Array.from(selectedIds) - setBatchLoading(true) - try { - const result = await batchTagAssets({ - asset_ids: ids, - tags: batchTags, - mode: tagMode, - }) - setOperationResult(result) - setOperationTitle("批量打标签") - setResultDrawerOpen(true) - setTagModalOpen(false) - setBatchTags([]) - setBatchTagInput("") - setTagMode("add") - queryClient.invalidateQueries({ queryKey: ["assets"] }) - setSelectedIds(new Set()) - if (result.failure_count === 0) { - message.success(`成功为 ${result.success_count} 个素材打标签`) - } else { - message.warning( - `打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`, - ) - } - } catch { - message.error("批量打标签失败,请重试") - } finally { - setBatchLoading(false) - } - } - - /* 批量改分类 */ - const handleBatchClassify = async () => { - if (!batchCategory) { - message.warning("请选择分类") - return - } - const ids = Array.from(selectedIds) - setBatchLoading(true) - try { - const result = await batchClassifyAssets({ - asset_ids: ids, - category: batchCategory, - }) - setOperationResult(result) - setOperationTitle("批量改分类") - setResultDrawerOpen(true) - setClassifyModalOpen(false) - setBatchCategory("") - queryClient.invalidateQueries({ queryKey: ["assets"] }) - setSelectedIds(new Set()) - if (result.failure_count === 0) { - message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}」`) - } else { - message.warning( - `改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`, - ) - } - } catch { - message.error("批量改分类失败,请重试") - } finally { - setBatchLoading(false) - } - } - - /* 批量智能标记 */ - const handleBatchMark = async () => { - const ids = Array.from(selectedIds) - setBatchLoading(true) - try { - const result = await batchMarkAssets({ - asset_ids: ids, - smart_view: batchSmartView, - }) - setOperationResult(result) - setOperationTitle("批量智能标记") - setResultDrawerOpen(true) - setMarkModalOpen(false) - queryClient.invalidateQueries({ queryKey: ["assets"] }) - setSelectedIds(new Set()) - const labelMap: Record = { - recommended: "推荐", - caution: "慎用", - high_risk: "高风险", - } - if (result.failure_count === 0) { - message.success( - `成功将 ${result.success_count} 个素材标记为「${labelMap[batchSmartView]}」`, - ) - } else { - message.warning( - `智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`, - ) - } - } catch { - message.error("批量智能标记失败,请重试") - } finally { - setBatchLoading(false) - } - } - - /* 标签输入处理 */ - const handleTagInputKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && batchTagInput.trim()) { - e.preventDefault() - const tag = batchTagInput.trim() - if (!batchTags.includes(tag)) { - setBatchTags([...batchTags, tag]) - } - setBatchTagInput("") - } - } - - const removeBatchTag = (tag: string) => { - setBatchTags(batchTags.filter((t) => t !== tag)) - } - // ── Loading 状态 ── if (libLoading) { return ( @@ -499,8 +148,6 @@ const AssetLibrary: React.FC = () => { {/* 上传区域 */} { - // 同步返回 false 阻止 antd 默认上传行为 - // 异步上传由 handleUpload 处理 handleUpload(file as File) return false }} @@ -595,7 +242,7 @@ const AssetLibrary: React.FC = () => { onNameChange={setNewLibName} kind={newLibKind} onKindChange={setNewLibKind} - confirmLoading={createLibMutation.isPending} + confirmLoading={isCreating} /> {/* ─── 视频/音频播放弹窗 ─── */} @@ -651,10 +298,7 @@ const AssetLibrary: React.FC = () => { open={resultDrawerOpen} title={operationTitle} result={operationResult} - onClose={() => { - setResultDrawerOpen(false) - setOperationResult(null) - }} + onClose={handleResultDrawerClose} /> ) diff --git a/apps/web/src/pages/assets/hooks/useAssetOperations.ts b/apps/web/src/pages/assets/hooks/useAssetOperations.ts new file mode 100644 index 000000000..d1ec91f29 --- /dev/null +++ b/apps/web/src/pages/assets/hooks/useAssetOperations.ts @@ -0,0 +1,301 @@ +import { useState, useCallback } from "react" +import { useQueryClient } from "@tanstack/react-query" +import { message } from "antd" +import { + deleteAsset, + getAssetDiagnosis, + batchDeleteAssets, + batchTagAssets, + batchClassifyAssets, + batchMarkAssets, + type BatchOperationResult, +} from "@/api/assets" +import type { AssetItem } from "../types" +import type { SmartViewType } from "../components/BatchMarkModal" + +/** + * 素材操作 Hook + * 封装素材的诊断、删除、批量打标签、批量改分类、批量智能标记等操作, + * 以及相关弹窗和结果展示的状态管理 + */ +interface UseAssetOperationsProps { + selectedIds: Set + setSelectedIds: (ids: Set) => void +} + +export function useAssetOperations({ selectedIds, setSelectedIds }: UseAssetOperationsProps) { + const queryClient = useQueryClient() + + /* ── 诊断状态 ── */ + const [diagnosingId, setDiagnosingId] = useState(null) + + /* ── 批量操作弹窗状态 ── */ + const [tagModalOpen, setTagModalOpen] = useState(false) + const [classifyModalOpen, setClassifyModalOpen] = useState(false) + const [markModalOpen, setMarkModalOpen] = useState(false) + const [resultDrawerOpen, setResultDrawerOpen] = useState(false) + + /* ── 批量打标签表单 ── */ + const [batchTagInput, setBatchTagInput] = useState("") + const [batchTags, setBatchTags] = useState([]) + const [tagMode, setTagMode] = useState<"add" | "replace">("add") + + /* ── 批量改分类表单 ── */ + const [batchCategory, setBatchCategory] = useState("") + + /* ── 批量智能标记表单 ── */ + const [batchSmartView, setBatchSmartView] = useState("recommended") + + /* ── 操作结果 ── */ + const [operationResult, setOperationResult] = useState(null) + const [operationTitle, setOperationTitle] = useState("") + + /* ── 批量操作 loading ── */ + const [batchLoading, setBatchLoading] = useState(false) + + /* ── 刷新数据辅助函数 ── */ + const invalidateAssets = useCallback(() => { + queryClient.invalidateQueries({ queryKey: ["assets"] }) + queryClient.invalidateQueries({ queryKey: ["asset-libraries"] }) + }, [queryClient]) + + /* ── 诊断 ── */ + const handleDiagnose = useCallback( + 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) + } + }, + [queryClient], + ) + + /* ── 单个素材删除 ── */ + const handleSingleDelete = useCallback( + async (assetId: string) => { + try { + await deleteAsset(assetId) + invalidateAssets() + // 从选中集合中移除 + setSelectedIds( + (() => { + const next = new Set(selectedIds) + next.delete(assetId) + return next + })(), + ) + message.success("素材已删除") + } catch { + message.error("删除失败,请重试") + } + }, + [invalidateAssets, selectedIds, setSelectedIds], + ) + + /* ── 显示操作结果 ── */ + const showOperationResult = useCallback( + (result: BatchOperationResult, title: string, clearSelection = true) => { + setOperationResult(result) + setOperationTitle(title) + setResultDrawerOpen(true) + if (clearSelection) setSelectedIds(new Set()) + }, + [setSelectedIds], + ) + + /* ── 批量删除 ── */ + const handleBatchDelete = useCallback(async () => { + const ids = Array.from(selectedIds) + setBatchLoading(true) + try { + const result = await batchDeleteAssets(ids) + invalidateAssets() + showOperationResult(result, "批量删除") + if (result.failure_count === 0) { + message.success(`成功删除 ${result.success_count} 个素材`) + } else { + message.warning( + `删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`, + ) + } + } catch { + message.error("批量删除失败,请重试") + } finally { + setBatchLoading(false) + } + }, [selectedIds, invalidateAssets, showOperationResult]) + + /* ── 批量打标签 ── */ + const handleBatchTag = useCallback(async () => { + if (batchTags.length === 0) { + message.warning("请至少输入一个标签") + return + } + const ids = Array.from(selectedIds) + setBatchLoading(true) + try { + const result = await batchTagAssets({ + asset_ids: ids, + tags: batchTags, + mode: tagMode, + }) + queryClient.invalidateQueries({ queryKey: ["assets"] }) + showOperationResult(result, "批量打标签") + setTagModalOpen(false) + setBatchTags([]) + setBatchTagInput("") + setTagMode("add") + if (result.failure_count === 0) { + message.success(`成功为 ${result.success_count} 个素材打标签`) + } else { + message.warning( + `打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`, + ) + } + } catch { + message.error("批量打标签失败,请重试") + } finally { + setBatchLoading(false) + } + }, [batchTags, selectedIds, tagMode, queryClient, showOperationResult]) + + /* ── 标签输入处理 ── */ + const handleTagInputKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && batchTagInput.trim()) { + e.preventDefault() + const tag = batchTagInput.trim() + if (!batchTags.includes(tag)) { + setBatchTags([...batchTags, tag]) + } + setBatchTagInput("") + } + }, + [batchTagInput, batchTags], + ) + + const removeBatchTag = useCallback( + (tag: string) => { + setBatchTags(batchTags.filter((t) => t !== tag)) + }, + [batchTags], + ) + + /* ── 批量改分类 ── */ + const handleBatchClassify = useCallback(async () => { + if (!batchCategory) { + message.warning("请选择分类") + return + } + const ids = Array.from(selectedIds) + setBatchLoading(true) + try { + const result = await batchClassifyAssets({ + asset_ids: ids, + category: batchCategory, + }) + queryClient.invalidateQueries({ queryKey: ["assets"] }) + showOperationResult(result, "批量改分类") + setClassifyModalOpen(false) + setBatchCategory("") + if (result.failure_count === 0) { + message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}」`) + } else { + message.warning( + `改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`, + ) + } + } catch { + message.error("批量改分类失败,请重试") + } finally { + setBatchLoading(false) + } + }, [batchCategory, selectedIds, queryClient, showOperationResult]) + + /* ── 批量智能标记 ── */ + const handleBatchMark = useCallback(async () => { + const ids = Array.from(selectedIds) + setBatchLoading(true) + try { + const result = await batchMarkAssets({ + asset_ids: ids, + smart_view: batchSmartView, + }) + queryClient.invalidateQueries({ queryKey: ["assets"] }) + showOperationResult(result, "批量智能标记") + setMarkModalOpen(false) + const labelMap: Record = { + recommended: "推荐", + caution: "慎用", + high_risk: "高风险", + } + if (result.failure_count === 0) { + message.success( + `成功将 ${result.success_count} 个素材标记为「${labelMap[batchSmartView]}」`, + ) + } else { + message.warning( + `智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`, + ) + } + } catch { + message.error("批量智能标记失败,请重试") + } finally { + setBatchLoading(false) + } + }, [batchSmartView, selectedIds, queryClient, showOperationResult]) + + /* ── 关闭结果 Drawer ── */ + const handleResultDrawerClose = useCallback(() => { + setResultDrawerOpen(false) + setOperationResult(null) + }, []) + + return { + // 诊断 + diagnosingId, + handleDiagnose, + // 单个操作 + handleSingleDelete, + // 批量操作 loading + batchLoading, + // 批量打标签 + tagModalOpen, + setTagModalOpen, + batchTagInput, + setBatchTagInput, + batchTags, + setBatchTags, + tagMode, + setTagMode, + handleBatchTag, + handleTagInputKeyDown, + removeBatchTag, + // 批量改分类 + classifyModalOpen, + setClassifyModalOpen, + batchCategory, + setBatchCategory, + handleBatchClassify, + // 批量智能标记 + markModalOpen, + setMarkModalOpen, + batchSmartView, + setBatchSmartView, + handleBatchMark, + // 批量删除 + handleBatchDelete, + // 操作结果 + resultDrawerOpen, + operationResult, + operationTitle, + handleResultDrawerClose, + } +} diff --git a/apps/web/src/pages/assets/hooks/useAssetSelection.ts b/apps/web/src/pages/assets/hooks/useAssetSelection.ts new file mode 100644 index 000000000..3bdb14e5d --- /dev/null +++ b/apps/web/src/pages/assets/hooks/useAssetSelection.ts @@ -0,0 +1,40 @@ +import { useState, useCallback } from "react" +import type { AssetItem } from "../types" + +/** + * 素材选中态管理 Hook + * 封装单选、全选、取消全选等选中逻辑 + */ +interface UseAssetSelectionProps { + filteredAssets: AssetItem[] +} + +export function useAssetSelection({ filteredAssets }: UseAssetSelectionProps) { + const [selectedIds, setSelectedIds] = useState>(new Set()) + + const toggleSelect = useCallback((id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + }, []) + + const selectAll = useCallback(() => { + setSelectedIds(new Set(filteredAssets.map((a) => a.id))) + }, [filteredAssets]) + + const deselectAll = useCallback(() => { + setSelectedIds(new Set()) + }, []) + + return { + selectedIds, + setSelectedIds, + toggleSelect, + selectAll, + deselectAll, + selectedCount: selectedIds.size, + } +} diff --git a/apps/web/src/pages/assets/hooks/useAssetUpload.ts b/apps/web/src/pages/assets/hooks/useAssetUpload.ts new file mode 100644 index 000000000..60e2a3267 --- /dev/null +++ b/apps/web/src/pages/assets/hooks/useAssetUpload.ts @@ -0,0 +1,65 @@ +import { useState, useCallback } from "react" +import { useQueryClient } from "@tanstack/react-query" +import { message } from "antd" +import { uploadAssetDirect } from "@/api/assets" +import { MAX_FILE_SIZE, LARGE_FILE_THRESHOLD } from "../constants" + +/** + * 素材上传 Hook + * 封装上传状态、进度管理和上传逻辑 + */ +interface UseAssetUploadProps { + effectiveLibId: string +} + +export function useAssetUpload({ effectiveLibId }: UseAssetUploadProps) { + const queryClient = useQueryClient() + + const [uploading, setUploading] = useState(false) + const [uploadProgress, setUploadProgress] = useState(0) + + const handleUpload = useCallback( + async (file: File) => { + if (file.size > MAX_FILE_SIZE) { + message.error(`文件 "${file.name}" 超过 2GB 限制`) + return + } + if (!effectiveLibId) { + message.warning("请先选择或创建一个视频库") + return + } + + setUploading(true) + setUploadProgress(0) + try { + if (file.size > LARGE_FILE_THRESHOLD) { + message.info(`大文件 "${file.name}" 将使用直传上传`) + } + await uploadAssetDirect({ + file, + library_id: effectiveLibId, + onProgress: (pct) => setUploadProgress(pct), + }) + message.success(`"${file.name}" 上传成功`) + queryClient.invalidateQueries({ queryKey: ["assets"] }) + queryClient.invalidateQueries({ queryKey: ["asset-libraries"] }) + } catch (err: unknown) { + const detail = err instanceof Error ? err.message : "" + console.error("[handleUpload] 上传失败:", err) + message.error(`"${file.name}" 上传失败${detail ? `:${detail}` : ""}`) + // 错误时延迟关闭弹窗,让用户能看到错误提示 + await new Promise((r) => setTimeout(r, 1500)) + } finally { + setUploading(false) + setUploadProgress(0) + } + }, + [effectiveLibId, queryClient], + ) + + return { + uploading, + uploadProgress, + handleUpload, + } +} diff --git a/apps/web/src/pages/assets/hooks/useAssetsData.ts b/apps/web/src/pages/assets/hooks/useAssetsData.ts new file mode 100644 index 000000000..34f2e8588 --- /dev/null +++ b/apps/web/src/pages/assets/hooks/useAssetsData.ts @@ -0,0 +1,118 @@ +import { useState, useMemo } from "react" +import { useQuery } from "@tanstack/react-query" +import { + getAssetLibraries, + getAssets, + type AssetLibraryItem, + type AssetItem as ApiAssetItem, +} from "@/api/assets" +import { mapLibrary, mapAsset, type AssetItem, type LibraryItem } from "../types" + +/** + * 素材库数据 Hook + * 封装视频库列表、素材列表的数据查询,以及筛选、搜索状态管理 + */ +export function useAssetsData() { + /* ── 视频库列表查询 ── */ + const { data: apiLibraries = [], isLoading: libLoading } = useQuery({ + queryKey: ["asset-libraries"], + queryFn: getAssetLibraries, + staleTime: 60_000, + }) + + const libraries: LibraryItem[] = useMemo( + () => + (Array.isArray(apiLibraries) ? apiLibraries : []) + .map(mapLibrary) + .filter((lib) => lib.kind === "video"), + [apiLibraries], + ) + + /* ── 当前选中的视频库 ── */ + const [activeLibId, setActiveLibId] = useState("") + + // 当库列表加载完成后,自动选中第一个 + const effectiveLibId = activeLibId || libraries[0]?.id || "" + + /* ── 当前库的素材列表查询 ── */ + const { + data: apiAssets = { items: [], total: 0 }, + isLoading: assetsLoading, + isError: assetsError, + error: assetsErrorObj, + refetch: refetchAssets, + } = useQuery<{ items: ApiAssetItem[]; total: number }, Error>({ + queryKey: ["assets", effectiveLibId], + queryFn: () => + getAssets(effectiveLibId, { + // 拉取所有非删除状态的素材,让用户上传后立刻能看到"处理中"的素材 + status: "ready,uploading,ingesting,processing,pending,error,failed", + }), + enabled: !!effectiveLibId, + staleTime: 30_000, + }) + + const assets: AssetItem[] = useMemo( + () => (Array.isArray(apiAssets?.items) ? apiAssets.items : []).map(mapAsset), + [apiAssets], + ) + + /* ── 筛选状态 ── */ + const [searchText, setSearchText] = useState("") + const [filterType, setFilterType] = useState("all") + const [filterTime, setFilterTime] = useState("all") + + /* ── 筛选后的素材列表 ── */ + const filteredAssets = useMemo(() => { + let list = assets + + /* 按素材类型过滤 */ + 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]) + + return { + // 视频库 + libraries, + libLoading, + activeLibId, + setActiveLibId, + effectiveLibId, + // 素材列表 + assets, + assetsLoading, + assetsError, + assetsErrorObj, + refetchAssets, + // 筛选 + searchText, + setSearchText, + filterType, + setFilterType, + filterTime, + setFilterTime, + filteredAssets, + } +} diff --git a/apps/web/src/pages/assets/hooks/useLibraryManagement.ts b/apps/web/src/pages/assets/hooks/useLibraryManagement.ts new file mode 100644 index 000000000..5a2af07fd --- /dev/null +++ b/apps/web/src/pages/assets/hooks/useLibraryManagement.ts @@ -0,0 +1,106 @@ +import { useState, useCallback } from "react" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { message } from "antd" +import { createAssetLibrary, deleteAssetLibrary } from "@/api/assets" +import type { AssetKind, LibraryItem } from "../types" + +/** + * 视频库管理 Hook + * 封装视频库的创建、删除操作,以及新建弹窗的表单状态 + */ +interface UseLibraryManagementProps { + libraries: LibraryItem[] + activeLibId: string + setActiveLibId: (id: string) => void + effectiveLibId: string +} + +export function useLibraryManagement({ + libraries, + setActiveLibId, + effectiveLibId, +}: UseLibraryManagementProps) { + const queryClient = useQueryClient() + + /* ── 状态 ── */ + const [createModalOpen, setCreateModalOpen] = useState(false) + const [newLibName, setNewLibName] = useState("") + const [newLibKind, setNewLibKind] = useState("video") + + /* ── 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 handleCreateLibrary = useCallback(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 + } + }, [newLibName, newLibKind, createLibMutation, setActiveLibId]) + + /* ── 删除视频库 ── */ + const handleDeleteLibrary = useCallback( + 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 + } + }, + [deleteLibMutation, effectiveLibId, libraries, setActiveLibId], + ) + + return { + // 弹窗状态 + createModalOpen, + setCreateModalOpen, + // 表单状态 + newLibName, + setNewLibName, + newLibKind, + setNewLibKind, + // Mutations + isCreating: createLibMutation.isPending, + isDeleting: deleteLibMutation.isPending, + // Handlers + handleCreateLibrary, + handleDeleteLibrary, + } +} diff --git a/apps/web/src/test/pages/assets/smoke.test.tsx b/apps/web/src/test/pages/assets/smoke.test.tsx index d46884aa4..bf1468e9b 100644 --- a/apps/web/src/test/pages/assets/smoke.test.tsx +++ b/apps/web/src/test/pages/assets/smoke.test.tsx @@ -1,13 +1,27 @@ /** * AssetLibrary 模块 smoke test * 建立完整依赖链,确保 vitest related 模式能匹配到 - * assets 目录下所有文件的改动(包括子组件和工具函数) + * assets 目录下所有文件的改动(包括子组件、Hook 和工具函数) */ import { describe, it, expect } from "vitest" // 主组件 import "@/pages/assets/AssetLibrary" +// 子组件 +import "@/pages/assets/components/AssetCard" +import "@/pages/assets/components/AssetFilterBar" +import "@/pages/assets/components/AssetSkeleton" +import "@/pages/assets/components/BatchClassifyModal" +import "@/pages/assets/components/BatchMarkModal" +import "@/pages/assets/components/BatchOperationBar" +import "@/pages/assets/components/BatchTagModal" +import "@/pages/assets/components/CreateLibraryModal" +import "@/pages/assets/components/LibrarySidebar" +import "@/pages/assets/components/PlayModal" +import "@/pages/assets/components/ResultDrawer" +import "@/pages/assets/components/UploadProgressModal" + // 类型与常量 import "@/pages/assets/types" import "@/pages/assets/constants" @@ -15,21 +29,13 @@ import "@/pages/assets/constants" // 工具函数 import "@/pages/assets/utils/format" import "@/pages/assets/utils/asset" -import "@/pages/assets/utils/kindIcon" -// UI 组件 -import "@/pages/assets/components/AssetCard" -import "@/pages/assets/components/AssetSkeleton" -import "@/pages/assets/components/LibrarySidebar" -import "@/pages/assets/components/AssetFilterBar" -import "@/pages/assets/components/BatchOperationBar" -import "@/pages/assets/components/CreateLibraryModal" -import "@/pages/assets/components/PlayModal" -import "@/pages/assets/components/BatchTagModal" -import "@/pages/assets/components/BatchClassifyModal" -import "@/pages/assets/components/BatchMarkModal" -import "@/pages/assets/components/ResultDrawer" -import "@/pages/assets/components/UploadProgressModal" +// Hooks +import "@/pages/assets/hooks/useAssetsData" +import "@/pages/assets/hooks/useLibraryManagement" +import "@/pages/assets/hooks/useAssetUpload" +import "@/pages/assets/hooks/useAssetSelection" +import "@/pages/assets/hooks/useAssetOperations" describe("AssetLibrary module smoke test", () => { it("should load all asset modules", () => { -- 2.54.0