Compare commits

..

11 Commits

Author SHA1 Message Date
xiaoxia 40911bb9a2 style: prettier format useVoiceMaterialFilterState.ts
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Failing after 1s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Failing after 1s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Failing after 0s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 0s
PR Automation / Auto Approve on CI Green (pull_request) Failing after 0s
AI Code Review / AI Code Review (pull_request) Failing after 0s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 0s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 0s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 0s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 0s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m46s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 3m1s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m53s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 10m5s
CI/CD Pipeline / CI Gate (pull_request) Failing after 0s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
ACR Cleanup / ACR Image Cleanup (pull_request_target) Failing after 0s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 42s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1218h23m47s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1218h24m5s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1218h24m5s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1218h24m5s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1218h24m32s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1218h25m0s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1218h27m15s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1218h27m16s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1218h27m16s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1218h38m4s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1218h38m4s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1218h38m6s
2026-07-27 19:05:01 +08:00
xiaoxia 6f432bb3d5 style: prettier format useVoiceMaterialData.ts
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Failing after 0s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 0s
2026-07-27 19:04:59 +08:00
xiaoxia 44ee5f4557 style: prettier format index.ts
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 0s
PR Automation / Auto Approve on CI Green (pull_request) Failing after 0s
2026-07-27 19:04:57 +08:00
xiaoxia 9348df6ac1 fix: correct import paths (../→../../)
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Failing after 1s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 1s
2026-07-27 18:29:11 +08:00
xiaoxia 39957fa499 fix: correct import paths (../→../../) 2026-07-27 18:29:10 +08:00
xiaoxia d6c3fbd32b fix: correct import paths (../→../../)
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Failing after 1s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 1s
2026-07-27 18:29:08 +08:00
xiaoxia 512d0448cb fix: correct import paths (../→../../) 2026-07-27 18:29:07 +08:00
xiaoxia 3274763728 Merge branch 'develop' into refactor/use-voice-materials-split
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Successful in 47s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 51s
2026-07-27 18:08:08 +08:00
xiaoxia 1f6ad6aee4 fix: 替换 any 类型为 AssetLibraryItem,消除 no-explicit-any 警告
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 11s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m24s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m17s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 33s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m15s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 25s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 48s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m4s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 44s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 50s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 3m1s
AI Code Review / AI Code Review (pull_request) Successful in 5m54s
CI/CD Pipeline / CI Gate (pull_request) 失败: CI/CD Pipeline / Validate - Code Quality (pull_request), CI/CD Pipeline / Frontend Lint (pull_request), CI/CD Pipeline / PR Build Web Image (pull_request) [frontend-only]
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 39s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1221h31m13s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1221h31m22s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1221h31m24s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1221h31m26s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1221h31m30s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1223h47m16s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1223h47m18s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1223h47m20s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1222h4m6s
2026-07-27 12:48:15 +08:00
xiaoxia e75bbfb7e7 Merge branch 'develop' into refactor/use-voice-materials-split
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Successful in 58s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 49s
2026-07-27 12:34:27 +08:00
xiaoxia 3b7d9e153a refactor(voice-materials): 拆分 useVoiceMaterials Hook,按职责拆为 4 个子模块
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 15s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m19s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 47s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m23s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m49s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 29s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m2s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m13s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 50s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m33s
AI Code Review / AI Code Review (pull_request) Successful in 2m28s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 7m2s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 31s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1230h55m8s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1231h8m16s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1231h8m18s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1231h8m20s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1231h13m11s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1231h35m30s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1231h35m57s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1231h39m57s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1231h40m1s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1231h40m3s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1231h57m53s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1231h57m57s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1232h12m42s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1232h30m38s
主文件 351→105 行(-70%),拆分为:
- useVoiceTags: 标签管理
- useVoiceMaterialFilterState: 筛选视图状态
- useVoiceMaterialData: 素材库 + 列表查询
- useVoiceMaterialActions: 上传/编辑/删除操作
2026-07-27 07:21:30 +08:00
19 changed files with 961 additions and 1238 deletions
+707 -108
View File
@@ -4,132 +4,731 @@
* 支持:标题卡片展示、AI 生成标题、复制/编辑/删除、收藏、分类筛选、搜索
* 对接后端真实 API(GET/POST/PUT/DELETE /titles)
*/
import React from "react"
import { useTitleLibrary } from "./hooks/useTitleLibrary"
import { useTitleEdit } from "./hooks/useTitleEdit"
import { useTitleAI } from "./hooks/useTitleAI"
import { CategorySidebar } from "./components/title-library/CategorySidebar"
import { FilterBar } from "./components/title-library/FilterBar"
import { TitleGrid } from "./components/title-library/TitleGrid"
import { CreateTitleModal } from "./components/title-library/CreateTitleModal"
import { AIGenerateModal } from "./components/title-library/AIGenerateModal"
import React, { useMemo, useState, useCallback } from "react"
import { Modal as AntModal, message, Popconfirm } from "antd"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import {
PlusOutlined,
SearchOutlined,
DeleteOutlined,
EditOutlined,
CopyOutlined,
CheckOutlined,
RobotOutlined,
StarOutlined,
StarFilled,
FileTextOutlined,
} from "@ant-design/icons"
import { Button, Input, Select } from "@/components/ui"
import { getTitles, createTitle, updateTitle, deleteTitle, type TitleItem } from "@/api/titles"
import "./titles.css"
/* ============================================================
* 类型
* ============================================================ */
type TitleType = "hot" | "normal" | "creative"
type Industry = "general" | "food" | "tech" | "beauty" | "education" | "travel"
type Frequency = "all" | "high" | "medium" | "low"
interface TitleData {
id: string
content: string
type: TitleType
industry: Industry
category: string
usageCount: number
isFavorited: boolean
createdAt: string
}
/** 后端 TitleItem → 前端 TitleData 映射 */
const toTitleData = (item: TitleItem): TitleData => ({
id: item.id,
content: item.content,
type: (item.category as TitleType) || "normal",
industry: "general",
category: item.category || "未分类",
usageCount: 0,
isFavorited: false,
createdAt: item.created_at?.slice(0, 10) || "",
})
/* ============================================================
* 工具函数
* ============================================================ */
const typeLabel = (type: TitleType): string => {
switch (type) {
case "hot":
return "爆款"
case "normal":
return "常规"
case "creative":
return "创意"
}
}
/** 复制文本到剪贴板 */
const copyToClipboard = async (text: string): Promise<boolean> => {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
/* 降级方案 */
const textarea = document.createElement("textarea")
textarea.value = text
textarea.style.position = "fixed"
textarea.style.opacity = "0"
document.body.appendChild(textarea)
textarea.select()
try {
document.execCommand("copy")
return true
} catch {
return false
} finally {
document.body.removeChild(textarea)
}
}
}
/* ============================================================
* TitleCard 组件
* ============================================================ */
const TitleCard: React.FC<{
title: TitleData
isEditing: boolean
editText: string
onEditChange: (text: string) => void
onStartEdit: () => void
onSaveEdit: () => void
onCancelEdit: () => void
onCopy: () => void
onDelete: () => void
onToggleFavorite: () => void
}> = ({
title,
isEditing,
editText,
onEditChange,
onStartEdit,
onSaveEdit,
onCancelEdit,
onCopy,
onDelete,
onToggleFavorite,
}) => {
return (
<div className="xx-title-card">
{/* 收藏按钮 */}
<button
className="xx-title-fav-btn"
onClick={onToggleFavorite}
title={title.isFavorited ? "取消收藏" : "收藏"}
style={{
position: "absolute",
top: 12,
right: 12,
color: title.isFavorited ? "#f59e0b" : "var(--text-tertiary)",
}}
>
{title.isFavorited ? <StarFilled /> : <StarOutlined />}
</button>
{/* 标题文本 / 编辑区 */}
{isEditing ? (
<textarea
className="xx-title-card-edit"
value={editText}
onChange={(e) => onEditChange(e.target.value)}
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
onSaveEdit()
}
if (e.key === "Escape") {
onCancelEdit()
}
}}
/>
) : (
<div className="xx-title-card-text" style={{ paddingRight: 24 }}>
{title.content}
</div>
)}
{/* 底部元信息 */}
<div className="xx-title-card-meta">
<div className="xx-title-card-meta-left">
<span className={`xx-title-type-tag ${title.type}`}>{typeLabel(title.type)}</span>
<span className="xx-title-card-stat">使用 {title.usageCount} 次</span>
<span className="xx-title-card-stat">{title.createdAt}</span>
</div>
<div className="xx-title-card-actions">
{isEditing ? (
<>
<button className="xx-title-card-action-btn" onClick={onSaveEdit} title="保存">
<CheckOutlined />
</button>
<button className="xx-title-card-action-btn" onClick={onCancelEdit} title="取消">
✕
</button>
</>
) : (
<>
<button className="xx-title-card-action-btn" onClick={onCopy} title="复制">
<CopyOutlined />
</button>
<button className="xx-title-card-action-btn" onClick={onStartEdit} title="编辑">
<EditOutlined />
</button>
<Popconfirm
title="确定删除此标题?"
onConfirm={onDelete}
okText="删除"
cancelText="取消"
>
<button className="xx-title-card-action-btn danger" title="删除">
<DeleteOutlined />
</button>
</Popconfirm>
</>
)}
</div>
</div>
</div>
)
}
/* ============================================================
* 主组件
* ============================================================ */
const TitleLibrary: React.FC = () => {
const {
categories,
activeCatId,
filteredTitles,
searchText,
filterType,
filterIndustry,
filterFrequency,
createMutation,
updateMutation,
setActiveCatId,
setSearchText,
setFilterType,
setFilterIndustry,
setFilterFrequency,
handleToggleFavorite,
handleCopy,
handleDelete,
} = useTitleLibrary()
const queryClient = useQueryClient()
const {
editingId,
editText,
setEditText,
createTitleModalOpen,
setCreateTitleModalOpen,
newTitleContent,
setNewTitleContent,
newTitleType,
setNewTitleType,
handleStartEdit,
handleSaveEdit,
handleCancelEdit,
handleCreateTitle,
handleCloseCreateModal,
} = useTitleEdit({ updateMutation, createMutation })
/* 分类数据 — 从真实标题数据动态派生 */
const [activeCatId, setActiveCatId] = useState<string>("cat-all")
const {
aiModalOpen,
setAiModalOpen,
aiKeyword,
setAiKeyword,
aiLoading,
aiResults,
handleAIGenerate,
handleAdoptAITitle,
handleCopyAI,
handleCloseAIModal,
} = useTitleAI({ createMutation })
/* 标题数据 — 真实 API */
const { data: apiTitles = [] } = useQuery({
queryKey: ["titles"],
queryFn: getTitles,
staleTime: 30_000,
})
const titles: TitleData[] = useMemo(() => apiTitles.map(toTitleData), [apiTitles])
/* 从真实标题数据动态派生分类(无需后端分类 API) */
const categories = useMemo(() => {
const cats = new Map<string, number>()
apiTitles.forEach((t) => {
const cat = t.category || "未分类"
cats.set(cat, (cats.get(cat) || 0) + 1)
})
return [
{ id: "cat-all", name: "全部标题", count: apiTitles.length },
...Array.from(cats.entries()).map(([name, count]) => ({
id: `cat-${name}`,
name,
count,
})),
]
}, [apiTitles])
/* CRUD mutations */
const createMutation = useMutation({
mutationFn: (content: string) => createTitle({ content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] })
},
onError: () => message.error("创建标题失败"),
})
const updateMutation = useMutation({
mutationFn: ({ id, content }: { id: string; content: string }) => updateTitle(id, { content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] })
},
onError: () => message.error("更新标题失败"),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteTitle(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] })
},
onError: () => message.error("删除标题失败"),
})
/* 筛选 */
const [searchText, setSearchText] = useState("")
const [filterType, setFilterType] = useState<string>("all")
const [filterIndustry, setFilterIndustry] = useState<string>("all")
const [filterFrequency, setFilterFrequency] = useState<Frequency>("all")
/* 编辑状态 */
const [editingId, setEditingId] = useState<string | null>(null)
const [editText, setEditText] = useState("")
/* 新建标题 */
const [createTitleModalOpen, setCreateTitleModalOpen] = useState(false)
const [newTitleContent, setNewTitleContent] = useState("")
const [newTitleType, setNewTitleType] = useState<TitleType>("normal")
/* AI 生成 */
const [aiModalOpen, setAiModalOpen] = useState(false)
const [aiKeyword, setAiKeyword] = useState("")
const [aiLoading, setAiLoading] = useState(false)
const [aiResults, setAiResults] = useState<string[]>([])
/* 派生数据 */
const activeCategory = categories.find((c) => c.id === activeCatId)
const filteredTitles = useMemo(() => {
let list = titles
/* 按分类过滤("全部标题" 不过滤)— 直接匹配后端 category 字段 */
if (activeCatId !== "cat-all") {
const catName = activeCategory?.name || ""
if (catName) {
list = list.filter((t) => t.category === catName)
}
}
/* 按类型筛选 */
if (filterType !== "all") {
list = list.filter((t) => t.type === filterType)
}
/* 按行业筛选 */
if (filterIndustry !== "all") {
list = list.filter((t) => t.industry === filterIndustry)
}
/* 按使用频率筛选 */
if (filterFrequency !== "all") {
switch (filterFrequency) {
case "high":
list = list.filter((t) => t.usageCount >= 100)
break
case "medium":
list = list.filter((t) => t.usageCount >= 30 && t.usageCount < 100)
break
case "low":
list = list.filter((t) => t.usageCount < 30)
break
}
}
/* 搜索 */
if (searchText.trim()) {
const q = searchText.trim().toLowerCase()
list = list.filter((t) => t.content.toLowerCase().includes(q))
}
return list
}, [titles, activeCatId, activeCategory, filterType, filterIndustry, filterFrequency, searchText])
/* 收藏切换(暂不支持,待后端 API) */
const handleToggleFavorite = useCallback((_id: string) => {
message.info("收藏功能即将上线")
}, [])
/* 复制 */
const handleCopy = useCallback(async (title: TitleData) => {
const ok = await copyToClipboard(title.content)
if (ok) {
message.success("已复制到剪贴板")
} else {
message.error("复制失败")
}
}, [])
/* 编辑 */
const handleStartEdit = useCallback((title: TitleData) => {
setEditingId(title.id)
setEditText(title.content)
}, [])
const handleSaveEdit = useCallback(() => {
if (!editText.trim()) {
message.warning("标题内容不能为空")
return
}
if (editingId) {
updateMutation.mutate({ id: editingId, content: editText.trim() })
}
setEditingId(null)
setEditText("")
message.success("标题已更新")
}, [editingId, editText, updateMutation])
const handleCancelEdit = useCallback(() => {
setEditingId(null)
setEditText("")
}, [])
/* 删除 */
const handleDelete = useCallback(
(id: string) => {
deleteMutation.mutate(id)
message.success("标题已删除")
},
[deleteMutation],
)
/* 新建标题 */
const handleCreateTitle = () => {
if (!newTitleContent.trim()) {
message.warning("请输入标题内容")
return
}
createMutation.mutate(newTitleContent.trim(), {
onSuccess: () => {
setCreateTitleModalOpen(false)
setNewTitleContent("")
setNewTitleType("normal")
message.success("标题创建成功")
},
})
}
/* AI 生成标题 */
const handleAIGenerate = () => {
if (!aiKeyword.trim()) {
message.warning("请输入关键词或主题")
return
}
setAiLoading(true)
setAiResults([])
/* Mock AI 生成延迟 */
setTimeout(() => {
const keyword = aiKeyword.trim()
const results = [
`${keyword}:这个方法让我事半功倍!`,
`关于${keyword},99%的人都不知道的事`,
`${keyword}全攻略,看完这篇就够了`,
`我花了 3 个月研究${keyword},总结出这些经验`,
`${keyword}避坑指南,帮你省下 1000 块`,
]
setAiResults(results)
setAiLoading(false)
}, 2000)
}
/* 采纳 AI 生成的标题 */
const handleAdoptAITitle = (text: string) => {
createMutation.mutate(text, {
onSuccess: () => {
message.success("标题已采纳并添加到标题库")
},
})
}
/* 复制 AI 生成的标题 */
const handleCopyAI = async (text: string) => {
const ok = await copyToClipboard(text)
if (ok) {
message.success("已复制到剪贴板")
} else {
message.error("复制失败")
}
}
return (
<div className="xx-titles-page">
{/* 两栏布局 */}
<div className="xx-titles-layout">
{/* 左侧:分类列表 */}
<CategorySidebar
categories={categories}
activeCatId={activeCatId}
onSelect={setActiveCatId}
/>
{/* ─── 左侧:分类列表 ─── */}
<div className="xx-title-category-list">
{categories.map((cat) => (
<div
key={cat.id}
className={`xx-title-category-item${cat.id === activeCatId ? " active" : ""}`}
onClick={() => setActiveCatId(cat.id)}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flex: 1,
minWidth: 0,
}}
>
<div style={{ minWidth: 0 }}>
<h4 style={{ margin: 0 }}>
<FileTextOutlined /> {cat.name}
</h4>
<span>{cat.count} 条</span>
</div>
</div>
</div>
))}
{/* 右侧:内容区 */}
{/* TODO: 新建分类功能待后端分类 API 就绪后启用 */}
</div>
{/* ─── 右侧:内容区 ─── */}
<div className="xx-titles-content">
<FilterBar
searchText={searchText}
onSearchChange={setSearchText}
filterType={filterType}
onFilterTypeChange={setFilterType}
filterIndustry={filterIndustry}
onFilterIndustryChange={setFilterIndustry}
filterFrequency={filterFrequency}
onFilterFrequencyChange={setFilterFrequency}
onCreateClick={() => setCreateTitleModalOpen(true)}
onAIClick={() => setAiModalOpen(true)}
/>
{/* 筛选栏 */}
<div className="xx-titles-filters">
<div className="xx-titles-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: 110 }}
options={[
{ value: "all", label: "全部类型" },
{ value: "hot", label: "爆款" },
{ value: "normal", label: "常规" },
{ value: "creative", label: "创意" },
]}
/>
<Select
value={filterIndustry}
onChange={setFilterIndustry}
style={{ width: 110 }}
options={[
{ value: "all", label: "全部行业" },
{ value: "food", label: "美食" },
{ value: "tech", label: "科技" },
{ value: "beauty", label: "美妆" },
{ value: "education", label: "教育" },
{ value: "travel", label: "旅行" },
]}
/>
<Select
value={filterFrequency}
onChange={(v) => setFilterFrequency(v as Frequency)}
style={{ width: 120 }}
options={[
{ value: "all", label: "全部频率" },
{ value: "high", label: "高频使用" },
{ value: "medium", label: "中频使用" },
{ value: "low", label: "低频使用" },
]}
/>
</div>
<div className="xx-titles-filters-right">
<Button
buttonType="ghost"
buttonSize="sm"
icon={<PlusOutlined />}
onClick={() => setCreateTitleModalOpen(true)}
>
新建标题
</Button>
<Button
buttonType="primary"
buttonSize="sm"
icon={<RobotOutlined />}
onClick={() => setAiModalOpen(true)}
>
AI 生成标题
</Button>
</div>
</div>
<TitleGrid
titles={filteredTitles}
editingId={editingId}
editText={editText}
searchText={searchText}
onEditChange={setEditText}
onStartEdit={handleStartEdit}
onSaveEdit={handleSaveEdit}
onCancelEdit={handleCancelEdit}
onCopy={handleCopy}
onDelete={handleDelete}
onToggleFavorite={handleToggleFavorite}
/>
{/* 标题卡片网格 */}
{filteredTitles.length > 0 ? (
<div className="xx-title-grid">
{filteredTitles.map((title) => (
<TitleCard
key={title.id}
title={title}
isEditing={editingId === title.id}
editText={editingId === title.id ? editText : ""}
onEditChange={setEditText}
onStartEdit={() => handleStartEdit(title)}
onSaveEdit={handleSaveEdit}
onCancelEdit={handleCancelEdit}
onCopy={() => handleCopy(title)}
onDelete={() => handleDelete(title.id)}
onToggleFavorite={() => handleToggleFavorite(title.id)}
/>
))}
</div>
) : (
<div className="xx-titles-empty">
<div className="xx-titles-empty-icon">
<FileTextOutlined />
</div>
<p>
{searchText
? "未找到匹配的标题"
: "暂无标题,点击「新建标题」或「AI 生成标题」开始"}
</p>
</div>
)}
</div>
</div>
{/* 新建标题弹窗 */}
<CreateTitleModal
{/* ─── 新建标题弹窗 ─── */}
<AntModal
title="新建标题"
open={createTitleModalOpen}
newTitleContent={newTitleContent}
newTitleType={newTitleType}
onContentChange={setNewTitleContent}
onTypeChange={setNewTitleType}
onCancel={handleCloseCreateModal}
onSubmit={handleCreateTitle}
/>
onCancel={() => setCreateTitleModalOpen(false)}
onOk={handleCreateTitle}
okText="创建"
cancelText="取消"
destroyOnClose
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 16,
padding: "8px 0",
}}
>
<div>
<div
style={{
marginBottom: 6,
fontSize: "var(--font-size-sm)",
color: "var(--text-secondary)",
}}
>
标题内容
</div>
<Input.TextArea
placeholder="请输入标题内容"
value={newTitleContent}
onChange={(e) => setNewTitleContent(e.target.value)}
rows={3}
maxLength={200}
showCount
/>
</div>
<div>
<div
style={{
marginBottom: 6,
fontSize: "var(--font-size-sm)",
color: "var(--text-secondary)",
}}
>
标题类型
</div>
<Select
value={newTitleType}
onChange={(v) => setNewTitleType(v)}
style={{ width: "100%" }}
options={[
{ value: "hot", label: "爆款" },
{ value: "normal", label: "常规" },
{ value: "creative", label: "创意" },
]}
/>
</div>
</div>
</AntModal>
{/* AI 生成标题弹窗 */}
<AIGenerateModal
{/* ─── AI 生成标题弹窗 ─── */}
<AntModal
title="AI 生成标题"
open={aiModalOpen}
aiKeyword={aiKeyword}
aiLoading={aiLoading}
aiResults={aiResults}
onKeywordChange={setAiKeyword}
onGenerate={handleAIGenerate}
onCancel={handleCloseAIModal}
onCopy={handleCopyAI}
onAdopt={handleAdoptAITitle}
/>
onCancel={() => {
setAiModalOpen(false)
setAiLoading(false)
setAiResults([])
setAiKeyword("")
}}
onOk={handleAIGenerate}
okText={aiLoading ? "生成中..." : "生成"}
cancelText="关闭"
okButtonProps={{ disabled: aiLoading }}
destroyOnClose
width={640}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 16,
padding: "8px 0",
}}
>
<div>
<div
style={{
marginBottom: 6,
fontSize: "var(--font-size-sm)",
color: "var(--text-secondary)",
}}
>
输入关键词或主题
</div>
<Input
placeholder="例如:美食探店、科技评测、旅行攻略..."
value={aiKeyword}
onChange={(e) => setAiKeyword(e.target.value)}
maxLength={100}
onPressEnter={handleAIGenerate}
/>
</div>
{/* AI 加载动画 */}
{aiLoading && (
<div className="xx-ai-loading">
<div className="xx-ai-loading-dots">
<div className="xx-ai-loading-dot" />
<div className="xx-ai-loading-dot" />
<div className="xx-ai-loading-dot" />
</div>
<span>AI 正在生成标题候选...</span>
</div>
)}
{/* AI 生成结果列表 */}
{aiResults.length > 0 && (
<div className="xx-ai-results">
<div
style={{
fontSize: "var(--font-size-sm)",
color: "var(--text-secondary)",
marginBottom: 4,
}}
>
已生成 {aiResults.length} 个候选标题,点击采纳或复制:
</div>
{aiResults.map((text, idx) => (
<div key={idx} className="xx-ai-result-item">
<span className="xx-ai-result-text">{text}</span>
<div className="xx-ai-result-actions">
<Button
buttonType="ghost"
buttonSize="sm"
icon={<CopyOutlined />}
onClick={() => handleCopyAI(text)}
>
复制
</Button>
<Button
buttonType="primary"
buttonSize="sm"
icon={<CheckOutlined />}
onClick={() => handleAdoptAITitle(text)}
>
采纳
</Button>
</div>
</div>
))}
</div>
)}
</div>
</AntModal>
</div>
)
}
@@ -1,121 +0,0 @@
import React from "react"
import { Modal as AntModal } from "antd"
import { CopyOutlined, CheckOutlined } from "@ant-design/icons"
import { Button, Input } from "@/components/ui"
import { AI_KEYWORD_MAX_LENGTH } from "../../constants/titleLibrary"
interface AIGenerateModalProps {
open: boolean
aiKeyword: string
aiLoading: boolean
aiResults: string[]
onKeywordChange: (keyword: string) => void
onGenerate: () => void
onCancel: () => void
onCopy: (text: string) => void
onAdopt: (text: string) => void
}
export const AIGenerateModal: React.FC<AIGenerateModalProps> = ({
open,
aiKeyword,
aiLoading,
aiResults,
onKeywordChange,
onGenerate,
onCancel,
onCopy,
onAdopt,
}) => {
return (
<AntModal
title="AI 生成标题"
open={open}
onCancel={onCancel}
onOk={onGenerate}
okText={aiLoading ? "生成中..." : "生成"}
cancelText="关闭"
okButtonProps={{ disabled: aiLoading }}
destroyOnClose
width={640}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 16,
padding: "8px 0",
}}
>
<div>
<div
style={{
marginBottom: 6,
fontSize: "var(--font-size-sm)",
color: "var(--text-secondary)",
}}
>
输入关键词或主题
</div>
<Input
placeholder="例如:美食探店、科技评测、旅行攻略..."
value={aiKeyword}
onChange={(e) => onKeywordChange(e.target.value)}
maxLength={AI_KEYWORD_MAX_LENGTH}
onPressEnter={onGenerate}
/>
</div>
{/* AI 加载动画 */}
{aiLoading && (
<div className="xx-ai-loading">
<div className="xx-ai-loading-dots">
<div className="xx-ai-loading-dot" />
<div className="xx-ai-loading-dot" />
<div className="xx-ai-loading-dot" />
</div>
<span>AI 正在生成标题候选...</span>
</div>
)}
{/* AI 生成结果列表 */}
{aiResults.length > 0 && (
<div className="xx-ai-results">
<div
style={{
fontSize: "var(--font-size-sm)",
color: "var(--text-secondary)",
marginBottom: 4,
}}
>
已生成 {aiResults.length} 个候选标题,点击采纳或复制:
</div>
{aiResults.map((text, idx) => (
<div key={idx} className="xx-ai-result-item">
<span className="xx-ai-result-text">{text}</span>
<div className="xx-ai-result-actions">
<Button
buttonType="ghost"
buttonSize="sm"
icon={<CopyOutlined />}
onClick={() => onCopy(text)}
>
复制
</Button>
<Button
buttonType="primary"
buttonSize="sm"
icon={<CheckOutlined />}
onClick={() => onAdopt(text)}
>
采纳
</Button>
</div>
</div>
))}
</div>
)}
</div>
</AntModal>
)
}
@@ -1,44 +0,0 @@
import React from "react"
import { FileTextOutlined } from "@ant-design/icons"
import type { CategoryItem } from "../../types/titleLibrary"
interface CategorySidebarProps {
categories: CategoryItem[]
activeCatId: string
onSelect: (catId: string) => void
}
export const CategorySidebar: React.FC<CategorySidebarProps> = ({
categories,
activeCatId,
onSelect,
}) => {
return (
<div className="xx-title-category-list">
{categories.map((cat) => (
<div
key={cat.id}
className={`xx-title-category-item${cat.id === activeCatId ? " active" : ""}`}
onClick={() => onSelect(cat.id)}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flex: 1,
minWidth: 0,
}}
>
<div style={{ minWidth: 0 }}>
<h4 style={{ margin: 0 }}>
<FileTextOutlined /> {cat.name}
</h4>
<span>{cat.count} 条</span>
</div>
</div>
</div>
))}
</div>
)
}
@@ -1,89 +0,0 @@
import React from "react"
import { Modal as AntModal } from "antd"
import { Input, Select } from "@/components/ui"
import type { TitleType } from "../../types/titleLibrary"
import { TITLE_MAX_LENGTH } from "../../constants/titleLibrary"
const TITLE_TYPE_CREATE_OPTIONS: Array<{ value: TitleType; label: string }> = [
{ value: "hot", label: "爆款" },
{ value: "normal", label: "常规" },
{ value: "creative", label: "创意" },
]
interface CreateTitleModalProps {
open: boolean
newTitleContent: string
newTitleType: TitleType
onContentChange: (content: string) => void
onTypeChange: (type: TitleType) => void
onCancel: () => void
onSubmit: () => void
}
export const CreateTitleModal: React.FC<CreateTitleModalProps> = ({
open,
newTitleContent,
newTitleType,
onContentChange,
onTypeChange,
onCancel,
onSubmit,
}) => {
return (
<AntModal
title="新建标题"
open={open}
onCancel={onCancel}
onOk={onSubmit}
okText="创建"
cancelText="取消"
destroyOnClose
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 16,
padding: "8px 0",
}}
>
<div>
<div
style={{
marginBottom: 6,
fontSize: "var(--font-size-sm)",
color: "var(--text-secondary)",
}}
>
标题内容
</div>
<Input.TextArea
placeholder="请输入标题内容"
value={newTitleContent}
onChange={(e) => onContentChange(e.target.value)}
rows={3}
maxLength={TITLE_MAX_LENGTH}
showCount
/>
</div>
<div>
<div
style={{
marginBottom: 6,
fontSize: "var(--font-size-sm)",
color: "var(--text-secondary)",
}}
>
标题类型
</div>
<Select
value={newTitleType}
onChange={(v) => onTypeChange(v as TitleType)}
style={{ width: "100%" }}
options={TITLE_TYPE_CREATE_OPTIONS}
/>
</div>
</div>
</AntModal>
)
}
@@ -1,76 +0,0 @@
import React from "react"
import { SearchOutlined, PlusOutlined, RobotOutlined } from "@ant-design/icons"
import { Button, Input, Select } from "@/components/ui"
import type { Frequency } from "../../types/titleLibrary"
import {
TITLE_TYPE_OPTIONS,
INDUSTRY_OPTIONS,
FREQUENCY_OPTIONS,
} from "../../constants/titleLibrary"
interface FilterBarProps {
searchText: string
onSearchChange: (text: string) => void
filterType: string
onFilterTypeChange: (value: string) => void
filterIndustry: string
onFilterIndustryChange: (value: string) => void
filterFrequency: Frequency
onFilterFrequencyChange: (value: Frequency) => void
onCreateClick: () => void
onAIClick: () => void
}
export const FilterBar: React.FC<FilterBarProps> = ({
searchText,
onSearchChange,
filterType,
onFilterTypeChange,
filterIndustry,
onFilterIndustryChange,
filterFrequency,
onFilterFrequencyChange,
onCreateClick,
onAIClick,
}) => {
return (
<div className="xx-titles-filters">
<div className="xx-titles-filters-left">
<Input
placeholder="搜索标题关键词..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => onSearchChange(e.target.value)}
allowClear
style={{ width: 220 }}
/>
<Select
value={filterType}
onChange={onFilterTypeChange}
style={{ width: 110 }}
options={TITLE_TYPE_OPTIONS}
/>
<Select
value={filterIndustry}
onChange={onFilterIndustryChange}
style={{ width: 110 }}
options={INDUSTRY_OPTIONS}
/>
<Select
value={filterFrequency}
onChange={(v) => onFilterFrequencyChange(v as Frequency)}
style={{ width: 120 }}
options={FREQUENCY_OPTIONS}
/>
</div>
<div className="xx-titles-filters-right">
<Button buttonType="ghost" buttonSize="sm" icon={<PlusOutlined />} onClick={onCreateClick}>
新建标题
</Button>
<Button buttonType="primary" buttonSize="sm" icon={<RobotOutlined />} onClick={onAIClick}>
AI 生成标题
</Button>
</div>
</div>
)
}
@@ -1,121 +0,0 @@
import React from "react"
import { Popconfirm } from "antd"
import {
StarOutlined,
StarFilled,
EditOutlined,
CopyOutlined,
DeleteOutlined,
CheckOutlined,
} from "@ant-design/icons"
import type { TitleData } from "../../types/titleLibrary"
import { typeLabel } from "../../utils/titleLibrary"
interface TitleCardProps {
title: TitleData
isEditing: boolean
editText: string
onEditChange: (text: string) => void
onStartEdit: () => void
onSaveEdit: () => void
onCancelEdit: () => void
onCopy: () => void
onDelete: () => void
onToggleFavorite: () => void
}
export const TitleCard: React.FC<TitleCardProps> = ({
title,
isEditing,
editText,
onEditChange,
onStartEdit,
onSaveEdit,
onCancelEdit,
onCopy,
onDelete,
onToggleFavorite,
}) => {
return (
<div className="xx-title-card">
{/* 收藏按钮 */}
<button
className="xx-title-fav-btn"
onClick={onToggleFavorite}
title={title.isFavorited ? "取消收藏" : "收藏"}
style={{
position: "absolute",
top: 12,
right: 12,
color: title.isFavorited ? "#f59e0b" : "var(--text-tertiary)",
}}
>
{title.isFavorited ? <StarFilled /> : <StarOutlined />}
</button>
{/* 标题文本 / 编辑区 */}
{isEditing ? (
<textarea
className="xx-title-card-edit"
value={editText}
onChange={(e) => onEditChange(e.target.value)}
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
onSaveEdit()
}
if (e.key === "Escape") {
onCancelEdit()
}
}}
/>
) : (
<div className="xx-title-card-text" style={{ paddingRight: 24 }}>
{title.content}
</div>
)}
{/* 底部元信息 */}
<div className="xx-title-card-meta">
<div className="xx-title-card-meta-left">
<span className={`xx-title-type-tag ${title.type}`}>{typeLabel(title.type)}</span>
<span className="xx-title-card-stat">使用 {title.usageCount} 次</span>
<span className="xx-title-card-stat">{title.createdAt}</span>
</div>
<div className="xx-title-card-actions">
{isEditing ? (
<>
<button className="xx-title-card-action-btn" onClick={onSaveEdit} title="保存">
<CheckOutlined />
</button>
<button className="xx-title-card-action-btn" onClick={onCancelEdit} title="取消">
✕
</button>
</>
) : (
<>
<button className="xx-title-card-action-btn" onClick={onCopy} title="复制">
<CopyOutlined />
</button>
<button className="xx-title-card-action-btn" onClick={onStartEdit} title="编辑">
<EditOutlined />
</button>
<Popconfirm
title="确定删除此标题?"
onConfirm={onDelete}
okText="删除"
cancelText="取消"
>
<button className="xx-title-card-action-btn danger" title="删除">
<DeleteOutlined />
</button>
</Popconfirm>
</>
)}
</div>
</div>
</div>
)
}
@@ -1,63 +0,0 @@
import React from "react"
import { FileTextOutlined } from "@ant-design/icons"
import { TitleCard } from "./TitleCard"
import type { TitleData } from "../../types/titleLibrary"
interface TitleGridProps {
titles: TitleData[]
editingId: string | null
editText: string
searchText: string
onEditChange: (text: string) => void
onStartEdit: (title: TitleData) => void
onSaveEdit: () => void
onCancelEdit: () => void
onCopy: (title: TitleData) => void
onDelete: (id: string) => void
onToggleFavorite: (id: string) => void
}
export const TitleGrid: React.FC<TitleGridProps> = ({
titles,
editingId,
editText,
searchText,
onEditChange,
onStartEdit,
onSaveEdit,
onCancelEdit,
onCopy,
onDelete,
onToggleFavorite,
}) => {
if (titles.length > 0) {
return (
<div className="xx-title-grid">
{titles.map((title) => (
<TitleCard
key={title.id}
title={title}
isEditing={editingId === title.id}
editText={editingId === title.id ? editText : ""}
onEditChange={onEditChange}
onStartEdit={() => onStartEdit(title)}
onSaveEdit={onSaveEdit}
onCancelEdit={onCancelEdit}
onCopy={() => onCopy(title)}
onDelete={() => onDelete(title.id)}
onToggleFavorite={() => onToggleFavorite(title.id)}
/>
))}
</div>
)
}
return (
<div className="xx-titles-empty">
<div className="xx-titles-empty-icon">
<FileTextOutlined />
</div>
<p>{searchText ? "未找到匹配的标题" : "暂无标题,点击「新建标题」或「AI 生成标题」开始"}</p>
</div>
)
}
@@ -1,34 +0,0 @@
import type { TitleType, Industry, Frequency } from "../types/titleLibrary"
export const TITLE_TYPE_OPTIONS: Array<{ value: TitleType | "all"; label: string }> = [
{ value: "all", label: "全部类型" },
{ value: "hot", label: "爆款" },
{ value: "normal", label: "常规" },
{ value: "creative", label: "创意" },
]
export const INDUSTRY_OPTIONS: Array<{ value: Industry | "all"; label: string }> = [
{ value: "all", label: "全部行业" },
{ value: "food", label: "美食" },
{ value: "tech", label: "科技" },
{ value: "beauty", label: "美妆" },
{ value: "education", label: "教育" },
{ value: "travel", label: "旅行" },
]
export const FREQUENCY_OPTIONS: Array<{ value: Frequency; label: string }> = [
{ value: "all", label: "全部频率" },
{ value: "high", label: "高频使用" },
{ value: "medium", label: "中频使用" },
{ value: "low", label: "低频使用" },
]
export const FREQUENCY_THRESHOLDS = {
high: 100,
medium: 30,
} as const
export const AI_GENERATE_DELAY = 2000
export const TITLE_MAX_LENGTH = 200
export const AI_KEYWORD_MAX_LENGTH = 100
export const ALL_CATEGORY_ID = "cat-all"
@@ -1,84 +0,0 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import type { UseMutationResult } from "@tanstack/react-query"
import type { TitleItem } from "@/api/titles"
import { copyToClipboard } from "../utils/titleLibrary"
import { AI_GENERATE_DELAY } from "../constants/titleLibrary"
interface UseTitleAIProps {
createMutation: UseMutationResult<TitleItem, Error, string, unknown>
}
const generateMockTitles = (keyword: string): string[] => [
`${keyword}:这个方法让我事半功倍!`,
`关于${keyword},99%的人都不知道的事`,
`${keyword}全攻略,看完这篇就够了`,
`我花了 3 个月研究${keyword},总结出这些经验`,
`${keyword}避坑指南,帮你省下 1000 块`,
]
export const useTitleAI = ({ createMutation }: UseTitleAIProps) => {
const [aiModalOpen, setAiModalOpen] = useState(false)
const [aiKeyword, setAiKeyword] = useState("")
const [aiLoading, setAiLoading] = useState(false)
const [aiResults, setAiResults] = useState<string[]>([])
/* AI 生成标题 */
const handleAIGenerate = useCallback(() => {
if (!aiKeyword.trim()) {
message.warning("请输入关键词或主题")
return
}
setAiLoading(true)
setAiResults([])
setTimeout(() => {
const results = generateMockTitles(aiKeyword.trim())
setAiResults(results)
setAiLoading(false)
}, AI_GENERATE_DELAY)
}, [aiKeyword])
/* 采纳 AI 生成的标题 */
const handleAdoptAITitle = useCallback(
(text: string) => {
createMutation.mutate(text, {
onSuccess: () => {
message.success("标题已采纳并添加到标题库")
},
})
},
[createMutation],
)
/* 复制 AI 生成的标题 */
const handleCopyAI = useCallback(async (text: string) => {
const ok = await copyToClipboard(text)
if (ok) {
message.success("已复制到剪贴板")
} else {
message.error("复制失败")
}
}, [])
/* 关闭 AI 弹窗 */
const handleCloseAIModal = useCallback(() => {
setAiModalOpen(false)
setAiLoading(false)
setAiResults([])
setAiKeyword("")
}, [])
return {
aiModalOpen,
setAiModalOpen,
aiKeyword,
setAiKeyword,
aiLoading,
aiResults,
handleAIGenerate,
handleAdoptAITitle,
handleCopyAI,
handleCloseAIModal,
}
}
@@ -1,87 +0,0 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import type { TitleData, TitleType } from "../types/titleLibrary"
import type { UseMutationResult } from "@tanstack/react-query"
import type { TitleItem } from "@/api/titles"
interface UseTitleEditProps {
updateMutation: UseMutationResult<TitleItem, Error, { id: string; content: string }, unknown>
createMutation: UseMutationResult<TitleItem, Error, string, unknown>
}
export const useTitleEdit = ({ updateMutation, createMutation }: UseTitleEditProps) => {
/* 编辑状态 */
const [editingId, setEditingId] = useState<string | null>(null)
const [editText, setEditText] = useState("")
/* 新建标题弹窗 */
const [createTitleModalOpen, setCreateTitleModalOpen] = useState(false)
const [newTitleContent, setNewTitleContent] = useState("")
const [newTitleType, setNewTitleType] = useState<TitleType>("normal")
/* 开始编辑 */
const handleStartEdit = useCallback((title: TitleData) => {
setEditingId(title.id)
setEditText(title.content)
}, [])
/* 保存编辑 */
const handleSaveEdit = useCallback(() => {
if (!editText.trim()) {
message.warning("标题内容不能为空")
return
}
if (editingId) {
updateMutation.mutate({ id: editingId, content: editText.trim() })
}
setEditingId(null)
setEditText("")
message.success("标题已更新")
}, [editingId, editText, updateMutation])
/* 取消编辑 */
const handleCancelEdit = useCallback(() => {
setEditingId(null)
setEditText("")
}, [])
/* 新建标题提交 */
const handleCreateTitle = useCallback(() => {
if (!newTitleContent.trim()) {
message.warning("请输入标题内容")
return
}
createMutation.mutate(newTitleContent.trim(), {
onSuccess: () => {
setCreateTitleModalOpen(false)
setNewTitleContent("")
setNewTitleType("normal")
message.success("标题创建成功")
},
})
}, [newTitleContent, newTitleType, createMutation])
/* 关闭新建弹窗 */
const handleCloseCreateModal = useCallback(() => {
setCreateTitleModalOpen(false)
setNewTitleContent("")
setNewTitleType("normal")
}, [])
return {
editingId,
editText,
setEditText,
createTitleModalOpen,
setCreateTitleModalOpen,
newTitleContent,
setNewTitleContent,
newTitleType,
setNewTitleType,
handleStartEdit,
handleSaveEdit,
handleCancelEdit,
handleCreateTitle,
handleCloseCreateModal,
}
}
@@ -1,174 +0,0 @@
import { useMemo, useState, useCallback } from "react"
import { message } from "antd"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { getTitles, createTitle, updateTitle, deleteTitle } from "@/api/titles"
import type { TitleData, CategoryItem, Frequency } from "../types/titleLibrary"
import { toTitleData, copyToClipboard } from "../utils/titleLibrary"
import { ALL_CATEGORY_ID, FREQUENCY_THRESHOLDS } from "../constants/titleLibrary"
export const useTitleLibrary = () => {
const queryClient = useQueryClient()
/* 分类 */
const [activeCatId, setActiveCatId] = useState<string>(ALL_CATEGORY_ID)
/* 数据获取 */
const { data: apiTitles = [] } = useQuery({
queryKey: ["titles"],
queryFn: getTitles,
staleTime: 30_000,
})
const titles: TitleData[] = useMemo(() => apiTitles.map(toTitleData), [apiTitles])
/* 动态派生分类 */
const categories: CategoryItem[] = useMemo(() => {
const cats = new Map<string, number>()
apiTitles.forEach((t) => {
const cat = t.category || "未分类"
cats.set(cat, (cats.get(cat) || 0) + 1)
})
return [
{ id: ALL_CATEGORY_ID, name: "全部标题", count: apiTitles.length },
...Array.from(cats.entries()).map(([name, count]) => ({
id: `cat-${name}`,
name,
count,
})),
]
}, [apiTitles])
/* CRUD mutations */
const createMutation = useMutation({
mutationFn: (content: string) => createTitle({ content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] })
},
onError: () => message.error("创建标题失败"),
})
const updateMutation = useMutation({
mutationFn: ({ id, content }: { id: string; content: string }) => updateTitle(id, { content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] })
},
onError: () => message.error("更新标题失败"),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteTitle(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["titles"] })
},
onError: () => message.error("删除标题失败"),
})
/* 筛选状态 */
const [searchText, setSearchText] = useState("")
const [filterType, setFilterType] = useState<string>("all")
const [filterIndustry, setFilterIndustry] = useState<string>("all")
const [filterFrequency, setFilterFrequency] = useState<Frequency>("all")
/* 派生:筛选后的标题列表 */
const activeCategory = categories.find((c) => c.id === activeCatId)
const filteredTitles = useMemo(() => {
let list = titles
/* 按分类过滤 */
if (activeCatId !== ALL_CATEGORY_ID) {
const catName = activeCategory?.name || ""
if (catName) {
list = list.filter((t) => t.category === catName)
}
}
/* 按类型筛选 */
if (filterType !== "all") {
list = list.filter((t) => t.type === filterType)
}
/* 按行业筛选 */
if (filterIndustry !== "all") {
list = list.filter((t) => t.industry === filterIndustry)
}
/* 按使用频率筛选 */
if (filterFrequency !== "all") {
switch (filterFrequency) {
case "high":
list = list.filter((t) => t.usageCount >= FREQUENCY_THRESHOLDS.high)
break
case "medium":
list = list.filter(
(t) =>
t.usageCount >= FREQUENCY_THRESHOLDS.medium &&
t.usageCount < FREQUENCY_THRESHOLDS.high,
)
break
case "low":
list = list.filter((t) => t.usageCount < FREQUENCY_THRESHOLDS.medium)
break
}
}
/* 搜索 */
if (searchText.trim()) {
const q = searchText.trim().toLowerCase()
list = list.filter((t) => t.content.toLowerCase().includes(q))
}
return list
}, [titles, activeCatId, activeCategory, filterType, filterIndustry, filterFrequency, searchText])
/* 操作:收藏 */
const handleToggleFavorite = useCallback((_id: string) => {
message.info("收藏功能即将上线")
}, [])
/* 操作:复制 */
const handleCopy = useCallback(async (title: TitleData) => {
const ok = await copyToClipboard(title.content)
if (ok) {
message.success("已复制到剪贴板")
} else {
message.error("复制失败")
}
}, [])
/* 操作:删除 */
const handleDelete = useCallback(
(id: string) => {
deleteMutation.mutate(id)
message.success("标题已删除")
},
[deleteMutation],
)
return {
/* 状态 */
titles,
categories,
activeCatId,
activeCategory,
filteredTitles,
searchText,
filterType,
filterIndustry,
filterFrequency,
/* mutations */
createMutation,
updateMutation,
deleteMutation,
/* setters */
setActiveCatId,
setSearchText,
setFilterType,
setFilterIndustry,
setFilterFrequency,
/* handlers */
handleToggleFavorite,
handleCopy,
handleDelete,
}
}
@@ -1,20 +0,0 @@
export type TitleType = "hot" | "normal" | "creative"
export type Industry = "general" | "food" | "tech" | "beauty" | "education" | "travel"
export type Frequency = "all" | "high" | "medium" | "low"
export interface TitleData {
id: string
content: string
type: TitleType
industry: Industry
category: string
usageCount: number
isFavorited: boolean
createdAt: string
}
export interface CategoryItem {
id: string
name: string
count: number
}
@@ -1,49 +0,0 @@
import type { TitleData, TitleType } from "../types/titleLibrary"
import type { TitleItem } from "@/api/titles"
export const typeLabel = (type: TitleType): string => {
switch (type) {
case "hot":
return "爆款"
case "normal":
return "常规"
case "creative":
return "创意"
}
}
/** 后端 TitleItem → 前端 TitleData 映射 */
export const toTitleData = (item: TitleItem): TitleData => ({
id: item.id,
content: item.content,
type: (item.category as TitleType) || "normal",
industry: "general",
category: item.category || "未分类",
usageCount: 0,
isFavorited: false,
createdAt: item.created_at?.slice(0, 10) || "",
})
/** 复制文本到剪贴板 */
export const copyToClipboard = async (text: string): Promise<boolean> => {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
/* 降级方案 */
const textarea = document.createElement("textarea")
textarea.value = text
textarea.style.position = "fixed"
textarea.style.opacity = "0"
document.body.appendChild(textarea)
textarea.select()
try {
document.execCommand("copy")
return true
} catch {
return false
} finally {
document.body.removeChild(textarea)
}
}
}
@@ -0,0 +1,107 @@
import { useMemo } from "react"
import type { TagItem } from "@/api/tags"
import { useVoiceTags } from "./useVoiceTags"
import { useVoiceMaterialFilterState } from "./useVoiceMaterialFilterState"
import { useVoiceMaterialData } from "./useVoiceMaterialData"
import { useVoiceMaterialActions } from "./useVoiceMaterialActions"
import { type VoiceMaterial } from "../../types"
/**
* 配音素材数据 Hook
* 封装素材列表查询、筛选状态管理、增删改等数据操作逻辑
*/
export function useVoiceMaterials() {
// 标签管理
const { tags, tagMap, handleCreateTag } = useVoiceTags()
// 筛选视图状态
const filterState = useVoiceMaterialFilterState()
const { searchText, filterGender, filterTagId } = filterState
// 后端查询参数
const keyword = searchText.trim() || undefined
const gender = filterGender !== "all" ? filterGender : undefined
const tagIds = filterTagId !== "all" ? [filterTagId] : undefined
// 数据查询
const { libraries, voiceLibrary, materials, isLoading, createLibMutation } = useVoiceMaterialData(
{ keyword, gender, tagIds },
)
// 操作层
const actions = useVoiceMaterialActions({
voiceLibrary,
materials,
createLibMutation,
})
// 前端二次筛选(与后端筛选同时存在,保证即时响应)
const filtered = useMemo(() => {
let list: VoiceMaterial[] = materials
if (filterGender !== "all") {
list = list.filter((m) => m.gender === filterGender)
}
if (filterTagId !== "all") {
list = list.filter((m) => m.tagIds.includes(filterTagId))
}
if (searchText.trim()) {
const q = searchText.trim().toLowerCase()
list = list.filter(
(m) =>
m.name.toLowerCase().includes(q) ||
m.description.toLowerCase().includes(q) ||
m.tagIds.some((id) =>
(tagMap as Map<string, TagItem>).get(id)?.name?.toLowerCase().includes(q),
),
)
}
return list
}, [materials, filterGender, filterTagId, searchText, tagMap])
// 标签使用计数
const tagCountMap = useMemo(() => {
const map: Record<string, number> = {}
materials.forEach((m) =>
m.tagIds.forEach((id) => {
map[id] = (map[id] || 0) + 1
}),
)
return map
}, [materials])
return {
// 数据
libraries,
voiceLibrary,
tags,
tagMap,
materials,
filtered,
tagCountMap,
isLoading,
// 视图 & 筛选状态
viewMode: filterState.viewMode,
searchText: filterState.searchText,
filterGender: filterState.filterGender,
filterTagId: filterState.filterTagId,
// 上传 & 编辑状态
uploadProgress: actions.uploadProgress,
isUploading: actions.isUploading,
isEditing: actions.isEditing,
// 弹窗状态
uploadOpen: actions.uploadOpen,
editingMaterial: actions.editingMaterial,
// 视图控制
setViewMode: filterState.setViewMode,
setSearchText: filterState.setSearchText,
setFilterGender: filterState.setFilterGender,
setFilterTagId: filterState.setFilterTagId,
setUploadOpen: actions.setUploadOpen,
setEditingMaterial: actions.setEditingMaterial,
// 操作
handleCreateTag,
handleUpload: actions.handleUpload,
handleEdit: actions.handleEdit,
handleDelete: actions.handleDelete,
}
}
@@ -1,117 +1,35 @@
import { useState, useMemo, useCallback, useEffect } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useState, useCallback } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import {
getAssetsByKind,
createAsset,
updateAsset,
deleteAsset,
uploadAssetDirect,
getAssetLibraries,
createAssetLibrary,
type AssetLibraryItem,
} from "@/api/assets"
import { type TagItem, getTags, createTag, tagAsset, untagAsset } from "@/api/tags"
import {
type VoiceGender,
type ViewMode,
type VoiceMaterial,
mapAssetToMaterial,
buildMetadata,
} from "../types"
import { getAudioDuration } from "../utils/audio"
import { tagAsset, untagAsset } from "@/api/tags"
import { type VoiceGender, type VoiceMaterial, buildMetadata } from "../../types"
import { getAudioDuration } from "../../utils/audio"
interface UseVoiceMaterialActionsOptions {
voiceLibrary?: { id: string; kind: string }
materials: VoiceMaterial[]
createLibMutation: { mutateAsync: () => Promise<AssetLibraryItem>; isPending: boolean }
}
/**
* 配音素材数据 Hook
* 封装素材列表查询、筛选状态管理、增删改等数据操作逻辑
* 配音素材操作 Hook
* 封装上传、编辑、删除等变更操作及相关 UI 状态
*/
export function useVoiceMaterials() {
export function useVoiceMaterialActions({
voiceLibrary,
materials,
createLibMutation,
}: UseVoiceMaterialActionsOptions) {
const queryClient = useQueryClient()
// ── 获取 voice 类型素材库(用于上传) ──────────────────────
const { data: libraries = [] } = useQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
staleTime: 60_000,
})
const voiceLibrary = useMemo(() => libraries.find((lib) => lib.kind === "voice"), [libraries])
// 自动创建 voice 素材库(如果不存在)
const createLibMutation = useMutation({
mutationFn: () => createAssetLibrary({ name: "配音库", kind: "voice" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
},
})
useEffect(() => {
if (libraries.length > 0 && !voiceLibrary && !createLibMutation.isPending) {
createLibMutation.mutate()
}
}, [libraries, voiceLibrary, createLibMutation])
// ── 获取标签列表 ───────────────────────────────────────────
const { data: tags = [] } = useQuery({
queryKey: ["tags"],
queryFn: getTags,
staleTime: 60_000,
})
/** 标签 ID → TagItem 映射(用于卡片/行渲染) */
const tagMap = useMemo(() => {
const m = new Map<string, TagItem>()
tags.forEach((t) => m.set(t.id, t))
return m
}, [tags])
/** 创建标签 mutation(供 TagSelector 调用) */
const createTagMutation = useMutation({
mutationFn: (name: string) => createTag(name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tags"] })
},
})
/** 创建标签并返回 TagItem(供 TagSelector 使用) */
const handleCreateTag = useCallback(
async (name: string): Promise<TagItem> => {
return createTagMutation.mutateAsync(name)
},
[createTagMutation],
)
// ── 视图 & 筛选状态 ────────────────────────────────────────
const [viewMode, setViewMode] = useState<ViewMode>("card")
const [searchText, setSearchText] = useState("")
const [filterGender, setFilterGender] = useState<string>("all")
const [filterTagId, setFilterTagId] = useState<string>("all")
// ── 获取配音素材列表(筛选参数透传后端) ─────────────────
const filterKeyword = searchText.trim() || undefined
const filterGenderParam = filterGender !== "all" ? filterGender : undefined
const filterTagIdsParam = filterTagId !== "all" ? [filterTagId] : undefined
const { data: assets = [], isLoading } = useQuery({
queryKey: [
"assets",
"voice",
{
keyword: filterKeyword,
gender: filterGenderParam,
tag_ids: filterTagIdsParam,
},
],
queryFn: () =>
getAssetsByKind("voice", {
keyword: filterKeyword,
gender: filterGenderParam,
tag_ids: filterTagIdsParam,
}),
staleTime: 30_000,
})
const materials: VoiceMaterial[] = useMemo(() => assets.map(mapAssetToMaterial), [assets])
// ── 弹窗状态 ──────────────────────────────────────────────
const [uploadOpen, setUploadOpen] = useState(false)
const [editingMaterial, setEditingMaterial] = useState<VoiceMaterial | null>(null)
@@ -140,7 +58,7 @@ export function useVoiceMaterials() {
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
})
lib = libs.find((l) => l.kind === "voice")
lib = libs.find((l: AssetLibraryItem) => l.kind === "voice")
if (!lib) throw new Error("无法创建配音库")
}
@@ -231,40 +149,6 @@ export function useVoiceMaterials() {
},
})
/* ── 前端二次筛选(与后端筛选同时存在) ──────────────────── */
const filtered = useMemo(() => {
let list = materials
if (filterGender !== "all") {
list = list.filter((m) => m.gender === filterGender)
}
if (filterTagId !== "all") {
list = list.filter((m) => m.tagIds.includes(filterTagId))
}
if (searchText.trim()) {
const q = searchText.trim().toLowerCase()
list = list.filter(
(m) =>
m.name.toLowerCase().includes(q) ||
m.description.toLowerCase().includes(q) ||
m.tagIds.some((id) => tagMap.get(id)?.name?.toLowerCase().includes(q)),
)
}
return list
}, [materials, filterGender, filterTagId, searchText, tagMap])
/* ── 标签使用计数(药丸条展示,按 tag ID 统计) ──────────── */
const tagCountMap = useMemo(() => {
const map: Record<string, number> = {}
materials.forEach((m) =>
m.tagIds.forEach((id) => {
map[id] = (map[id] || 0) + 1
}),
)
return map
}, [materials])
/* ── 数据操作 handlers ──────────────────────────────────── */
const handleUpload = useCallback(
@@ -314,20 +198,6 @@ export function useVoiceMaterials() {
)
return {
// 数据
libraries,
voiceLibrary,
tags,
tagMap,
materials,
filtered,
tagCountMap,
isLoading,
// 视图 & 筛选状态
viewMode,
searchText,
filterGender,
filterTagId,
// 上传 & 编辑状态
uploadProgress,
isUploading: uploadMutation.isPending,
@@ -336,16 +206,11 @@ export function useVoiceMaterials() {
uploadOpen,
editingMaterial,
// 视图控制
setViewMode,
setSearchText,
setFilterGender,
setFilterTagId,
setUploadOpen,
setEditingMaterial,
// 操作
handleCreateTag,
handleUpload,
handleEdit,
handleDelete,
}
}
}
@@ -0,0 +1,58 @@
import { useMemo, useEffect } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { getAssetsByKind, getAssetLibraries, createAssetLibrary } from "@/api/assets"
import { type VoiceMaterial, mapAssetToMaterial } from "../../types"
interface UseVoiceMaterialDataOptions {
keyword?: string
gender?: string
tagIds?: string[]
}
/**
* 配音素材数据查询 Hook
* 封装素材库获取、自动创建 voice 库、素材列表查询
*/
export function useVoiceMaterialData({ keyword, gender, tagIds }: UseVoiceMaterialDataOptions) {
const queryClient = useQueryClient()
// ── 获取 voice 类型素材库 ─────────────────────────────────
const { data: libraries = [] } = useQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
staleTime: 60_000,
})
const voiceLibrary = useMemo(() => libraries.find((lib) => lib.kind === "voice"), [libraries])
// 自动创建 voice 素材库(如果不存在)
const createLibMutation = useMutation({
mutationFn: () => createAssetLibrary({ name: "配音库", kind: "voice" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
},
})
useEffect(() => {
if (libraries.length > 0 && !voiceLibrary && !createLibMutation.isPending) {
createLibMutation.mutate()
}
}, [libraries, voiceLibrary, createLibMutation])
// ── 获取配音素材列表 ─────────────────────────────────────
const { data: assets = [], isLoading } = useQuery({
queryKey: ["assets", "voice", { keyword, gender, tag_ids: tagIds }],
queryFn: () => getAssetsByKind("voice", { keyword, gender, tag_ids: tagIds }),
staleTime: 30_000,
})
const materials: VoiceMaterial[] = useMemo(() => assets.map(mapAssetToMaterial), [assets])
return {
libraries,
voiceLibrary,
materials,
isLoading,
createLibMutation,
}
}
@@ -0,0 +1,24 @@
import { useState } from "react"
import { type ViewMode } from "../../types"
/**
* 配音素材筛选视图状态 Hook
* 管理视图模式、搜索、性别/标签筛选的状态
*/
export function useVoiceMaterialFilterState() {
const [viewMode, setViewMode] = useState<ViewMode>("card")
const [searchText, setSearchText] = useState("")
const [filterGender, setFilterGender] = useState<string>("all")
const [filterTagId, setFilterTagId] = useState<string>("all")
return {
viewMode,
searchText,
filterGender,
filterTagId,
setViewMode,
setSearchText,
setFilterGender,
setFilterTagId,
}
}
@@ -0,0 +1,44 @@
import { useMemo, useCallback } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { type TagItem, getTags, createTag } from "@/api/tags"
/**
* 配音素材标签管理 Hook
* 封装标签列表查询、标签映射、创建标签等逻辑
*/
export function useVoiceTags() {
const queryClient = useQueryClient()
const { data: tags = [] } = useQuery({
queryKey: ["tags"],
queryFn: getTags,
staleTime: 60_000,
})
/** 标签 ID → TagItem 映射(用于卡片/行渲染) */
const tagMap = useMemo(() => {
const m = new Map<string, TagItem>()
tags.forEach((t) => m.set(t.id, t))
return m
}, [tags])
const createTagMutation = useMutation({
mutationFn: (name: string) => createTag(name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tags"] })
},
})
const handleCreateTag = useCallback(
async (name: string): Promise<TagItem> => {
return createTagMutation.mutateAsync(name)
},
[createTagMutation],
)
return {
tags,
tagMap,
handleCreateTag,
}
}
@@ -65,18 +65,6 @@ vi.mock("@/store/authStore", () => ({
vi.mock("@/pages/titles/titles.css", () => ({}))
import TitleLibrary from "@/pages/titles/TitleLibrary"
import "@/pages/titles/types/titleLibrary"
import "@/pages/titles/constants/titleLibrary"
import "@/pages/titles/utils/titleLibrary"
import "@/pages/titles/hooks/useTitleLibrary"
import "@/pages/titles/hooks/useTitleEdit"
import "@/pages/titles/hooks/useTitleAI"
import "@/pages/titles/components/title-library/TitleCard"
import "@/pages/titles/components/title-library/CategorySidebar"
import "@/pages/titles/components/title-library/FilterBar"
import "@/pages/titles/components/title-library/TitleGrid"
import "@/pages/titles/components/title-library/CreateTitleModal"
import "@/pages/titles/components/title-library/AIGenerateModal"
describe("TitleLibrary Page", () => {
it("should render without crashing", () => {