Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 807606d5f0 | |||
| 71ac73a889 | |||
| dd669f6ad9 | |||
| c34b3b2c5c | |||
| 3283c3e01c | |||
| a6ebb762e6 | |||
| b99a760bb1 | |||
| 0b998421d8 | |||
| 018cf59018 |
@@ -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", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""视频调速引擎 — 基于 FFmpeg setpts + atempo 的速度调整能力。
|
||||
"""视频调速引擎 — 基于 FFmpeg setpts + atempo 的速度调整能力.
|
||||
|
||||
支持:
|
||||
- 0.25x ~ 4x 变速范围
|
||||
@@ -6,147 +6,57 @@
|
||||
- 音频调速(atempo,多级串联处理超范围值)
|
||||
- 音调修正(pitch_correct,默认开启)
|
||||
- 边界自动钳制,不阻断渲染
|
||||
|
||||
注:核心领域模型已抽离到 packages/domain/speed_config.py,
|
||||
本模块保留薄包装层,确保向后兼容。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
# ─── 常量 ───────────────────────────────────────────────
|
||||
MIN_SPEED = 0.25
|
||||
MAX_SPEED = 4.0
|
||||
DEFAULT_SPEED = 1.0
|
||||
|
||||
# atempo 单级有效范围
|
||||
_ATEMPO_MIN = 0.5
|
||||
_ATEMPO_MAX = 2.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpeedConfig:
|
||||
"""调速配置。
|
||||
|
||||
Attributes:
|
||||
speed: 播放速度,0.25~4.0,1.0 为原速
|
||||
pitch_correct: 是否保持音调(默认 True,用 atempo 时间拉伸算法)
|
||||
"""
|
||||
|
||||
speed: float = DEFAULT_SPEED
|
||||
pitch_correct: bool = True
|
||||
|
||||
@classmethod
|
||||
def parse(cls, data: Optional[dict]) -> "SpeedConfig":
|
||||
"""从 dict 解析配置,无效值回退到默认。"""
|
||||
if not data or not isinstance(data, dict):
|
||||
return cls()
|
||||
|
||||
speed = data.get("speed", DEFAULT_SPEED)
|
||||
if not isinstance(speed, (int, float)):
|
||||
speed = DEFAULT_SPEED
|
||||
|
||||
pitch_correct = data.get("pitch_correct", True)
|
||||
if not isinstance(pitch_correct, bool):
|
||||
pitch_correct = True
|
||||
|
||||
config = cls(speed=float(speed), pitch_correct=pitch_correct)
|
||||
config.clamp()
|
||||
return config
|
||||
|
||||
def clamp(self) -> None:
|
||||
"""将速度钳制到合法范围。"""
|
||||
if self.speed <= 0:
|
||||
self.speed = DEFAULT_SPEED
|
||||
elif self.speed < MIN_SPEED:
|
||||
self.speed = MIN_SPEED
|
||||
elif self.speed > MAX_SPEED:
|
||||
self.speed = MAX_SPEED
|
||||
|
||||
@property
|
||||
def is_original(self) -> bool:
|
||||
"""是否原速(无需调速)。"""
|
||||
return abs(self.speed - 1.0) < 1e-6
|
||||
from packages.domain.speed_config import ( # noqa: F401 — 向后兼容
|
||||
DEFAULT_SPEED,
|
||||
MAX_SPEED,
|
||||
MIN_SPEED,
|
||||
SpeedConfig,
|
||||
_split_atempo_stages,
|
||||
adjust_duration as _adjust_duration_base,
|
||||
build_audio_filter as _build_audio_filter_base,
|
||||
build_video_filter as _build_video_filter_base,
|
||||
resolve_clip_speed as _resolve_clip_speed_base,
|
||||
)
|
||||
|
||||
|
||||
class SpeedEngine:
|
||||
"""调速引擎 — 生成 FFmpeg 调速滤镜链。
|
||||
"""调速引擎 — 生成 FFmpeg 调速滤镜链.
|
||||
|
||||
用法:
|
||||
engine = SpeedEngine()
|
||||
video_filter = engine.build_video_filter(config)
|
||||
audio_filter = engine.build_audio_filter(config)
|
||||
new_duration = engine.adjust_duration(duration, config)
|
||||
薄包装层,实际逻辑委托给 packages.domain.speed_config。
|
||||
"""
|
||||
|
||||
def build_video_filter(self, config: SpeedConfig) -> str:
|
||||
"""生成视频调速滤镜字符串。
|
||||
|
||||
返回 setpts 滤镜表达式,原速时返回空字符串。
|
||||
"""
|
||||
if config.is_original:
|
||||
return ""
|
||||
# setpts=PTS/speed — speed>1 加速,speed<1 减速
|
||||
return f"setpts=PTS/{config.speed:.4f}"
|
||||
"""生成视频调速滤镜字符串."""
|
||||
return _build_video_filter_base(config)
|
||||
|
||||
def build_audio_filter(self, config: SpeedConfig) -> str:
|
||||
"""生成音频调速滤镜字符串。
|
||||
|
||||
atempo 单级范围 0.5~2.0,超出范围时自动多级串联:
|
||||
- 0.25x → atempo=0.5,atempo=0.5
|
||||
- 4x → atempo=2.0,atempo=2.0
|
||||
- 0.3x → atempo=0.5,atempo=0.6
|
||||
- 3x → atempo=2.0,atempo=1.5
|
||||
|
||||
原速时返回空字符串。
|
||||
"""
|
||||
if config.is_original:
|
||||
return ""
|
||||
|
||||
speed = config.speed
|
||||
stages: list[float] = self._split_atempo_stages(speed)
|
||||
return ",".join(f"atempo={s:.4f}" for s in stages)
|
||||
"""生成音频调速滤镜字符串."""
|
||||
return _build_audio_filter_base(config)
|
||||
|
||||
@staticmethod
|
||||
def _split_atempo_stages(speed: float) -> list[float]:
|
||||
"""将速度拆分为多级 atempo 串联,每级都在 [0.5, 2.0] 范围内。"""
|
||||
if _ATEMPO_MIN <= speed <= _ATEMPO_MAX:
|
||||
return [speed]
|
||||
|
||||
stages: list[float] = []
|
||||
remaining = speed
|
||||
|
||||
# 加速场景(speed > 2.0)
|
||||
if speed > _ATEMPO_MAX:
|
||||
while remaining > _ATEMPO_MAX:
|
||||
stages.append(_ATEMPO_MAX)
|
||||
remaining /= _ATEMPO_MAX
|
||||
stages.append(remaining)
|
||||
|
||||
# 减速场景(speed < 0.5)
|
||||
else:
|
||||
while remaining < _ATEMPO_MIN:
|
||||
stages.append(_ATEMPO_MIN)
|
||||
remaining /= _ATEMPO_MIN
|
||||
stages.append(remaining)
|
||||
|
||||
return stages
|
||||
"""将速度拆分为多级 atempo 串联(内部方法,向后兼容)."""
|
||||
return _split_atempo_stages(speed)
|
||||
|
||||
def adjust_duration(self, original_duration: float, config: SpeedConfig) -> float:
|
||||
"""计算调速后的时长。
|
||||
|
||||
加速 → 时长变短;减速 → 时长变长。
|
||||
"""
|
||||
if config.is_original or original_duration <= 0:
|
||||
return original_duration
|
||||
return original_duration / config.speed
|
||||
"""计算调速后的时长."""
|
||||
return _adjust_duration_base(original_duration, config)
|
||||
|
||||
def build_clip_speed_filter(
|
||||
self,
|
||||
speed: float,
|
||||
pitch_correct: bool = True,
|
||||
) -> tuple[str, str, SpeedConfig]:
|
||||
"""便捷方法:从单一 speed 值生成视频+音频滤镜。
|
||||
|
||||
返回 (video_filter, audio_filter, config)。
|
||||
"""
|
||||
"""便捷方法:从单一 speed 值生成视频+音频滤镜."""
|
||||
config = SpeedConfig(speed=speed, pitch_correct=pitch_correct)
|
||||
config.clamp()
|
||||
return (
|
||||
@@ -160,8 +70,5 @@ class SpeedEngine:
|
||||
clip_config: dict,
|
||||
global_speed: float = DEFAULT_SPEED,
|
||||
) -> float:
|
||||
"""从 clip config 中解析 playback_speed,0 或缺失则使用全局速度。"""
|
||||
speed = clip_config.get("playback_speed", 0) if clip_config else 0
|
||||
if not isinstance(speed, (int, float)) or speed <= 0:
|
||||
return global_speed
|
||||
return float(speed)
|
||||
"""从 clip config 中解析 playback_speed,0 或缺失则使用全局速度."""
|
||||
return _resolve_clip_speed_base(clip_config, global_speed)
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"""调速配置领域模型 — 纯逻辑,无FFmpeg依赖.
|
||||
|
||||
抽离自 speed_engine.py,包含:
|
||||
- SpeedConfig 数据类(解析/钳制/原速判断)
|
||||
- 视频/音频调速滤镜构建
|
||||
- atempo 多级拆分算法
|
||||
- 时长计算
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
# ─── 常量 ───────────────────────────────────────────────
|
||||
MIN_SPEED = 0.25
|
||||
MAX_SPEED = 4.0
|
||||
DEFAULT_SPEED = 1.0
|
||||
|
||||
# atempo 单级有效范围
|
||||
_ATEMPO_MIN = 0.5
|
||||
_ATEMPO_MAX = 2.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpeedConfig:
|
||||
"""调速配置.
|
||||
|
||||
Attributes:
|
||||
speed: 播放速度,0.25~4.0,1.0 为原速
|
||||
pitch_correct: 是否保持音调(默认 True,用 atempo 时间拉伸算法)
|
||||
"""
|
||||
|
||||
speed: float = DEFAULT_SPEED
|
||||
pitch_correct: bool = True
|
||||
|
||||
@classmethod
|
||||
def parse(cls, data: dict[str, Any] | None) -> SpeedConfig:
|
||||
"""从 dict 解析配置,无效值回退到默认."""
|
||||
if not data or not isinstance(data, dict):
|
||||
return cls()
|
||||
|
||||
speed = data.get("speed", DEFAULT_SPEED)
|
||||
if not isinstance(speed, (int, float)):
|
||||
speed = DEFAULT_SPEED
|
||||
|
||||
pitch_correct = data.get("pitch_correct", True)
|
||||
if not isinstance(pitch_correct, bool):
|
||||
pitch_correct = True
|
||||
|
||||
config = cls(speed=float(speed), pitch_correct=pitch_correct)
|
||||
config.clamp()
|
||||
return config
|
||||
|
||||
def clamp(self) -> None:
|
||||
"""将速度钳制到合法范围."""
|
||||
if self.speed <= 0:
|
||||
self.speed = DEFAULT_SPEED
|
||||
elif self.speed < MIN_SPEED:
|
||||
self.speed = MIN_SPEED
|
||||
elif self.speed > MAX_SPEED:
|
||||
self.speed = MAX_SPEED
|
||||
|
||||
@property
|
||||
def is_original(self) -> bool:
|
||||
"""是否原速(无需调速)."""
|
||||
return abs(self.speed - 1.0) < 1e-6
|
||||
|
||||
@property
|
||||
def is_fast(self) -> bool:
|
||||
"""是否加速播放."""
|
||||
return self.speed > 1.0
|
||||
|
||||
@property
|
||||
def is_slow(self) -> bool:
|
||||
"""是否减速播放."""
|
||||
return self.speed < 1.0
|
||||
|
||||
|
||||
# ── 滤镜构建 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_video_filter(config: SpeedConfig) -> str:
|
||||
"""生成视频调速滤镜字符串.
|
||||
|
||||
返回 setpts 滤镜表达式,原速时返回空字符串。
|
||||
"""
|
||||
if config.is_original:
|
||||
return ""
|
||||
# setpts=PTS/speed — speed>1 加速,speed<1 减速
|
||||
return f"setpts=PTS/{config.speed:.4f}"
|
||||
|
||||
|
||||
def build_audio_filter(config: SpeedConfig) -> str:
|
||||
"""生成音频调速滤镜字符串.
|
||||
|
||||
atempo 单级范围 0.5~2.0,超出范围时自动多级串联:
|
||||
- 0.25x → atempo=0.5,atempo=0.5
|
||||
- 4x → atempo=2.0,atempo=2.0
|
||||
- 0.3x → atempo=0.5,atempo=0.6
|
||||
- 3x → atempo=2.0,atempo=1.5
|
||||
|
||||
原速时返回空字符串。
|
||||
"""
|
||||
if config.is_original:
|
||||
return ""
|
||||
|
||||
speed = config.speed
|
||||
stages: list[float] = _split_atempo_stages(speed)
|
||||
return ",".join(f"atempo={s:.4f}" for s in stages)
|
||||
|
||||
|
||||
def _split_atempo_stages(speed: float) -> list[float]:
|
||||
"""将速度拆分为多级 atempo 串联,每级都在 [0.5, 2.0] 范围内."""
|
||||
if _ATEMPO_MIN <= speed <= _ATEMPO_MAX:
|
||||
return [speed]
|
||||
|
||||
stages: list[float] = []
|
||||
remaining = speed
|
||||
|
||||
# 加速场景(speed > 2.0)
|
||||
if speed > _ATEMPO_MAX:
|
||||
while remaining > _ATEMPO_MAX:
|
||||
stages.append(_ATEMPO_MAX)
|
||||
remaining /= _ATEMPO_MAX
|
||||
stages.append(remaining)
|
||||
|
||||
# 减速场景(speed < 0.5)
|
||||
else:
|
||||
while remaining < _ATEMPO_MIN:
|
||||
stages.append(_ATEMPO_MIN)
|
||||
remaining /= _ATEMPO_MIN
|
||||
stages.append(remaining)
|
||||
|
||||
return stages
|
||||
|
||||
|
||||
# ── 时长计算 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def adjust_duration(original_duration: float, config: SpeedConfig) -> float:
|
||||
"""计算调速后的时长.
|
||||
|
||||
加速 → 时长变短;减速 → 时长变长。
|
||||
"""
|
||||
if config.is_original or original_duration <= 0:
|
||||
return original_duration
|
||||
return original_duration / config.speed
|
||||
|
||||
|
||||
# ── 便捷方法 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_clip_speed_filter(
|
||||
speed: float,
|
||||
pitch_correct: bool = True,
|
||||
) -> tuple[str, str, SpeedConfig]:
|
||||
"""便捷方法:从单一 speed 值生成视频+音频滤镜.
|
||||
|
||||
返回 (video_filter, audio_filter, config)。
|
||||
"""
|
||||
config = SpeedConfig(speed=speed, pitch_correct=pitch_correct)
|
||||
config.clamp()
|
||||
return (
|
||||
build_video_filter(config),
|
||||
build_audio_filter(config),
|
||||
config,
|
||||
)
|
||||
|
||||
|
||||
def resolve_clip_speed(
|
||||
clip_config: dict[str, Any] | None,
|
||||
global_speed: float = DEFAULT_SPEED,
|
||||
) -> float:
|
||||
"""从 clip config 中解析 playback_speed,0 或缺失则使用全局速度."""
|
||||
speed = clip_config.get("playback_speed", 0) if clip_config else 0
|
||||
if not isinstance(speed, (int, float)) or speed <= 0:
|
||||
return global_speed
|
||||
return float(speed)
|
||||
@@ -0,0 +1,313 @@
|
||||
"""speed_config 领域模型单测."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.speed_config import (
|
||||
DEFAULT_SPEED,
|
||||
MAX_SPEED,
|
||||
MIN_SPEED,
|
||||
SpeedConfig,
|
||||
adjust_duration,
|
||||
build_audio_filter,
|
||||
build_video_filter,
|
||||
build_clip_speed_filter,
|
||||
resolve_clip_speed,
|
||||
)
|
||||
|
||||
# ── 常量测试 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestConstants:
|
||||
def test_min_speed(self):
|
||||
assert MIN_SPEED == 0.25
|
||||
|
||||
def test_max_speed(self):
|
||||
assert MAX_SPEED == 4.0
|
||||
|
||||
def test_default_speed(self):
|
||||
assert DEFAULT_SPEED == 1.0
|
||||
|
||||
|
||||
# ── SpeedConfig.parse 测试 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSpeedConfigParse:
|
||||
def test_none_returns_default(self):
|
||||
cfg = SpeedConfig.parse(None)
|
||||
assert cfg.speed == DEFAULT_SPEED
|
||||
assert cfg.pitch_correct is True
|
||||
|
||||
def test_empty_dict_returns_default(self):
|
||||
cfg = SpeedConfig.parse({})
|
||||
assert cfg.speed == DEFAULT_SPEED
|
||||
|
||||
def test_invalid_type_returns_default(self):
|
||||
cfg = SpeedConfig.parse("not_a_dict")
|
||||
assert cfg.speed == DEFAULT_SPEED
|
||||
|
||||
def test_valid_speed(self):
|
||||
cfg = SpeedConfig.parse({"speed": 2.0})
|
||||
assert cfg.speed == 2.0
|
||||
|
||||
def test_speed_clamped_low(self):
|
||||
cfg = SpeedConfig.parse({"speed": 0.1})
|
||||
assert cfg.speed == MIN_SPEED
|
||||
|
||||
def test_speed_clamped_high(self):
|
||||
cfg = SpeedConfig.parse({"speed": 5.0})
|
||||
assert cfg.speed == MAX_SPEED
|
||||
|
||||
def test_zero_speed_returns_default(self):
|
||||
cfg = SpeedConfig.parse({"speed": 0})
|
||||
assert cfg.speed == DEFAULT_SPEED
|
||||
|
||||
def test_negative_speed_returns_default(self):
|
||||
cfg = SpeedConfig.parse({"speed": -1.0})
|
||||
assert cfg.speed == DEFAULT_SPEED
|
||||
|
||||
def test_pitch_correct_false(self):
|
||||
cfg = SpeedConfig.parse({"pitch_correct": False})
|
||||
assert cfg.pitch_correct is False
|
||||
|
||||
def test_pitch_correct_invalid_type_defaults_true(self):
|
||||
cfg = SpeedConfig.parse({"pitch_correct": "yes"})
|
||||
assert cfg.pitch_correct is True
|
||||
|
||||
def test_string_speed_invalid_uses_default(self):
|
||||
cfg = SpeedConfig.parse({"speed": "fast"})
|
||||
assert cfg.speed == DEFAULT_SPEED
|
||||
|
||||
|
||||
# ── SpeedConfig.clamp 测试 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestClamp:
|
||||
def test_already_valid_unchanged(self):
|
||||
cfg = SpeedConfig(speed=1.5)
|
||||
cfg.clamp()
|
||||
assert cfg.speed == 1.5
|
||||
|
||||
def test_below_min_clamped(self):
|
||||
cfg = SpeedConfig(speed=0.1)
|
||||
cfg.clamp()
|
||||
assert cfg.speed == MIN_SPEED
|
||||
|
||||
def test_above_max_clamped(self):
|
||||
cfg = SpeedConfig(speed=10.0)
|
||||
cfg.clamp()
|
||||
assert cfg.speed == MAX_SPEED
|
||||
|
||||
def test_zero_defaults(self):
|
||||
cfg = SpeedConfig(speed=0.0)
|
||||
cfg.clamp()
|
||||
assert cfg.speed == DEFAULT_SPEED
|
||||
|
||||
def test_negative_defaults(self):
|
||||
cfg = SpeedConfig(speed=-2.0)
|
||||
cfg.clamp()
|
||||
assert cfg.speed == DEFAULT_SPEED
|
||||
|
||||
def test_exact_min_stays(self):
|
||||
cfg = SpeedConfig(speed=MIN_SPEED)
|
||||
cfg.clamp()
|
||||
assert cfg.speed == MIN_SPEED
|
||||
|
||||
def test_exact_max_stays(self):
|
||||
cfg = SpeedConfig(speed=MAX_SPEED)
|
||||
cfg.clamp()
|
||||
assert cfg.speed == MAX_SPEED
|
||||
|
||||
|
||||
# ── is_original / is_fast / is_slow 测试 ─────────────────────────────────
|
||||
|
||||
|
||||
class TestSpeedProperties:
|
||||
def test_is_original_true(self):
|
||||
cfg = SpeedConfig(speed=1.0)
|
||||
assert cfg.is_original is True
|
||||
|
||||
def test_is_original_false_fast(self):
|
||||
cfg = SpeedConfig(speed=2.0)
|
||||
assert cfg.is_original is False
|
||||
|
||||
def test_is_original_false_slow(self):
|
||||
cfg = SpeedConfig(speed=0.5)
|
||||
assert cfg.is_original is False
|
||||
|
||||
def test_is_original_near_one(self):
|
||||
cfg = SpeedConfig(speed=1.0000001)
|
||||
assert cfg.is_original is True
|
||||
|
||||
def test_is_fast_true(self):
|
||||
cfg = SpeedConfig(speed=2.0)
|
||||
assert cfg.is_fast is True
|
||||
|
||||
def test_is_fast_false(self):
|
||||
cfg = SpeedConfig(speed=0.5)
|
||||
assert cfg.is_fast is False
|
||||
|
||||
def test_is_false_for_original(self):
|
||||
cfg = SpeedConfig(speed=1.0)
|
||||
assert cfg.is_fast is False
|
||||
assert cfg.is_slow is False
|
||||
|
||||
def test_is_slow_true(self):
|
||||
cfg = SpeedConfig(speed=0.5)
|
||||
assert cfg.is_slow is True
|
||||
|
||||
def test_is_slow_false(self):
|
||||
cfg = SpeedConfig(speed=2.0)
|
||||
assert cfg.is_slow is False
|
||||
|
||||
|
||||
# ── build_video_filter 测试 ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildVideoFilter:
|
||||
def test_original_speed_empty(self):
|
||||
cfg = SpeedConfig(speed=1.0)
|
||||
assert build_video_filter(cfg) == ""
|
||||
|
||||
def test_fast_speed_setpts(self):
|
||||
cfg = SpeedConfig(speed=2.0)
|
||||
result = build_video_filter(cfg)
|
||||
assert "setpts=PTS/2.0000" in result
|
||||
|
||||
def test_slow_speed_setpts(self):
|
||||
cfg = SpeedConfig(speed=0.5)
|
||||
result = build_video_filter(cfg)
|
||||
assert "setpts=PTS/0.5000" in result
|
||||
|
||||
def test_format_four_decimals(self):
|
||||
cfg = SpeedConfig(speed=1.5)
|
||||
result = build_video_filter(cfg)
|
||||
assert "1.5000" in result
|
||||
|
||||
|
||||
# ── build_audio_filter 测试 ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildAudioFilter:
|
||||
def test_original_speed_empty(self):
|
||||
cfg = SpeedConfig(speed=1.0)
|
||||
assert build_audio_filter(cfg) == ""
|
||||
|
||||
def test_single_stage_within_range(self):
|
||||
cfg = SpeedConfig(speed=1.5)
|
||||
result = build_audio_filter(cfg)
|
||||
assert result == "atempo=1.5000"
|
||||
assert result.count("atempo") == 1
|
||||
|
||||
def test_fast_two_stages(self):
|
||||
cfg = SpeedConfig(speed=3.0)
|
||||
result = build_audio_filter(cfg)
|
||||
assert result.count("atempo") == 2
|
||||
# 2.0 * 1.5 = 3.0
|
||||
assert "atempo=2.0000" in result
|
||||
assert "atempo=1.5000" in result
|
||||
|
||||
def test_max_speed_two_stages(self):
|
||||
cfg = SpeedConfig(speed=4.0)
|
||||
result = build_audio_filter(cfg)
|
||||
assert result.count("atempo") == 2
|
||||
# 2.0 * 2.0 = 4.0
|
||||
assert result == "atempo=2.0000,atempo=2.0000"
|
||||
|
||||
def test_slow_two_stages(self):
|
||||
cfg = SpeedConfig(speed=0.25)
|
||||
result = build_audio_filter(cfg)
|
||||
assert result.count("atempo") == 2
|
||||
# 0.5 * 0.5 = 0.25
|
||||
assert result == "atempo=0.5000,atempo=0.5000"
|
||||
|
||||
def test_slow_single_stage(self):
|
||||
cfg = SpeedConfig(speed=0.8)
|
||||
result = build_audio_filter(cfg)
|
||||
assert result == "atempo=0.8000"
|
||||
assert result.count("atempo") == 1
|
||||
|
||||
def test_exactly_two_point_zero_single(self):
|
||||
cfg = SpeedConfig(speed=2.0)
|
||||
result = build_audio_filter(cfg)
|
||||
assert result.count("atempo") == 1
|
||||
assert "atempo=2.0000" in result
|
||||
|
||||
def test_exactly_half_single(self):
|
||||
cfg = SpeedConfig(speed=0.5)
|
||||
result = build_audio_filter(cfg)
|
||||
assert result.count("atempo") == 1
|
||||
assert "atempo=0.5000" in result
|
||||
|
||||
|
||||
# ── adjust_duration 测试 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAdjustDuration:
|
||||
def test_original_speed_unchanged(self):
|
||||
cfg = SpeedConfig(speed=1.0)
|
||||
assert adjust_duration(10.0, cfg) == 10.0
|
||||
|
||||
def test_double_speed_halved(self):
|
||||
cfg = SpeedConfig(speed=2.0)
|
||||
assert adjust_duration(10.0, cfg) == 5.0
|
||||
|
||||
def test_half_speed_doubled(self):
|
||||
cfg = SpeedConfig(speed=0.5)
|
||||
assert adjust_duration(10.0, cfg) == 20.0
|
||||
|
||||
def test_zero_duration_unchanged(self):
|
||||
cfg = SpeedConfig(speed=2.0)
|
||||
assert adjust_duration(0.0, cfg) == 0.0
|
||||
|
||||
def test_negative_duration_unchanged(self):
|
||||
cfg = SpeedConfig(speed=2.0)
|
||||
assert adjust_duration(-5.0, cfg) == -5.0
|
||||
|
||||
|
||||
# ── build_clip_speed_filter 测试 ─────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildClipSpeedFilter:
|
||||
def test_normal_speed(self):
|
||||
vf, af, cfg = build_clip_speed_filter(2.0)
|
||||
assert vf == "setpts=PTS/2.0000"
|
||||
assert "atempo=2.0000" in af
|
||||
assert cfg.speed == 2.0
|
||||
|
||||
def test_clamped_speed(self):
|
||||
vf, af, cfg = build_clip_speed_filter(10.0)
|
||||
assert cfg.speed == MAX_SPEED
|
||||
|
||||
def test_pitch_correct_param(self):
|
||||
vf, af, cfg = build_clip_speed_filter(1.5, pitch_correct=False)
|
||||
assert cfg.pitch_correct is False
|
||||
|
||||
def test_original_speed_empty_filters(self):
|
||||
vf, af, cfg = build_clip_speed_filter(1.0)
|
||||
assert vf == ""
|
||||
assert af == ""
|
||||
|
||||
|
||||
# ── resolve_clip_speed 测试 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestResolveClipSpeed:
|
||||
def test_none_config_uses_global(self):
|
||||
assert resolve_clip_speed(None, 1.5) == 1.5
|
||||
|
||||
def test_no_playback_speed_uses_global(self):
|
||||
assert resolve_clip_speed({}, 1.5) == 1.5
|
||||
|
||||
def test_zero_speed_uses_global(self):
|
||||
assert resolve_clip_speed({"playback_speed": 0}, 1.5) == 1.5
|
||||
|
||||
def test_valid_speed_returns_speed(self):
|
||||
assert resolve_clip_speed({"playback_speed": 2.0}, 1.0) == 2.0
|
||||
|
||||
def test_invalid_type_uses_global(self):
|
||||
assert resolve_clip_speed({"playback_speed": "fast"}, 1.0) == 1.0
|
||||
|
||||
def test_default_global_speed(self):
|
||||
assert resolve_clip_speed({}) == DEFAULT_SPEED
|
||||
Reference in New Issue
Block a user