Compare commits

..

8 Commits

Author SHA1 Message Date
xiaoxia 807606d5f0 style: prettier format FilterBar.tsx
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 / Validate - Type Check (mypy) (pull_request) Failing after 0s
CI/CD Pipeline / Frontend Lint (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 1s
PR Automation / Auto Approve on CI Green (pull_request) Failing after 1s
CI/CD Pipeline / Integration Tests (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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m23s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m5s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m54s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 8m29s
CI/CD Pipeline / CI Gate (pull_request) Failing after 0s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Failing after 1s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 36s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1218h23m50s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1218h24m12s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1218h24m12s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1218h24m14s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1218h24m35s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1218h25m49s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1218h27m25s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1218h27m25s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1218h55m10s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1218h55m12s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1218h55m14s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1219h0m14s
2026-07-27 19:01:37 +08:00
xiaoxia 71ac73a889 fix: useTitleAI mutation return type void -> TitleItem
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 18:35:18 +08:00
xiaoxia dd669f6ad9 fix: useTitleEdit mutation return type void -> TitleItem
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 18:34:59 +08:00
xiaoxia c34b3b2c5c fix: remove unused imports
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 37s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 36s
2026-07-27 18:15:18 +08:00
xiaoxia 3283c3e01c Merge branch 'develop' into refactor/title-library
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 39s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 36s
2026-07-27 18:07:51 +08:00
xiaoxia b99a760bb1 Merge branch 'develop' into refactor/title-library
CI/CD Pipeline / CI Gate (pull_request) CI运行中 [frontend-only]
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 13s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m14s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m12s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m23s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 14s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m11s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m9s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m22s
AI Code Review / AI Code Review (pull_request) Successful in 1m14s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 48s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 23s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 36s
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (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 / Build Staging Worker Image (pull_request) Failing after 1222h6m41s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1222h6m43s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1222h6m45s
2026-07-27 15:30:49 +08:00
xiaoxia 0b998421d8 Merge branch 'develop' into refactor/title-library
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 23s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 44s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m23s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m33s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 44s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m52s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m7s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 38s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 53s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m52s
AI Code Review / AI Code Review (pull_request) Successful in 4m0s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 7m33s
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (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 / Build Staging Worker Image (pull_request) Failing after 1225h58m23s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1225h58m27s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1226h31m10s
2026-07-27 12:34:39 +08:00
xiaoxia 018cf59018 refactor(title-library): 三阶段重构 TitleLibrary(736→137行, -81%)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 37s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 56s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m17s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m26s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 44s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 3m35s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m28s
AI Code Review / AI Code Review (pull_request) Successful in 2m34s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 48s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m3s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 3m10s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 8m18s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 15s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1243h44m23s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1243h47m4s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1243h47m8s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1243h47m10s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1243h55m54s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1243h55m55s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1243h55m57s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1243h55m58s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1243h55m59s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1244h35m12s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1244h35m16s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1244h35m14s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1244h19m48s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1244h28m37s
Phase 1 - 抽类型/常量/工具函数:
- types/titleLibrary.ts: TitleType, Industry, Frequency, TitleData, CategoryItem
- constants/titleLibrary.ts: 筛选选项、频率阈值、常量
- utils/titleLibrary.ts: typeLabel, toTitleData, copyToClipboard

Phase 2 - 抽 6 个 UI 组件:
- TitleCard: 标题卡片(编辑/收藏/复制/删除)
- CategorySidebar: 左侧分类列表
- FilterBar: 顶部筛选栏(搜索+类型+行业+频率)
- TitleGrid: 标题网格 + 空状态
- CreateTitleModal: 新建标题弹窗
- AIGenerateModal: AI 生成标题弹窗

Phase 3 - 抽 3 个业务 Hook:
- useTitleLibrary: 数据获取、分类派生、CRUD、筛选逻辑
- useTitleEdit: 编辑状态管理 + 新建标题逻辑
- useTitleAI: AI 生成标题逻辑(mock)

主文件 736→137 行,共新增 12 个文件
2026-07-26 18:23:51 +08:00
19 changed files with 1408 additions and 1133 deletions
@@ -8,13 +8,21 @@
* - 拖动时实时显示裁剪预览(入点/出点/时长)
* - 右键片段弹出菜单:分割 / 恢复原始长度 / 删除
*/
import React, { useState, useRef, useCallback, useEffect } from "react"
import React, { useState, useRef, useCallback, useEffect, useLayoutEffect, useMemo } from "react"
import type { ClipData, ClipType, TrimConfig } from "../types"
import { DEFAULT_PIXELS_PER_SECOND } from "../constants/timeline"
import {
DEFAULT_PIXELS_PER_SECOND,
MIN_PIXELS_PER_SECOND,
MAX_PIXELS_PER_SECOND,
ZOOM_STEP,
MIN_TRIM_DURATION,
DEFAULT_ADD_DURATION,
MIN_ADD_DURATION,
MAX_ADD_DURATION,
TRACK_GAP,
ADD_PICKER_WIDTH,
} from "../constants/timeline"
import { formatTime } from "../utils/timeline"
import { useClipDrag } from "../hooks/useClipDrag"
import { useTrimDrag } from "../hooks/useTrimDrag"
import { useTimelineMenus } from "../hooks/useTimelineMenus"
import { ClipCard } from "./timeline/ClipCard"
import { TimeRuler } from "./timeline/TimeRuler"
import { AddClipPicker } from "./timeline/AddClipPicker"
@@ -47,6 +55,25 @@ interface TimelinePanelProps {
totalDuration?: number
}
/** 裁剪拖拽方向 */
type TrimDirection = "left" | "right"
/** 裁剪拖拽状态 */
interface TrimDragState {
clipId: string
direction: TrimDirection
startX: number
originalTrim: TrimConfig
originalDuration: number
}
/** 右键菜单状态 */
interface ContextMenuState {
x: number
y: number
clipId: string
}
const TimelinePanel: React.FC<TimelinePanelProps> = ({
clips,
selectedClipId,
@@ -64,51 +91,143 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
onSeek,
totalDuration: totalDurationProp,
}) => {
/* ── 缩放 & 时长 ── */
const pps = pixelsPerSecond ?? DEFAULT_PIXELS_PER_SECOND
const totalDuration = totalDurationProp ?? clips.reduce((s, c) => s + c.duration, 0)
const [dragIdx, setDragIdx] = useState<number | null>(null)
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
const dragRef = useRef<number | null>(null)
const [showAddPicker, setShowAddPicker] = useState(false)
const pickerRef = useRef<HTMLDivElement>(null)
const addCardRef = useRef<HTMLDivElement>(null)
const [pickerPos, setPickerPos] = useState<{ top: number; right: number }>({
top: 0,
right: 0,
})
/* ── 裁剪拖拽 ── */
const { trimDrag, trimPreview, handleTrimHandleMouseDown } = useTrimDrag(clips, pps, onClipTrim)
/* ── 裁剪拖拽状态 ── */
const [trimDrag, setTrimDrag] = useState<TrimDragState | null>(null)
const [trimPreview, setTrimPreview] = useState<{
clipId: string
startTime: number
endTime: number
duration: number
x: number
y: number
} | null>(null)
/* ── 片段拖拽排序 ── */
const {
dragIdx,
dragOverIdx,
handleDragStart,
handleDragOver,
handleDragEnd,
handleDrop,
handleEmptyDragOver,
} = useClipDrag(onClipReorder, !!trimDrag)
/* ── 右键菜单 ── */
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
const contextMenuRef = useRef<HTMLDivElement>(null)
/* ── 菜单 & 面板 ── */
const {
contextMenu,
contextMenuRef,
handleContextMenu,
handleContextSplit,
handleContextResetTrim,
handleContextDelete,
showAddPicker,
pickerRef,
addCardRef,
pickerPos,
availableTypes,
addType,
addDuration,
setAddType,
setAddDuration,
handleTogglePicker,
handleConfirmAdd,
hoveredClipId,
setHoveredClipId,
} = useTimelineMenus(clips, currentMode, onAddClip, onClipSplit, onClipResetTrim, onClipRemove)
/* ── 悬停的片段 ID(显示裁剪手柄) ── */
const [hoveredClipId, setHoveredClipId] = useState<string | null>(null)
/* ── 播放头拖拽状态 ── */
const [playheadDragging, setPlayheadDragging] = useState(false)
const trackRef = useRef<HTMLDivElement>(null)
/* ── 根据模式决定可选类型 ── */
const availableTypes: ClipType[] = useMemo(
() =>
currentMode === "voice_over" ? ["voice"] : currentMode === "pip" ? ["pip"] : ["voice", "pip"], // voice_pip / one_take / 默认
[currentMode],
)
/* ── 默认添加类型:跟随模式 ── */
const defaultAddType: ClipType = useMemo(() => {
if (currentMode === "voice_over") return "voice"
if (currentMode === "pip") return "pip"
return "voice"
}, [currentMode])
/* ── "+" 卡片:类型+时长选择状态 ── */
const [addType, setAddType] = useState<ClipType>(defaultAddType)
const [addDuration, setAddDuration] = useState<number>(DEFAULT_ADD_DURATION)
/* ── 模式切换时自动同步默认添加类型 ── */
useEffect(() => {
if (!availableTypes.includes(addType)) {
setAddType(defaultAddType)
}
}, [currentMode, addType, availableTypes, defaultAddType])
/* ── 计算 picker 初始位置 ── */
const updatePickerPosition = useCallback(() => {
if (!addCardRef.current) return
const rect = addCardRef.current.getBoundingClientRect()
const vw = window.innerWidth
const roughHeight = 180
let top = rect.top - TRACK_GAP - roughHeight
if (top < 8) top = 8
let right = vw - rect.right
if (rect.right - ADD_PICKER_WIDTH < 8) {
right = vw - ADD_PICKER_WIDTH - 8
}
setPickerPos({ top, right })
}, [])
const handleTogglePicker = () => {
if (!showAddPicker) {
const defaultType =
currentMode === "pip" ? "pip" : currentMode === "voice_over" ? "voice" : "voice"
setAddType(defaultType)
updatePickerPosition()
}
setShowAddPicker((v) => !v)
}
/* ── 渲染后精确边界校正 ── */
useLayoutEffect(() => {
if (!showAddPicker || !pickerRef.current || !addCardRef.current) return
const pickerEl = pickerRef.current
const addRect = addCardRef.current.getBoundingClientRect()
const pickerH = pickerEl.offsetHeight
const vh = window.innerHeight
const vw = window.innerWidth
let top = addRect.top - TRACK_GAP - pickerH
if (top < 8) {
top = addRect.bottom + TRACK_GAP
if (top + pickerH > vh - 8) {
top = vh - 8 - pickerH
if (top < 8) top = 8
}
}
let right = vw - addRect.right
const pickerRect = pickerEl.getBoundingClientRect()
if (pickerRect.left < 8) {
right = vw - ADD_PICKER_WIDTH - 8
}
setPickerPos({ top, right })
}, [showAddPicker])
/* ── 点击外部关闭添加面板 ── */
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
setShowAddPicker(false)
}
}
if (showAddPicker) {
document.addEventListener("mousedown", handleClickOutside)
}
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [showAddPicker])
/* ── 点击外部关闭右键菜单 ── */
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
setContextMenu(null)
}
}
if (contextMenu) {
document.addEventListener("mousedown", handleClickOutside)
}
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [contextMenu])
/* ── 缩放 & 时长 ── */
const pps = pixelsPerSecond ?? DEFAULT_PIXELS_PER_SECOND
const totalDuration = totalDurationProp ?? clips.reduce((s, c) => s + c.duration, 0)
/* ── 播放头拖拽全局 mousemove/mouseup ── */
useEffect(() => {
if (!playheadDragging) return
@@ -152,6 +271,165 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
setPlayheadDragging(true)
}, [])
/* ── 确认添加片段 ── */
const handleConfirmAdd = () => {
onAddClip(addType, addDuration)
setShowAddPicker(false)
}
/* ── 片段拖拽排序 ── */
const handleDragStart = (e: React.DragEvent, idx: number) => {
// 如果正在裁剪拖拽,不允许排序拖拽
if (trimDrag) return
dragRef.current = idx
setDragIdx(idx)
e.dataTransfer.setData("application/x-clip-drag", String(idx))
e.dataTransfer.effectAllowed = "move"
}
const handleDragOver = (e: React.DragEvent, idx: number) => {
e.preventDefault()
e.dataTransfer.dropEffect = "move"
setDragOverIdx(idx)
}
const handleDragEnd = () => {
dragRef.current = null
setDragIdx(null)
setDragOverIdx(null)
}
const handleDrop = (e: React.DragEvent, toIdx: number) => {
e.preventDefault()
setDragOverIdx(null)
const fromStr = e.dataTransfer.getData("application/x-clip-drag")
if (fromStr !== "") {
const fromIdx = Number(fromStr)
if (fromIdx !== toIdx) {
onClipReorder(fromIdx, toIdx)
}
}
}
/* ── 空轨道区域不接受素材拖入 ── */
const handleEmptyDragOver = (e: React.DragEvent) => {
e.preventDefault()
}
/* ── 裁剪手柄拖拽 ── */
const handleTrimHandleMouseDown = useCallback(
(e: React.MouseEvent, clipId: string, direction: TrimDirection) => {
e.preventDefault()
e.stopPropagation()
const clip = clips.find((c) => c.id === clipId)
if (!clip) return
const trim: TrimConfig = clip.trim_config ?? {
start_time: 0,
end_time: clip.duration,
original_duration: clip.duration,
}
setTrimDrag({
clipId,
direction,
startX: e.clientX,
originalTrim: { ...trim },
originalDuration: clip.duration,
})
},
[clips],
)
/* ── 裁剪拖拽全局 mousemove/mouseup ── */
useEffect(() => {
if (!trimDrag) return
const handleMouseMove = (e: MouseEvent) => {
const dx = e.clientX - trimDrag.startX
const dtSec = dx / pps
const clip = clips.find((c) => c.id === trimDrag.clipId)
if (!clip) return
const origTrim = trimDrag.originalTrim
const origDur = origTrim.original_duration ?? trimDrag.originalDuration
let newStart = origTrim.start_time
let newEnd = origTrim.end_time
if (trimDrag.direction === "left") {
// 左手柄:调整入点
newStart = Math.max(0, Math.min(origTrim.start_time + dtSec, newEnd - MIN_TRIM_DURATION))
} else {
// 右手柄:调整出点
newEnd = Math.max(
origTrim.start_time + MIN_TRIM_DURATION,
Math.min(origTrim.end_time + dtSec, origDur),
)
}
const newDuration = Math.round((newEnd - newStart) * 10) / 10
setTrimPreview({
clipId: trimDrag.clipId,
startTime: Math.round(newStart * 10) / 10,
endTime: Math.round(newEnd * 10) / 10,
duration: newDuration,
x: e.clientX,
y: e.clientY,
})
}
const handleMouseUp = () => {
if (trimPreview && trimPreview.clipId === trimDrag.clipId && onClipTrim) {
const newTrim: TrimConfig = {
start_time: trimPreview.startTime,
end_time: trimPreview.endTime,
original_duration: trimDrag.originalTrim.original_duration ?? trimDrag.originalDuration,
}
onClipTrim(trimDrag.clipId, newTrim, trimPreview.duration)
}
setTrimDrag(null)
setTrimPreview(null)
}
document.addEventListener("mousemove", handleMouseMove)
document.addEventListener("mouseup", handleMouseUp)
return () => {
document.removeEventListener("mousemove", handleMouseMove)
document.removeEventListener("mouseup", handleMouseUp)
}
}, [trimDrag, trimPreview, clips, onClipTrim, pps])
/* ── 右键菜单 ── */
const handleContextMenu = useCallback((e: React.MouseEvent, clipId: string) => {
e.preventDefault()
e.stopPropagation()
setContextMenu({ x: e.clientX, y: e.clientY, clipId })
}, [])
/* ── 右键菜单操作 ── */
const handleContextSplit = useCallback(() => {
if (!contextMenu) return
if (onClipSplit) {
onClipSplit(contextMenu.clipId, 0.5) // 在中间分割
}
setContextMenu(null)
}, [contextMenu, onClipSplit])
const handleContextResetTrim = useCallback(() => {
if (!contextMenu) return
if (onClipResetTrim) {
onClipResetTrim(contextMenu.clipId)
}
setContextMenu(null)
}, [contextMenu, onClipResetTrim])
const handleContextDelete = useCallback(() => {
if (!contextMenu) return
onClipRemove(contextMenu.clipId)
setContextMenu(null)
}, [contextMenu, onClipRemove])
return (
<div className="ep-timeline-area">
{/* 时间线头部 */}
@@ -165,7 +443,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
<div className="ep-timeline-zoom">
<button
className="ep-zoom-btn"
onClick={() => onZoomChange?.(Math.max(10, pps - 10))}
onClick={() => onZoomChange?.(Math.max(MIN_PIXELS_PER_SECOND, pps - ZOOM_STEP))}
title="缩小"
>
−
@@ -173,15 +451,15 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
<input
type="range"
className="ep-zoom-slider"
min={10}
max={120}
min={MIN_PIXELS_PER_SECOND}
max={MAX_PIXELS_PER_SECOND}
step={5}
value={pps}
onChange={(e) => onZoomChange?.(Number(e.target.value))}
/>
<button
className="ep-zoom-btn"
onClick={() => onZoomChange?.(Math.min(120, pps + 10))}
onClick={() => onZoomChange?.(Math.min(MAX_PIXELS_PER_SECOND, pps + ZOOM_STEP))}
title="放大"
>
+
@@ -305,6 +583,8 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
availableTypes={availableTypes}
addType={addType}
addDuration={addDuration}
minDuration={MIN_ADD_DURATION}
maxDuration={MAX_ADD_DURATION}
onTypeChange={setAddType}
onDurationChange={setAddDuration}
onConfirm={handleConfirmAdd}
@@ -1,64 +0,0 @@
import { useState, useCallback } from "react"
/**
* 片段拖拽排序 Hook
* 支持 HTML5 原生拖拽,实时高亮拖拽位置
*/
export const useClipDrag = (
onClipReorder: (fromIdx: number, toIdx: number) => void,
disabled?: boolean,
) => {
const [dragIdx, setDragIdx] = useState<number | null>(null)
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
const handleDragStart = useCallback(
(e: React.DragEvent, idx: number) => {
if (disabled) return
setDragIdx(idx)
e.dataTransfer.setData("application/x-clip-drag", String(idx))
e.dataTransfer.effectAllowed = "move"
},
[disabled],
)
const handleDragOver = useCallback((e: React.DragEvent, idx: number) => {
e.preventDefault()
e.dataTransfer.dropEffect = "move"
setDragOverIdx(idx)
}, [])
const handleDragEnd = useCallback(() => {
setDragIdx(null)
setDragOverIdx(null)
}, [])
const handleDrop = useCallback(
(e: React.DragEvent, toIdx: number) => {
e.preventDefault()
setDragOverIdx(null)
const fromStr = e.dataTransfer.getData("application/x-clip-drag")
if (fromStr !== "") {
const fromIdx = Number(fromStr)
if (fromIdx !== toIdx) {
onClipReorder(fromIdx, toIdx)
}
}
setDragIdx(null)
},
[onClipReorder],
)
const handleEmptyDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
}, [])
return {
dragIdx,
dragOverIdx,
handleDragStart,
handleDragOver,
handleDragEnd,
handleDrop,
handleEmptyDragOver,
}
}
@@ -1,192 +0,0 @@
import { useState, useRef, useCallback, useEffect, useMemo, useLayoutEffect } from "react"
import type { ClipData, ClipType } from "../types"
import { DEFAULT_ADD_DURATION, ADD_PICKER_WIDTH, TRACK_GAP } from "../constants/timeline"
interface ContextMenuState {
x: number
y: number
clipId: string
}
/**
* 时间线菜单 Hook
* 管理右键菜单和添加片段面板的状态与交互
*/
export const useTimelineMenus = (
_clips: ClipData[],
currentMode: string,
onAddClip: (type: ClipType, duration: number) => void,
onClipSplit?: (clipId: string, splitRatio: number) => void,
onClipResetTrim?: (clipId: string) => void,
onClipRemove?: (clipId: string) => void,
) => {
/* ── 右键菜单 ── */
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
const contextMenuRef = useRef<HTMLDivElement>(null)
/* ── 添加片段面板 ── */
const [showAddPicker, setShowAddPicker] = useState(false)
const pickerRef = useRef<HTMLDivElement>(null)
const addCardRef = useRef<HTMLDivElement>(null)
const [pickerPos, setPickerPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 })
/* ── 悬停的片段 ID(显示裁剪手柄) ── */
const [hoveredClipId, setHoveredClipId] = useState<string | null>(null)
/* ── 根据模式决定可选类型 ── */
const availableTypes: ClipType[] = useMemo(
() =>
currentMode === "voice_over" ? ["voice"] : currentMode === "pip" ? ["pip"] : ["voice", "pip"],
[currentMode],
)
/* ── 默认添加类型:跟随模式 ── */
const defaultAddType: ClipType = useMemo(() => {
if (currentMode === "voice_over") return "voice"
if (currentMode === "pip") return "pip"
return "voice"
}, [currentMode])
/* ── "+" 卡片:类型+时长选择状态 ── */
const [addType, setAddType] = useState<ClipType>(defaultAddType)
const [addDuration, setAddDuration] = useState<number>(DEFAULT_ADD_DURATION)
/* ── 模式切换时自动同步默认添加类型 ── */
useEffect(() => {
if (!availableTypes.includes(addType)) {
setAddType(defaultAddType)
}
}, [currentMode, addType, availableTypes, defaultAddType])
/* ── 计算 picker 初始位置 ── */
const updatePickerPosition = useCallback(() => {
if (!addCardRef.current) return
const rect = addCardRef.current.getBoundingClientRect()
const vw = window.innerWidth
const roughHeight = 180
let top = rect.top - TRACK_GAP - roughHeight
if (top < 8) top = 8
let right = vw - rect.right
if (rect.right - ADD_PICKER_WIDTH < 8) {
right = vw - ADD_PICKER_WIDTH - 8
}
setPickerPos({ top, right })
}, [])
const handleTogglePicker = useCallback(() => {
if (!showAddPicker) {
setAddType(defaultAddType)
updatePickerPosition()
}
setShowAddPicker((v) => !v)
}, [showAddPicker, defaultAddType, updatePickerPosition])
/* ── 渲染后精确边界校正 ── */
useLayoutEffect(() => {
if (!showAddPicker || !pickerRef.current || !addCardRef.current) return
const pickerEl = pickerRef.current
const addRect = addCardRef.current.getBoundingClientRect()
const pickerH = pickerEl.offsetHeight
const vh = window.innerHeight
const vw = window.innerWidth
let top = addRect.top - TRACK_GAP - pickerH
if (top < 8) {
top = addRect.bottom + TRACK_GAP
if (top + pickerH > vh - 8) {
top = vh - 8 - pickerH
if (top < 8) top = 8
}
}
let right = vw - addRect.right
const pickerRect = pickerEl.getBoundingClientRect()
if (pickerRect.left < 8) {
right = vw - ADD_PICKER_WIDTH - 8
}
setPickerPos({ top, right })
}, [showAddPicker])
/* ── 点击外部关闭添加面板 ── */
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
setShowAddPicker(false)
}
}
if (showAddPicker) {
document.addEventListener("mousedown", handleClickOutside)
}
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [showAddPicker])
/* ── 点击外部关闭右键菜单 ── */
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
setContextMenu(null)
}
}
if (contextMenu) {
document.addEventListener("mousedown", handleClickOutside)
}
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [contextMenu])
/* ── 确认添加片段 ── */
const handleConfirmAdd = useCallback(() => {
onAddClip(addType, addDuration)
setShowAddPicker(false)
}, [onAddClip, addType, addDuration])
/* ── 右键菜单操作 ── */
const handleContextMenu = useCallback((e: React.MouseEvent, clipId: string) => {
e.preventDefault()
e.stopPropagation()
setContextMenu({ x: e.clientX, y: e.clientY, clipId })
}, [])
const handleContextSplit = useCallback(() => {
if (!contextMenu) return
onClipSplit?.(contextMenu.clipId, 0.5)
setContextMenu(null)
}, [contextMenu, onClipSplit])
const handleContextResetTrim = useCallback(() => {
if (!contextMenu) return
onClipResetTrim?.(contextMenu.clipId)
setContextMenu(null)
}, [contextMenu, onClipResetTrim])
const handleContextDelete = useCallback(() => {
if (!contextMenu) return
onClipRemove?.(contextMenu.clipId)
setContextMenu(null)
}, [contextMenu, onClipRemove])
return {
// 右键菜单
contextMenu,
contextMenuRef,
handleContextMenu,
handleContextSplit,
handleContextResetTrim,
handleContextDelete,
// 添加面板
showAddPicker,
pickerRef,
addCardRef,
pickerPos,
availableTypes,
addType,
addDuration,
setAddType,
setAddDuration,
handleTogglePicker,
handleConfirmAdd,
// 悬停状态
hoveredClipId,
setHoveredClipId,
}
}
@@ -1,121 +0,0 @@
import { useState, useCallback, useEffect } from "react"
import type { ClipData, TrimConfig } from "../types"
interface TrimDragState {
clipId: string
direction: "left" | "right"
startX: number
originalTrim: TrimConfig
originalDuration: number
}
interface TrimPreviewState {
clipId: string
startTime: number
endTime: number
duration: number
x: number
y: number
}
const MIN_TRIM_DURATION = 1
/**
* 裁剪拖拽 Hook
* 拖拽片段两端手柄调整入点/出点,实时显示预览
*/
export const useTrimDrag = (
clips: ClipData[],
pps: number,
onClipTrim?: (clipId: string, trimConfig: TrimConfig, newDuration: number) => void,
) => {
const [trimDrag, setTrimDrag] = useState<TrimDragState | null>(null)
const [trimPreview, setTrimPreview] = useState<TrimPreviewState | null>(null)
const handleTrimHandleMouseDown = useCallback(
(e: React.MouseEvent, clipId: string, direction: "left" | "right") => {
e.preventDefault()
e.stopPropagation()
const clip = clips.find((c) => c.id === clipId)
if (!clip) return
const trim: TrimConfig = clip.trim_config ?? {
start_time: 0,
end_time: clip.duration,
original_duration: clip.duration,
}
setTrimDrag({
clipId,
direction,
startX: e.clientX,
originalTrim: { ...trim },
originalDuration: clip.duration,
})
},
[clips],
)
/* ── 裁剪拖拽全局 mousemove/mouseup ── */
useEffect(() => {
if (!trimDrag) return
const handleMouseMove = (e: MouseEvent) => {
const dx = e.clientX - trimDrag.startX
const dtSec = dx / pps
const clip = clips.find((c) => c.id === trimDrag.clipId)
if (!clip) return
const origTrim = trimDrag.originalTrim
const origDur = origTrim.original_duration ?? trimDrag.originalDuration
let newStart = origTrim.start_time
let newEnd = origTrim.end_time
if (trimDrag.direction === "left") {
newStart = Math.max(0, Math.min(origTrim.start_time + dtSec, newEnd - MIN_TRIM_DURATION))
} else {
newEnd = Math.max(
origTrim.start_time + MIN_TRIM_DURATION,
Math.min(origTrim.end_time + dtSec, origDur),
)
}
const newDuration = Math.round((newEnd - newStart) * 10) / 10
setTrimPreview({
clipId: trimDrag.clipId,
startTime: Math.round(newStart * 10) / 10,
endTime: Math.round(newEnd * 10) / 10,
duration: newDuration,
x: e.clientX,
y: e.clientY,
})
}
const handleMouseUp = () => {
if (trimPreview && trimPreview.clipId === trimDrag.clipId && onClipTrim) {
const newTrim: TrimConfig = {
start_time: trimPreview.startTime,
end_time: trimPreview.endTime,
original_duration: trimDrag.originalTrim.original_duration ?? trimDrag.originalDuration,
}
onClipTrim(trimDrag.clipId, newTrim, trimPreview.duration)
}
setTrimDrag(null)
setTrimPreview(null)
}
document.addEventListener("mousemove", handleMouseMove)
document.addEventListener("mouseup", handleMouseUp)
return () => {
document.removeEventListener("mousemove", handleMouseMove)
document.removeEventListener("mouseup", handleMouseUp)
}
}, [trimDrag, trimPreview, clips, pps, onClipTrim])
return {
trimDrag,
trimPreview,
handleTrimHandleMouseDown,
}
}
+108 -707
View File
@@ -4,731 +4,132 @@
* 支持:标题卡片展示、AI 生成标题、复制/编辑/删除、收藏、分类筛选、搜索
* 对接后端真实 API(GET/POST/PUT/DELETE /titles)
*/
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 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 "./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 queryClient = useQueryClient()
const {
categories,
activeCatId,
filteredTitles,
searchText,
filterType,
filterIndustry,
filterFrequency,
createMutation,
updateMutation,
setActiveCatId,
setSearchText,
setFilterType,
setFilterIndustry,
setFilterFrequency,
handleToggleFavorite,
handleCopy,
handleDelete,
} = useTitleLibrary()
/* 分类数据 — 从真实标题数据动态派生 */
const [activeCatId, setActiveCatId] = useState<string>("cat-all")
const {
editingId,
editText,
setEditText,
createTitleModalOpen,
setCreateTitleModalOpen,
newTitleContent,
setNewTitleContent,
newTitleType,
setNewTitleType,
handleStartEdit,
handleSaveEdit,
handleCancelEdit,
handleCreateTitle,
handleCloseCreateModal,
} = useTitleEdit({ updateMutation, 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("复制失败")
}
}
const {
aiModalOpen,
setAiModalOpen,
aiKeyword,
setAiKeyword,
aiLoading,
aiResults,
handleAIGenerate,
handleAdoptAITitle,
handleCopyAI,
handleCloseAIModal,
} = useTitleAI({ createMutation })
return (
<div className="xx-titles-page">
{/* 两栏布局 */}
<div className="xx-titles-layout">
{/* ─── 左侧:分类列表 ─── */}
<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>
))}
{/* 左侧:分类列表 */}
<CategorySidebar
categories={categories}
activeCatId={activeCatId}
onSelect={setActiveCatId}
/>
{/* TODO: 新建分类功能待后端分类 API 就绪后启用 */}
</div>
{/* ─── 右侧:内容区 ─── */}
{/* 右侧:内容区 */}
<div className="xx-titles-content">
{/* 筛选栏 */}
<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>
<FilterBar
searchText={searchText}
onSearchChange={setSearchText}
filterType={filterType}
onFilterTypeChange={setFilterType}
filterIndustry={filterIndustry}
onFilterIndustryChange={setFilterIndustry}
filterFrequency={filterFrequency}
onFilterFrequencyChange={setFilterFrequency}
onCreateClick={() => setCreateTitleModalOpen(true)}
onAIClick={() => setAiModalOpen(true)}
/>
{/* 标题卡片网格 */}
{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>
)}
<TitleGrid
titles={filteredTitles}
editingId={editingId}
editText={editText}
searchText={searchText}
onEditChange={setEditText}
onStartEdit={handleStartEdit}
onSaveEdit={handleSaveEdit}
onCancelEdit={handleCancelEdit}
onCopy={handleCopy}
onDelete={handleDelete}
onToggleFavorite={handleToggleFavorite}
/>
</div>
</div>
{/* ─── 新建标题弹窗 ─── */}
<AntModal
title="新建标题"
{/* 新建标题弹窗 */}
<CreateTitleModal
open={createTitleModalOpen}
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>
newTitleContent={newTitleContent}
newTitleType={newTitleType}
onContentChange={setNewTitleContent}
onTypeChange={setNewTitleType}
onCancel={handleCloseCreateModal}
onSubmit={handleCreateTitle}
/>
{/* ─── AI 生成标题弹窗 ─── */}
<AntModal
title="AI 生成标题"
{/* AI 生成标题弹窗 */}
<AIGenerateModal
open={aiModalOpen}
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>
aiKeyword={aiKeyword}
aiLoading={aiLoading}
aiResults={aiResults}
onKeywordChange={setAiKeyword}
onGenerate={handleAIGenerate}
onCancel={handleCloseAIModal}
onCopy={handleCopyAI}
onAdopt={handleAdoptAITitle}
/>
</div>
)
}
@@ -0,0 +1,121 @@
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>
)
}
@@ -0,0 +1,44 @@
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>
)
}
@@ -0,0 +1,89 @@
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>
)
}
@@ -0,0 +1,76 @@
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>
)
}
@@ -0,0 +1,121 @@
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>
)
}
@@ -0,0 +1,63 @@
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>
)
}
@@ -0,0 +1,34 @@
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"
@@ -0,0 +1,84 @@
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,
}
}
@@ -0,0 +1,87 @@
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,
}
}
@@ -0,0 +1,174 @@
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,
}
}
@@ -0,0 +1,20 @@
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
}
@@ -0,0 +1,49 @@
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)
}
}
}
@@ -65,6 +65,18 @@ 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", () => {
@@ -94,9 +94,6 @@ import "@/pages/editing-planner/hooks/usePipLayers"
import "@/pages/editing-planner/hooks/useStickerItems"
import "@/pages/editing-planner/hooks/useTtsPanel"
import "@/pages/editing-planner/hooks/useWatermarkConfig"
import "@/pages/editing-planner/hooks/useTimelineMenus"
import "@/pages/editing-planner/hooks/useTrimDrag"
import "@/pages/editing-planner/hooks/useClipDrag"
describe("EditingPlanner module smoke test", () => {
it("should load all editing-planner modules", () => {