Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 807606d5f0 | |||
| 71ac73a889 | |||
| dd669f6ad9 | |||
| c34b3b2c5c | |||
| 3283c3e01c | |||
| 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", () => {
|
||||
|
||||
Reference in New Issue
Block a user