Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 807606d5f0 | |||
| 71ac73a889 | |||
| dd669f6ad9 | |||
| c34b3b2c5c | |||
| 3283c3e01c | |||
| b99a760bb1 | |||
| 0b998421d8 | |||
| 018cf59018 |
@@ -4,731 +4,132 @@
|
|||||||
* 支持:标题卡片展示、AI 生成标题、复制/编辑/删除、收藏、分类筛选、搜索
|
* 支持:标题卡片展示、AI 生成标题、复制/编辑/删除、收藏、分类筛选、搜索
|
||||||
* 对接后端真实 API(GET/POST/PUT/DELETE /titles)
|
* 对接后端真实 API(GET/POST/PUT/DELETE /titles)
|
||||||
*/
|
*/
|
||||||
import React, { useMemo, useState, useCallback } from "react"
|
import React from "react"
|
||||||
import { Modal as AntModal, message, Popconfirm } from "antd"
|
import { useTitleLibrary } from "./hooks/useTitleLibrary"
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
import { useTitleEdit } from "./hooks/useTitleEdit"
|
||||||
import {
|
import { useTitleAI } from "./hooks/useTitleAI"
|
||||||
PlusOutlined,
|
import { CategorySidebar } from "./components/title-library/CategorySidebar"
|
||||||
SearchOutlined,
|
import { FilterBar } from "./components/title-library/FilterBar"
|
||||||
DeleteOutlined,
|
import { TitleGrid } from "./components/title-library/TitleGrid"
|
||||||
EditOutlined,
|
import { CreateTitleModal } from "./components/title-library/CreateTitleModal"
|
||||||
CopyOutlined,
|
import { AIGenerateModal } from "./components/title-library/AIGenerateModal"
|
||||||
CheckOutlined,
|
|
||||||
RobotOutlined,
|
|
||||||
StarOutlined,
|
|
||||||
StarFilled,
|
|
||||||
FileTextOutlined,
|
|
||||||
} from "@ant-design/icons"
|
|
||||||
import { Button, Input, Select } from "@/components/ui"
|
|
||||||
import { getTitles, createTitle, updateTitle, deleteTitle, type TitleItem } from "@/api/titles"
|
|
||||||
import "./titles.css"
|
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 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 {
|
||||||
const [activeCatId, setActiveCatId] = useState<string>("cat-all")
|
editingId,
|
||||||
|
editText,
|
||||||
|
setEditText,
|
||||||
|
createTitleModalOpen,
|
||||||
|
setCreateTitleModalOpen,
|
||||||
|
newTitleContent,
|
||||||
|
setNewTitleContent,
|
||||||
|
newTitleType,
|
||||||
|
setNewTitleType,
|
||||||
|
handleStartEdit,
|
||||||
|
handleSaveEdit,
|
||||||
|
handleCancelEdit,
|
||||||
|
handleCreateTitle,
|
||||||
|
handleCloseCreateModal,
|
||||||
|
} = useTitleEdit({ updateMutation, createMutation })
|
||||||
|
|
||||||
/* 标题数据 — 真实 API */
|
const {
|
||||||
const { data: apiTitles = [] } = useQuery({
|
aiModalOpen,
|
||||||
queryKey: ["titles"],
|
setAiModalOpen,
|
||||||
queryFn: getTitles,
|
aiKeyword,
|
||||||
staleTime: 30_000,
|
setAiKeyword,
|
||||||
})
|
aiLoading,
|
||||||
const titles: TitleData[] = useMemo(() => apiTitles.map(toTitleData), [apiTitles])
|
aiResults,
|
||||||
|
handleAIGenerate,
|
||||||
/* 从真实标题数据动态派生分类(无需后端分类 API) */
|
handleAdoptAITitle,
|
||||||
const categories = useMemo(() => {
|
handleCopyAI,
|
||||||
const cats = new Map<string, number>()
|
handleCloseAIModal,
|
||||||
apiTitles.forEach((t) => {
|
} = useTitleAI({ createMutation })
|
||||||
const cat = t.category || "未分类"
|
|
||||||
cats.set(cat, (cats.get(cat) || 0) + 1)
|
|
||||||
})
|
|
||||||
return [
|
|
||||||
{ id: "cat-all", name: "全部标题", count: apiTitles.length },
|
|
||||||
...Array.from(cats.entries()).map(([name, count]) => ({
|
|
||||||
id: `cat-${name}`,
|
|
||||||
name,
|
|
||||||
count,
|
|
||||||
})),
|
|
||||||
]
|
|
||||||
}, [apiTitles])
|
|
||||||
|
|
||||||
/* CRUD mutations */
|
|
||||||
const createMutation = useMutation({
|
|
||||||
mutationFn: (content: string) => createTitle({ content }),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["titles"] })
|
|
||||||
},
|
|
||||||
onError: () => message.error("创建标题失败"),
|
|
||||||
})
|
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
|
||||||
mutationFn: ({ id, content }: { id: string; content: string }) => updateTitle(id, { content }),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["titles"] })
|
|
||||||
},
|
|
||||||
onError: () => message.error("更新标题失败"),
|
|
||||||
})
|
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
|
||||||
mutationFn: (id: string) => deleteTitle(id),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["titles"] })
|
|
||||||
},
|
|
||||||
onError: () => message.error("删除标题失败"),
|
|
||||||
})
|
|
||||||
|
|
||||||
/* 筛选 */
|
|
||||||
const [searchText, setSearchText] = useState("")
|
|
||||||
const [filterType, setFilterType] = useState<string>("all")
|
|
||||||
const [filterIndustry, setFilterIndustry] = useState<string>("all")
|
|
||||||
const [filterFrequency, setFilterFrequency] = useState<Frequency>("all")
|
|
||||||
|
|
||||||
/* 编辑状态 */
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
|
||||||
const [editText, setEditText] = useState("")
|
|
||||||
|
|
||||||
/* 新建标题 */
|
|
||||||
const [createTitleModalOpen, setCreateTitleModalOpen] = useState(false)
|
|
||||||
const [newTitleContent, setNewTitleContent] = useState("")
|
|
||||||
const [newTitleType, setNewTitleType] = useState<TitleType>("normal")
|
|
||||||
|
|
||||||
/* AI 生成 */
|
|
||||||
const [aiModalOpen, setAiModalOpen] = useState(false)
|
|
||||||
const [aiKeyword, setAiKeyword] = useState("")
|
|
||||||
const [aiLoading, setAiLoading] = useState(false)
|
|
||||||
const [aiResults, setAiResults] = useState<string[]>([])
|
|
||||||
|
|
||||||
/* 派生数据 */
|
|
||||||
const activeCategory = categories.find((c) => c.id === activeCatId)
|
|
||||||
|
|
||||||
const filteredTitles = useMemo(() => {
|
|
||||||
let list = titles
|
|
||||||
|
|
||||||
/* 按分类过滤("全部标题" 不过滤)— 直接匹配后端 category 字段 */
|
|
||||||
if (activeCatId !== "cat-all") {
|
|
||||||
const catName = activeCategory?.name || ""
|
|
||||||
if (catName) {
|
|
||||||
list = list.filter((t) => t.category === catName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 按类型筛选 */
|
|
||||||
if (filterType !== "all") {
|
|
||||||
list = list.filter((t) => t.type === filterType)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 按行业筛选 */
|
|
||||||
if (filterIndustry !== "all") {
|
|
||||||
list = list.filter((t) => t.industry === filterIndustry)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 按使用频率筛选 */
|
|
||||||
if (filterFrequency !== "all") {
|
|
||||||
switch (filterFrequency) {
|
|
||||||
case "high":
|
|
||||||
list = list.filter((t) => t.usageCount >= 100)
|
|
||||||
break
|
|
||||||
case "medium":
|
|
||||||
list = list.filter((t) => t.usageCount >= 30 && t.usageCount < 100)
|
|
||||||
break
|
|
||||||
case "low":
|
|
||||||
list = list.filter((t) => t.usageCount < 30)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 搜索 */
|
|
||||||
if (searchText.trim()) {
|
|
||||||
const q = searchText.trim().toLowerCase()
|
|
||||||
list = list.filter((t) => t.content.toLowerCase().includes(q))
|
|
||||||
}
|
|
||||||
|
|
||||||
return list
|
|
||||||
}, [titles, activeCatId, activeCategory, filterType, filterIndustry, filterFrequency, searchText])
|
|
||||||
|
|
||||||
/* 收藏切换(暂不支持,待后端 API) */
|
|
||||||
const handleToggleFavorite = useCallback((_id: string) => {
|
|
||||||
message.info("收藏功能即将上线")
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/* 复制 */
|
|
||||||
const handleCopy = useCallback(async (title: TitleData) => {
|
|
||||||
const ok = await copyToClipboard(title.content)
|
|
||||||
if (ok) {
|
|
||||||
message.success("已复制到剪贴板")
|
|
||||||
} else {
|
|
||||||
message.error("复制失败")
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/* 编辑 */
|
|
||||||
const handleStartEdit = useCallback((title: TitleData) => {
|
|
||||||
setEditingId(title.id)
|
|
||||||
setEditText(title.content)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleSaveEdit = useCallback(() => {
|
|
||||||
if (!editText.trim()) {
|
|
||||||
message.warning("标题内容不能为空")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (editingId) {
|
|
||||||
updateMutation.mutate({ id: editingId, content: editText.trim() })
|
|
||||||
}
|
|
||||||
setEditingId(null)
|
|
||||||
setEditText("")
|
|
||||||
message.success("标题已更新")
|
|
||||||
}, [editingId, editText, updateMutation])
|
|
||||||
|
|
||||||
const handleCancelEdit = useCallback(() => {
|
|
||||||
setEditingId(null)
|
|
||||||
setEditText("")
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/* 删除 */
|
|
||||||
const handleDelete = useCallback(
|
|
||||||
(id: string) => {
|
|
||||||
deleteMutation.mutate(id)
|
|
||||||
message.success("标题已删除")
|
|
||||||
},
|
|
||||||
[deleteMutation],
|
|
||||||
)
|
|
||||||
|
|
||||||
/* 新建标题 */
|
|
||||||
const handleCreateTitle = () => {
|
|
||||||
if (!newTitleContent.trim()) {
|
|
||||||
message.warning("请输入标题内容")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
createMutation.mutate(newTitleContent.trim(), {
|
|
||||||
onSuccess: () => {
|
|
||||||
setCreateTitleModalOpen(false)
|
|
||||||
setNewTitleContent("")
|
|
||||||
setNewTitleType("normal")
|
|
||||||
message.success("标题创建成功")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/* AI 生成标题 */
|
|
||||||
const handleAIGenerate = () => {
|
|
||||||
if (!aiKeyword.trim()) {
|
|
||||||
message.warning("请输入关键词或主题")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setAiLoading(true)
|
|
||||||
setAiResults([])
|
|
||||||
|
|
||||||
/* Mock AI 生成延迟 */
|
|
||||||
setTimeout(() => {
|
|
||||||
const keyword = aiKeyword.trim()
|
|
||||||
const results = [
|
|
||||||
`${keyword}:这个方法让我事半功倍!`,
|
|
||||||
`关于${keyword},99%的人都不知道的事`,
|
|
||||||
`${keyword}全攻略,看完这篇就够了`,
|
|
||||||
`我花了 3 个月研究${keyword},总结出这些经验`,
|
|
||||||
`${keyword}避坑指南,帮你省下 1000 块`,
|
|
||||||
]
|
|
||||||
setAiResults(results)
|
|
||||||
setAiLoading(false)
|
|
||||||
}, 2000)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 采纳 AI 生成的标题 */
|
|
||||||
const handleAdoptAITitle = (text: string) => {
|
|
||||||
createMutation.mutate(text, {
|
|
||||||
onSuccess: () => {
|
|
||||||
message.success("标题已采纳并添加到标题库")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 复制 AI 生成的标题 */
|
|
||||||
const handleCopyAI = async (text: string) => {
|
|
||||||
const ok = await copyToClipboard(text)
|
|
||||||
if (ok) {
|
|
||||||
message.success("已复制到剪贴板")
|
|
||||||
} else {
|
|
||||||
message.error("复制失败")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="xx-titles-page">
|
<div className="xx-titles-page">
|
||||||
{/* 两栏布局 */}
|
|
||||||
<div className="xx-titles-layout">
|
<div className="xx-titles-layout">
|
||||||
{/* ─── 左侧:分类列表 ─── */}
|
{/* 左侧:分类列表 */}
|
||||||
<div className="xx-title-category-list">
|
<CategorySidebar
|
||||||
{categories.map((cat) => (
|
categories={categories}
|
||||||
<div
|
activeCatId={activeCatId}
|
||||||
key={cat.id}
|
onSelect={setActiveCatId}
|
||||||
className={`xx-title-category-item${cat.id === activeCatId ? " active" : ""}`}
|
/>
|
||||||
onClick={() => setActiveCatId(cat.id)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ minWidth: 0 }}>
|
|
||||||
<h4 style={{ margin: 0 }}>
|
|
||||||
<FileTextOutlined /> {cat.name}
|
|
||||||
</h4>
|
|
||||||
<span>{cat.count} 条</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* TODO: 新建分类功能待后端分类 API 就绪后启用 */}
|
{/* 右侧:内容区 */}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ─── 右侧:内容区 ─── */}
|
|
||||||
<div className="xx-titles-content">
|
<div className="xx-titles-content">
|
||||||
{/* 筛选栏 */}
|
<FilterBar
|
||||||
<div className="xx-titles-filters">
|
searchText={searchText}
|
||||||
<div className="xx-titles-filters-left">
|
onSearchChange={setSearchText}
|
||||||
<Input
|
filterType={filterType}
|
||||||
placeholder="搜索标题关键词..."
|
onFilterTypeChange={setFilterType}
|
||||||
prefix={<SearchOutlined />}
|
filterIndustry={filterIndustry}
|
||||||
value={searchText}
|
onFilterIndustryChange={setFilterIndustry}
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
filterFrequency={filterFrequency}
|
||||||
allowClear
|
onFilterFrequencyChange={setFilterFrequency}
|
||||||
style={{ width: 220 }}
|
onCreateClick={() => setCreateTitleModalOpen(true)}
|
||||||
/>
|
onAIClick={() => setAiModalOpen(true)}
|
||||||
<Select
|
/>
|
||||||
value={filterType}
|
|
||||||
onChange={setFilterType}
|
|
||||||
style={{ width: 110 }}
|
|
||||||
options={[
|
|
||||||
{ value: "all", label: "全部类型" },
|
|
||||||
{ value: "hot", label: "爆款" },
|
|
||||||
{ value: "normal", label: "常规" },
|
|
||||||
{ value: "creative", label: "创意" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
value={filterIndustry}
|
|
||||||
onChange={setFilterIndustry}
|
|
||||||
style={{ width: 110 }}
|
|
||||||
options={[
|
|
||||||
{ value: "all", label: "全部行业" },
|
|
||||||
{ value: "food", label: "美食" },
|
|
||||||
{ value: "tech", label: "科技" },
|
|
||||||
{ value: "beauty", label: "美妆" },
|
|
||||||
{ value: "education", label: "教育" },
|
|
||||||
{ value: "travel", label: "旅行" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
value={filterFrequency}
|
|
||||||
onChange={(v) => setFilterFrequency(v as Frequency)}
|
|
||||||
style={{ width: 120 }}
|
|
||||||
options={[
|
|
||||||
{ value: "all", label: "全部频率" },
|
|
||||||
{ value: "high", label: "高频使用" },
|
|
||||||
{ value: "medium", label: "中频使用" },
|
|
||||||
{ value: "low", label: "低频使用" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="xx-titles-filters-right">
|
|
||||||
<Button
|
|
||||||
buttonType="ghost"
|
|
||||||
buttonSize="sm"
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
onClick={() => setCreateTitleModalOpen(true)}
|
|
||||||
>
|
|
||||||
新建标题
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
buttonType="primary"
|
|
||||||
buttonSize="sm"
|
|
||||||
icon={<RobotOutlined />}
|
|
||||||
onClick={() => setAiModalOpen(true)}
|
|
||||||
>
|
|
||||||
AI 生成标题
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 标题卡片网格 */}
|
<TitleGrid
|
||||||
{filteredTitles.length > 0 ? (
|
titles={filteredTitles}
|
||||||
<div className="xx-title-grid">
|
editingId={editingId}
|
||||||
{filteredTitles.map((title) => (
|
editText={editText}
|
||||||
<TitleCard
|
searchText={searchText}
|
||||||
key={title.id}
|
onEditChange={setEditText}
|
||||||
title={title}
|
onStartEdit={handleStartEdit}
|
||||||
isEditing={editingId === title.id}
|
onSaveEdit={handleSaveEdit}
|
||||||
editText={editingId === title.id ? editText : ""}
|
onCancelEdit={handleCancelEdit}
|
||||||
onEditChange={setEditText}
|
onCopy={handleCopy}
|
||||||
onStartEdit={() => handleStartEdit(title)}
|
onDelete={handleDelete}
|
||||||
onSaveEdit={handleSaveEdit}
|
onToggleFavorite={handleToggleFavorite}
|
||||||
onCancelEdit={handleCancelEdit}
|
/>
|
||||||
onCopy={() => handleCopy(title)}
|
|
||||||
onDelete={() => handleDelete(title.id)}
|
|
||||||
onToggleFavorite={() => handleToggleFavorite(title.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="xx-titles-empty">
|
|
||||||
<div className="xx-titles-empty-icon">
|
|
||||||
<FileTextOutlined />
|
|
||||||
</div>
|
|
||||||
<p>
|
|
||||||
{searchText
|
|
||||||
? "未找到匹配的标题"
|
|
||||||
: "暂无标题,点击「新建标题」或「AI 生成标题」开始"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ─── 新建标题弹窗 ─── */}
|
{/* 新建标题弹窗 */}
|
||||||
<AntModal
|
<CreateTitleModal
|
||||||
title="新建标题"
|
|
||||||
open={createTitleModalOpen}
|
open={createTitleModalOpen}
|
||||||
onCancel={() => setCreateTitleModalOpen(false)}
|
newTitleContent={newTitleContent}
|
||||||
onOk={handleCreateTitle}
|
newTitleType={newTitleType}
|
||||||
okText="创建"
|
onContentChange={setNewTitleContent}
|
||||||
cancelText="取消"
|
onTypeChange={setNewTitleType}
|
||||||
destroyOnClose
|
onCancel={handleCloseCreateModal}
|
||||||
>
|
onSubmit={handleCreateTitle}
|
||||||
<div
|
/>
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 16,
|
|
||||||
padding: "8px 0",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginBottom: 6,
|
|
||||||
fontSize: "var(--font-size-sm)",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
标题内容
|
|
||||||
</div>
|
|
||||||
<Input.TextArea
|
|
||||||
placeholder="请输入标题内容"
|
|
||||||
value={newTitleContent}
|
|
||||||
onChange={(e) => setNewTitleContent(e.target.value)}
|
|
||||||
rows={3}
|
|
||||||
maxLength={200}
|
|
||||||
showCount
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginBottom: 6,
|
|
||||||
fontSize: "var(--font-size-sm)",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
标题类型
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
value={newTitleType}
|
|
||||||
onChange={(v) => setNewTitleType(v)}
|
|
||||||
style={{ width: "100%" }}
|
|
||||||
options={[
|
|
||||||
{ value: "hot", label: "爆款" },
|
|
||||||
{ value: "normal", label: "常规" },
|
|
||||||
{ value: "creative", label: "创意" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AntModal>
|
|
||||||
|
|
||||||
{/* ─── AI 生成标题弹窗 ─── */}
|
{/* AI 生成标题弹窗 */}
|
||||||
<AntModal
|
<AIGenerateModal
|
||||||
title="AI 生成标题"
|
|
||||||
open={aiModalOpen}
|
open={aiModalOpen}
|
||||||
onCancel={() => {
|
aiKeyword={aiKeyword}
|
||||||
setAiModalOpen(false)
|
aiLoading={aiLoading}
|
||||||
setAiLoading(false)
|
aiResults={aiResults}
|
||||||
setAiResults([])
|
onKeywordChange={setAiKeyword}
|
||||||
setAiKeyword("")
|
onGenerate={handleAIGenerate}
|
||||||
}}
|
onCancel={handleCloseAIModal}
|
||||||
onOk={handleAIGenerate}
|
onCopy={handleCopyAI}
|
||||||
okText={aiLoading ? "生成中..." : "生成"}
|
onAdopt={handleAdoptAITitle}
|
||||||
cancelText="关闭"
|
/>
|
||||||
okButtonProps={{ disabled: aiLoading }}
|
|
||||||
destroyOnClose
|
|
||||||
width={640}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 16,
|
|
||||||
padding: "8px 0",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginBottom: 6,
|
|
||||||
fontSize: "var(--font-size-sm)",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
输入关键词或主题
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
placeholder="例如:美食探店、科技评测、旅行攻略..."
|
|
||||||
value={aiKeyword}
|
|
||||||
onChange={(e) => setAiKeyword(e.target.value)}
|
|
||||||
maxLength={100}
|
|
||||||
onPressEnter={handleAIGenerate}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* AI 加载动画 */}
|
|
||||||
{aiLoading && (
|
|
||||||
<div className="xx-ai-loading">
|
|
||||||
<div className="xx-ai-loading-dots">
|
|
||||||
<div className="xx-ai-loading-dot" />
|
|
||||||
<div className="xx-ai-loading-dot" />
|
|
||||||
<div className="xx-ai-loading-dot" />
|
|
||||||
</div>
|
|
||||||
<span>AI 正在生成标题候选...</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* AI 生成结果列表 */}
|
|
||||||
{aiResults.length > 0 && (
|
|
||||||
<div className="xx-ai-results">
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: "var(--font-size-sm)",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
marginBottom: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
已生成 {aiResults.length} 个候选标题,点击采纳或复制:
|
|
||||||
</div>
|
|
||||||
{aiResults.map((text, idx) => (
|
|
||||||
<div key={idx} className="xx-ai-result-item">
|
|
||||||
<span className="xx-ai-result-text">{text}</span>
|
|
||||||
<div className="xx-ai-result-actions">
|
|
||||||
<Button
|
|
||||||
buttonType="ghost"
|
|
||||||
buttonSize="sm"
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
onClick={() => handleCopyAI(text)}
|
|
||||||
>
|
|
||||||
复制
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
buttonType="primary"
|
|
||||||
buttonSize="sm"
|
|
||||||
icon={<CheckOutlined />}
|
|
||||||
onClick={() => handleAdoptAITitle(text)}
|
|
||||||
>
|
|
||||||
采纳
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</AntModal>
|
|
||||||
</div>
|
</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", () => ({}))
|
vi.mock("@/pages/titles/titles.css", () => ({}))
|
||||||
|
|
||||||
import TitleLibrary from "@/pages/titles/TitleLibrary"
|
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", () => {
|
describe("TitleLibrary Page", () => {
|
||||||
it("should render without crashing", () => {
|
it("should render without crashing", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user