Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 66734583d9 | |||
| 3fb9e13de6 | |||
| 646c5cebac | |||
| 464b4a392d | |||
| e56870b088 | |||
| c762740c30 | |||
| 4f774c5204 | |||
| 64544d0e5e |
@@ -0,0 +1,230 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import {
|
||||
batchDeleteAssets,
|
||||
batchTagAssets,
|
||||
batchClassifyAssets,
|
||||
batchMarkAssets,
|
||||
type BatchOperationResult,
|
||||
} from "@/api/assets"
|
||||
import type { SmartViewType } from "../../components/BatchMarkModal"
|
||||
import { SMART_VIEW_LABELS } from "./constants"
|
||||
|
||||
/* ── 批量删除 ── */
|
||||
interface UseBatchDeleteOptions {
|
||||
selectedIds: Set<string>
|
||||
invalidateAssets: () => void
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchDelete = ({ selectedIds, invalidateAssets, showResult }: UseBatchDeleteOptions) => {
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchDelete = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchDeleteAssets(ids)
|
||||
invalidateAssets()
|
||||
showResult(result, "批量删除")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功删除 ${result.success_count} 个素材`)
|
||||
} else {
|
||||
message.warning(
|
||||
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量删除失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [selectedIds, invalidateAssets, showResult])
|
||||
|
||||
return { batchLoading, handleBatchDelete }
|
||||
}
|
||||
|
||||
/* ── 批量打标签 ── */
|
||||
interface UseBatchTagOptions {
|
||||
selectedIds: Set<string>
|
||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchTag = ({ selectedIds, queryClient, showResult }: UseBatchTagOptions) => {
|
||||
const [tagModalOpen, setTagModalOpen] = useState(false)
|
||||
const [batchTagInput, setBatchTagInput] = useState("")
|
||||
const [batchTags, setBatchTags] = useState<string[]>([])
|
||||
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchTag = useCallback(async () => {
|
||||
if (batchTags.length === 0) {
|
||||
message.warning("请至少输入一个标签")
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchTagAssets({
|
||||
asset_ids: ids,
|
||||
tags: batchTags,
|
||||
mode: tagMode,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showResult(result, "批量打标签")
|
||||
setTagModalOpen(false)
|
||||
setBatchTags([])
|
||||
setBatchTagInput("")
|
||||
setTagMode("add")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功为 ${result.success_count} 个素材打标签`)
|
||||
} else {
|
||||
message.warning(
|
||||
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量打标签失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchTags, selectedIds, tagMode, queryClient, showResult])
|
||||
|
||||
const handleTagInputKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && batchTagInput.trim()) {
|
||||
e.preventDefault()
|
||||
const tag = batchTagInput.trim()
|
||||
if (!batchTags.includes(tag)) {
|
||||
setBatchTags([...batchTags, tag])
|
||||
}
|
||||
setBatchTagInput("")
|
||||
}
|
||||
},
|
||||
[batchTagInput, batchTags],
|
||||
)
|
||||
|
||||
const removeBatchTag = useCallback(
|
||||
(tag: string) => {
|
||||
setBatchTags(batchTags.filter((t) => t !== tag))
|
||||
},
|
||||
[batchTags],
|
||||
)
|
||||
|
||||
return {
|
||||
tagModalOpen,
|
||||
setTagModalOpen,
|
||||
batchTagInput,
|
||||
setBatchTagInput,
|
||||
batchTags,
|
||||
setBatchTags,
|
||||
tagMode,
|
||||
setTagMode,
|
||||
batchLoading,
|
||||
handleBatchTag,
|
||||
handleTagInputKeyDown,
|
||||
removeBatchTag,
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 批量改分类 ── */
|
||||
interface UseBatchClassifyOptions {
|
||||
selectedIds: Set<string>
|
||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchClassify = ({ selectedIds, queryClient, showResult }: UseBatchClassifyOptions) => {
|
||||
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
|
||||
const [batchCategory, setBatchCategory] = useState("")
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchClassify = useCallback(async () => {
|
||||
if (!batchCategory) {
|
||||
message.warning("请选择分类")
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchClassifyAssets({
|
||||
asset_ids: ids,
|
||||
category: batchCategory,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showResult(result, "批量改分类")
|
||||
setClassifyModalOpen(false)
|
||||
setBatchCategory("")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}」`)
|
||||
} else {
|
||||
message.warning(
|
||||
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量改分类失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchCategory, selectedIds, queryClient, showResult])
|
||||
|
||||
return {
|
||||
classifyModalOpen,
|
||||
setClassifyModalOpen,
|
||||
batchCategory,
|
||||
setBatchCategory,
|
||||
batchLoading,
|
||||
handleBatchClassify,
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 批量智能标记 ── */
|
||||
interface UseBatchMarkOptions {
|
||||
selectedIds: Set<string>
|
||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||
}
|
||||
|
||||
export const useBatchMark = ({ selectedIds, queryClient, showResult }: UseBatchMarkOptions) => {
|
||||
const [markModalOpen, setMarkModalOpen] = useState(false)
|
||||
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
const handleBatchMark = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchMarkAssets({
|
||||
asset_ids: ids,
|
||||
smart_view: batchSmartView,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showResult(result, "批量智能标记")
|
||||
setMarkModalOpen(false)
|
||||
if (result.failure_count === 0) {
|
||||
message.success(
|
||||
`成功将 ${result.success_count} 个素材标记为「${SMART_VIEW_LABELS[batchSmartView]}」`,
|
||||
)
|
||||
} else {
|
||||
message.warning(
|
||||
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量智能标记失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchSmartView, selectedIds, queryClient, showResult])
|
||||
|
||||
return {
|
||||
markModalOpen,
|
||||
setMarkModalOpen,
|
||||
batchSmartView,
|
||||
setBatchSmartView,
|
||||
batchLoading,
|
||||
handleBatchMark,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { SmartViewType } from "../../components/BatchMarkModal"
|
||||
|
||||
export const SMART_VIEW_LABELS: Record<SmartViewType, string> = {
|
||||
recommended: "推荐",
|
||||
caution: "慎用",
|
||||
high_risk: "高风险",
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useCallback } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { getAssetDiagnosis, deleteAsset, type BatchOperationResult } from "@/api/assets"
|
||||
import type { AssetItem } from "../../types"
|
||||
|
||||
interface UseSingleOperationsOptions {
|
||||
selectedIds: Set<string>
|
||||
setSelectedIds: (ids: Set<string>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个素材操作 Hook
|
||||
* 诊断、单个删除
|
||||
*/
|
||||
export const useSingleOperations = ({
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
}: UseSingleOperationsOptions) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
/* ── 诊断 ── */
|
||||
const handleDiagnose = useCallback(
|
||||
async (asset: AssetItem) => {
|
||||
// 模拟 loading 状态
|
||||
try {
|
||||
const result = await getAssetDiagnosis(asset.id)
|
||||
const score = result.readiness_score ?? "-"
|
||||
message.success(`"${asset.name}" 诊断完成,就绪分:${score}`)
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
} catch {
|
||||
message.error(`"${asset.name}" 诊断失败`)
|
||||
}
|
||||
},
|
||||
[queryClient],
|
||||
)
|
||||
|
||||
/* ── 单个素材删除 ── */
|
||||
const handleSingleDelete = useCallback(
|
||||
async (assetId: string) => {
|
||||
try {
|
||||
await deleteAsset(assetId)
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
// 从选中集合中移除
|
||||
setSelectedIds(
|
||||
(() => {
|
||||
const next = new Set(selectedIds)
|
||||
next.delete(assetId)
|
||||
return next
|
||||
})(),
|
||||
)
|
||||
message.success("素材已删除")
|
||||
} catch {
|
||||
message.error("删除失败,请重试")
|
||||
}
|
||||
},
|
||||
[queryClient, selectedIds, setSelectedIds],
|
||||
)
|
||||
|
||||
return {
|
||||
handleDiagnose,
|
||||
handleSingleDelete,
|
||||
}
|
||||
}
|
||||
|
||||
interface UseBatchHelpersOptions {
|
||||
queryClient: ReturnType<typeof useQueryClient>
|
||||
setSelectedIds: (ids: Set<string>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量操作辅助函数
|
||||
* 刷新数据、显示操作结果
|
||||
*/
|
||||
export const useBatchHelpers = ({
|
||||
queryClient,
|
||||
setSelectedIds,
|
||||
}: UseBatchHelpersOptions) => {
|
||||
const invalidateAssets = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
}, [queryClient])
|
||||
|
||||
const showOperationResult = useCallback(
|
||||
(
|
||||
setResult: (r: BatchOperationResult | null) => void,
|
||||
setTitle: (t: string) => void,
|
||||
setDrawerOpen: (v: boolean) => void,
|
||||
result: BatchOperationResult,
|
||||
title: string,
|
||||
clearSelection = true,
|
||||
) => {
|
||||
setResult(result)
|
||||
setTitle(title)
|
||||
setDrawerOpen(true)
|
||||
if (clearSelection) setSelectedIds(new Set())
|
||||
},
|
||||
[setSelectedIds],
|
||||
)
|
||||
|
||||
return {
|
||||
invalidateAssets,
|
||||
showOperationResult,
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,21 @@
|
||||
/**
|
||||
* 素材操作 Hook(入口)
|
||||
* 组合各子模块,保持导出不变
|
||||
*
|
||||
* 子模块位于 ./asset-operations/
|
||||
*/
|
||||
import { useState, useCallback } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { useSingleOperations, useBatchHelpers } from "./asset-operations/useSingleOperations"
|
||||
import {
|
||||
deleteAsset,
|
||||
getAssetDiagnosis,
|
||||
batchDeleteAssets,
|
||||
batchTagAssets,
|
||||
batchClassifyAssets,
|
||||
batchMarkAssets,
|
||||
type BatchOperationResult,
|
||||
} from "@/api/assets"
|
||||
import type { AssetItem } from "../types"
|
||||
useBatchDelete,
|
||||
useBatchTag,
|
||||
useBatchClassify,
|
||||
useBatchMark,
|
||||
} from "./asset-operations/batchOperations"
|
||||
import type { BatchOperationResult } from "@/api/assets"
|
||||
import type { SmartViewType } from "../components/BatchMarkModal"
|
||||
|
||||
/**
|
||||
* 素材操作 Hook
|
||||
* 封装素材的诊断、删除、批量打标签、批量改分类、批量智能标记等操作,
|
||||
* 以及相关弹窗和结果展示的状态管理
|
||||
*/
|
||||
interface UseAssetOperationsProps {
|
||||
selectedIds: Set<string>
|
||||
setSelectedIds: (ids: Set<string>) => void
|
||||
@@ -29,78 +27,35 @@ export function useAssetOperations({ selectedIds, setSelectedIds }: UseAssetOper
|
||||
/* ── 诊断状态 ── */
|
||||
const [diagnosingId, setDiagnosingId] = useState<string | null>(null)
|
||||
|
||||
/* ── 批量操作弹窗状态 ── */
|
||||
const [tagModalOpen, setTagModalOpen] = useState(false)
|
||||
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
|
||||
const [markModalOpen, setMarkModalOpen] = useState(false)
|
||||
const [resultDrawerOpen, setResultDrawerOpen] = useState(false)
|
||||
|
||||
/* ── 批量打标签表单 ── */
|
||||
const [batchTagInput, setBatchTagInput] = useState("")
|
||||
const [batchTags, setBatchTags] = useState<string[]>([])
|
||||
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
|
||||
|
||||
/* ── 批量改分类表单 ── */
|
||||
const [batchCategory, setBatchCategory] = useState("")
|
||||
|
||||
/* ── 批量智能标记表单 ── */
|
||||
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
|
||||
|
||||
/* ── 操作结果 ── */
|
||||
const [resultDrawerOpen, setResultDrawerOpen] = useState(false)
|
||||
const [operationResult, setOperationResult] = useState<BatchOperationResult | null>(null)
|
||||
const [operationTitle, setOperationTitle] = useState("")
|
||||
|
||||
/* ── 批量操作 loading ── */
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
/* ── 单个操作 ── */
|
||||
const { handleDiagnose: handleDiagnoseRaw, handleSingleDelete } = useSingleOperations({
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
})
|
||||
|
||||
/* ── 刷新数据辅助函数 ── */
|
||||
const invalidateAssets = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
}, [queryClient])
|
||||
|
||||
/* ── 诊断 ── */
|
||||
// 包装一下,加上 diagnosingId 状态
|
||||
const handleDiagnose = useCallback(
|
||||
async (asset: AssetItem) => {
|
||||
async (asset: Parameters<typeof handleDiagnoseRaw>[0]) => {
|
||||
setDiagnosingId(asset.id)
|
||||
try {
|
||||
const result = await getAssetDiagnosis(asset.id)
|
||||
const score = result.readiness_score ?? "-"
|
||||
message.success(`"${asset.name}" 诊断完成,就绪分:${score}`)
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
} catch {
|
||||
message.error(`"${asset.name}" 诊断失败`)
|
||||
await handleDiagnoseRaw(asset)
|
||||
} finally {
|
||||
setDiagnosingId(null)
|
||||
}
|
||||
},
|
||||
[queryClient],
|
||||
[handleDiagnoseRaw],
|
||||
)
|
||||
|
||||
/* ── 单个素材删除 ── */
|
||||
const handleSingleDelete = useCallback(
|
||||
async (assetId: string) => {
|
||||
try {
|
||||
await deleteAsset(assetId)
|
||||
invalidateAssets()
|
||||
// 从选中集合中移除
|
||||
setSelectedIds(
|
||||
(() => {
|
||||
const next = new Set(selectedIds)
|
||||
next.delete(assetId)
|
||||
return next
|
||||
})(),
|
||||
)
|
||||
message.success("素材已删除")
|
||||
} catch {
|
||||
message.error("删除失败,请重试")
|
||||
}
|
||||
},
|
||||
[invalidateAssets, selectedIds, setSelectedIds],
|
||||
)
|
||||
/* ── 批量操作辅助 ── */
|
||||
const { invalidateAssets } = useBatchHelpers({ queryClient, setSelectedIds })
|
||||
|
||||
/* ── 显示操作结果 ── */
|
||||
const showOperationResult = useCallback(
|
||||
// 包装 showResult 适配子模块的接口
|
||||
const showResult = useCallback(
|
||||
(result: BatchOperationResult, title: string, clearSelection = true) => {
|
||||
setOperationResult(result)
|
||||
setOperationTitle(title)
|
||||
@@ -110,147 +65,20 @@ export function useAssetOperations({ selectedIds, setSelectedIds }: UseAssetOper
|
||||
[setSelectedIds],
|
||||
)
|
||||
|
||||
/* ── 批量删除 ── */
|
||||
const handleBatchDelete = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchDeleteAssets(ids)
|
||||
invalidateAssets()
|
||||
showOperationResult(result, "批量删除")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功删除 ${result.success_count} 个素材`)
|
||||
} else {
|
||||
message.warning(
|
||||
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量删除失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [selectedIds, invalidateAssets, showOperationResult])
|
||||
/* ── 批量操作 ── */
|
||||
const { batchLoading: deleteLoading, handleBatchDelete } = useBatchDelete({
|
||||
selectedIds,
|
||||
invalidateAssets,
|
||||
showResult,
|
||||
})
|
||||
|
||||
/* ── 批量打标签 ── */
|
||||
const handleBatchTag = useCallback(async () => {
|
||||
if (batchTags.length === 0) {
|
||||
message.warning("请至少输入一个标签")
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchTagAssets({
|
||||
asset_ids: ids,
|
||||
tags: batchTags,
|
||||
mode: tagMode,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showOperationResult(result, "批量打标签")
|
||||
setTagModalOpen(false)
|
||||
setBatchTags([])
|
||||
setBatchTagInput("")
|
||||
setTagMode("add")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功为 ${result.success_count} 个素材打标签`)
|
||||
} else {
|
||||
message.warning(
|
||||
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量打标签失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchTags, selectedIds, tagMode, queryClient, showOperationResult])
|
||||
const tagResult = useBatchTag({ selectedIds, queryClient, showResult })
|
||||
const classifyResult = useBatchClassify({ selectedIds, queryClient, showResult })
|
||||
const markResult = useBatchMark({ selectedIds, queryClient, showResult })
|
||||
|
||||
/* ── 标签输入处理 ── */
|
||||
const handleTagInputKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && batchTagInput.trim()) {
|
||||
e.preventDefault()
|
||||
const tag = batchTagInput.trim()
|
||||
if (!batchTags.includes(tag)) {
|
||||
setBatchTags([...batchTags, tag])
|
||||
}
|
||||
setBatchTagInput("")
|
||||
}
|
||||
},
|
||||
[batchTagInput, batchTags],
|
||||
)
|
||||
|
||||
const removeBatchTag = useCallback(
|
||||
(tag: string) => {
|
||||
setBatchTags(batchTags.filter((t) => t !== tag))
|
||||
},
|
||||
[batchTags],
|
||||
)
|
||||
|
||||
/* ── 批量改分类 ── */
|
||||
const handleBatchClassify = useCallback(async () => {
|
||||
if (!batchCategory) {
|
||||
message.warning("请选择分类")
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchClassifyAssets({
|
||||
asset_ids: ids,
|
||||
category: batchCategory,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showOperationResult(result, "批量改分类")
|
||||
setClassifyModalOpen(false)
|
||||
setBatchCategory("")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}」`)
|
||||
} else {
|
||||
message.warning(
|
||||
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量改分类失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchCategory, selectedIds, queryClient, showOperationResult])
|
||||
|
||||
/* ── 批量智能标记 ── */
|
||||
const handleBatchMark = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchMarkAssets({
|
||||
asset_ids: ids,
|
||||
smart_view: batchSmartView,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showOperationResult(result, "批量智能标记")
|
||||
setMarkModalOpen(false)
|
||||
const labelMap: Record<SmartViewType, string> = {
|
||||
recommended: "推荐",
|
||||
caution: "慎用",
|
||||
high_risk: "高风险",
|
||||
}
|
||||
if (result.failure_count === 0) {
|
||||
message.success(
|
||||
`成功将 ${result.success_count} 个素材标记为「${labelMap[batchSmartView]}」`,
|
||||
)
|
||||
} else {
|
||||
message.warning(
|
||||
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量智能标记失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchSmartView, selectedIds, queryClient, showOperationResult])
|
||||
// 取任一批量操作的 loading 状态(任意一个在加载都算加载中)
|
||||
const batchLoading =
|
||||
deleteLoading || tagResult.batchLoading || classifyResult.batchLoading || markResult.batchLoading
|
||||
|
||||
/* ── 关闭结果 Drawer ── */
|
||||
const handleResultDrawerClose = useCallback(() => {
|
||||
@@ -267,29 +95,29 @@ export function useAssetOperations({ selectedIds, setSelectedIds }: UseAssetOper
|
||||
// 批量操作 loading
|
||||
batchLoading,
|
||||
// 批量打标签
|
||||
tagModalOpen,
|
||||
setTagModalOpen,
|
||||
batchTagInput,
|
||||
setBatchTagInput,
|
||||
batchTags,
|
||||
setBatchTags,
|
||||
tagMode,
|
||||
setTagMode,
|
||||
handleBatchTag,
|
||||
handleTagInputKeyDown,
|
||||
removeBatchTag,
|
||||
tagModalOpen: tagResult.tagModalOpen,
|
||||
setTagModalOpen: tagResult.setTagModalOpen,
|
||||
batchTagInput: tagResult.batchTagInput,
|
||||
setBatchTagInput: tagResult.setBatchTagInput,
|
||||
batchTags: tagResult.batchTags,
|
||||
setBatchTags: tagResult.setBatchTags,
|
||||
tagMode: tagResult.tagMode,
|
||||
setTagMode: tagResult.setTagMode,
|
||||
handleBatchTag: tagResult.handleBatchTag,
|
||||
handleTagInputKeyDown: tagResult.handleTagInputKeyDown,
|
||||
removeBatchTag: tagResult.removeBatchTag,
|
||||
// 批量改分类
|
||||
classifyModalOpen,
|
||||
setClassifyModalOpen,
|
||||
batchCategory,
|
||||
setBatchCategory,
|
||||
handleBatchClassify,
|
||||
classifyModalOpen: classifyResult.classifyModalOpen,
|
||||
setClassifyModalOpen: classifyResult.setClassifyModalOpen,
|
||||
batchCategory: classifyResult.batchCategory,
|
||||
setBatchCategory: classifyResult.setBatchCategory,
|
||||
handleBatchClassify: classifyResult.handleBatchClassify,
|
||||
// 批量智能标记
|
||||
markModalOpen,
|
||||
setMarkModalOpen,
|
||||
batchSmartView,
|
||||
setBatchSmartView,
|
||||
handleBatchMark,
|
||||
markModalOpen: markResult.markModalOpen,
|
||||
setMarkModalOpen: markResult.setMarkModalOpen,
|
||||
batchSmartView: markResult.batchSmartView as SmartViewType,
|
||||
setBatchSmartView: markResult.setBatchSmartView,
|
||||
handleBatchMark: markResult.handleBatchMark,
|
||||
// 批量删除
|
||||
handleBatchDelete,
|
||||
// 操作结果
|
||||
|
||||
@@ -4,132 +4,731 @@
|
||||
* 支持:标题卡片展示、AI 生成标题、复制/编辑/删除、收藏、分类筛选、搜索
|
||||
* 对接后端真实 API(GET/POST/PUT/DELETE /titles)
|
||||
*/
|
||||
import React from "react"
|
||||
import { useTitleLibrary } from "./hooks/useTitleLibrary"
|
||||
import { useTitleEdit } from "./hooks/useTitleEdit"
|
||||
import { useTitleAI } from "./hooks/useTitleAI"
|
||||
import { CategorySidebar } from "./components/title-library/CategorySidebar"
|
||||
import { FilterBar } from "./components/title-library/FilterBar"
|
||||
import { TitleGrid } from "./components/title-library/TitleGrid"
|
||||
import { CreateTitleModal } from "./components/title-library/CreateTitleModal"
|
||||
import { AIGenerateModal } from "./components/title-library/AIGenerateModal"
|
||||
import React, { useMemo, useState, useCallback } from "react"
|
||||
import { Modal as AntModal, message, Popconfirm } from "antd"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import {
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
CopyOutlined,
|
||||
CheckOutlined,
|
||||
RobotOutlined,
|
||||
StarOutlined,
|
||||
StarFilled,
|
||||
FileTextOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Button, Input, Select } from "@/components/ui"
|
||||
import { getTitles, createTitle, updateTitle, deleteTitle, type TitleItem } from "@/api/titles"
|
||||
import "./titles.css"
|
||||
|
||||
/* ============================================================
|
||||
* 类型
|
||||
* ============================================================ */
|
||||
type TitleType = "hot" | "normal" | "creative"
|
||||
type Industry = "general" | "food" | "tech" | "beauty" | "education" | "travel"
|
||||
type Frequency = "all" | "high" | "medium" | "low"
|
||||
|
||||
interface TitleData {
|
||||
id: string
|
||||
content: string
|
||||
type: TitleType
|
||||
industry: Industry
|
||||
category: string
|
||||
usageCount: number
|
||||
isFavorited: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** 后端 TitleItem → 前端 TitleData 映射 */
|
||||
const toTitleData = (item: TitleItem): TitleData => ({
|
||||
id: item.id,
|
||||
content: item.content,
|
||||
type: (item.category as TitleType) || "normal",
|
||||
industry: "general",
|
||||
category: item.category || "未分类",
|
||||
usageCount: 0,
|
||||
isFavorited: false,
|
||||
createdAt: item.created_at?.slice(0, 10) || "",
|
||||
})
|
||||
|
||||
/* ============================================================
|
||||
* 工具函数
|
||||
* ============================================================ */
|
||||
const typeLabel = (type: TitleType): string => {
|
||||
switch (type) {
|
||||
case "hot":
|
||||
return "爆款"
|
||||
case "normal":
|
||||
return "常规"
|
||||
case "creative":
|
||||
return "创意"
|
||||
}
|
||||
}
|
||||
|
||||
/** 复制文本到剪贴板 */
|
||||
const copyToClipboard = async (text: string): Promise<boolean> => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch {
|
||||
/* 降级方案 */
|
||||
const textarea = document.createElement("textarea")
|
||||
textarea.value = text
|
||||
textarea.style.position = "fixed"
|
||||
textarea.style.opacity = "0"
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
try {
|
||||
document.execCommand("copy")
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* TitleCard 组件
|
||||
* ============================================================ */
|
||||
const TitleCard: React.FC<{
|
||||
title: TitleData
|
||||
isEditing: boolean
|
||||
editText: string
|
||||
onEditChange: (text: string) => void
|
||||
onStartEdit: () => void
|
||||
onSaveEdit: () => void
|
||||
onCancelEdit: () => void
|
||||
onCopy: () => void
|
||||
onDelete: () => void
|
||||
onToggleFavorite: () => void
|
||||
}> = ({
|
||||
title,
|
||||
isEditing,
|
||||
editText,
|
||||
onEditChange,
|
||||
onStartEdit,
|
||||
onSaveEdit,
|
||||
onCancelEdit,
|
||||
onCopy,
|
||||
onDelete,
|
||||
onToggleFavorite,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-title-card">
|
||||
{/* 收藏按钮 */}
|
||||
<button
|
||||
className="xx-title-fav-btn"
|
||||
onClick={onToggleFavorite}
|
||||
title={title.isFavorited ? "取消收藏" : "收藏"}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 12,
|
||||
right: 12,
|
||||
color: title.isFavorited ? "#f59e0b" : "var(--text-tertiary)",
|
||||
}}
|
||||
>
|
||||
{title.isFavorited ? <StarFilled /> : <StarOutlined />}
|
||||
</button>
|
||||
|
||||
{/* 标题文本 / 编辑区 */}
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
className="xx-title-card-edit"
|
||||
value={editText}
|
||||
onChange={(e) => onEditChange(e.target.value)}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
onSaveEdit()
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
onCancelEdit()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="xx-title-card-text" style={{ paddingRight: 24 }}>
|
||||
{title.content}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部元信息 */}
|
||||
<div className="xx-title-card-meta">
|
||||
<div className="xx-title-card-meta-left">
|
||||
<span className={`xx-title-type-tag ${title.type}`}>{typeLabel(title.type)}</span>
|
||||
<span className="xx-title-card-stat">使用 {title.usageCount} 次</span>
|
||||
<span className="xx-title-card-stat">{title.createdAt}</span>
|
||||
</div>
|
||||
|
||||
<div className="xx-title-card-actions">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button className="xx-title-card-action-btn" onClick={onSaveEdit} title="保存">
|
||||
<CheckOutlined />
|
||||
</button>
|
||||
<button className="xx-title-card-action-btn" onClick={onCancelEdit} title="取消">
|
||||
✕
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="xx-title-card-action-btn" onClick={onCopy} title="复制">
|
||||
<CopyOutlined />
|
||||
</button>
|
||||
<button className="xx-title-card-action-btn" onClick={onStartEdit} title="编辑">
|
||||
<EditOutlined />
|
||||
</button>
|
||||
<Popconfirm
|
||||
title="确定删除此标题?"
|
||||
onConfirm={onDelete}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<button className="xx-title-card-action-btn danger" title="删除">
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
</Popconfirm>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 主组件
|
||||
* ============================================================ */
|
||||
const TitleLibrary: React.FC = () => {
|
||||
const {
|
||||
categories,
|
||||
activeCatId,
|
||||
filteredTitles,
|
||||
searchText,
|
||||
filterType,
|
||||
filterIndustry,
|
||||
filterFrequency,
|
||||
createMutation,
|
||||
updateMutation,
|
||||
setActiveCatId,
|
||||
setSearchText,
|
||||
setFilterType,
|
||||
setFilterIndustry,
|
||||
setFilterFrequency,
|
||||
handleToggleFavorite,
|
||||
handleCopy,
|
||||
handleDelete,
|
||||
} = useTitleLibrary()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const {
|
||||
editingId,
|
||||
editText,
|
||||
setEditText,
|
||||
createTitleModalOpen,
|
||||
setCreateTitleModalOpen,
|
||||
newTitleContent,
|
||||
setNewTitleContent,
|
||||
newTitleType,
|
||||
setNewTitleType,
|
||||
handleStartEdit,
|
||||
handleSaveEdit,
|
||||
handleCancelEdit,
|
||||
handleCreateTitle,
|
||||
handleCloseCreateModal,
|
||||
} = useTitleEdit({ updateMutation, createMutation })
|
||||
/* 分类数据 — 从真实标题数据动态派生 */
|
||||
const [activeCatId, setActiveCatId] = useState<string>("cat-all")
|
||||
|
||||
const {
|
||||
aiModalOpen,
|
||||
setAiModalOpen,
|
||||
aiKeyword,
|
||||
setAiKeyword,
|
||||
aiLoading,
|
||||
aiResults,
|
||||
handleAIGenerate,
|
||||
handleAdoptAITitle,
|
||||
handleCopyAI,
|
||||
handleCloseAIModal,
|
||||
} = useTitleAI({ createMutation })
|
||||
/* 标题数据 — 真实 API */
|
||||
const { data: apiTitles = [] } = useQuery({
|
||||
queryKey: ["titles"],
|
||||
queryFn: getTitles,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
const titles: TitleData[] = useMemo(() => apiTitles.map(toTitleData), [apiTitles])
|
||||
|
||||
/* 从真实标题数据动态派生分类(无需后端分类 API) */
|
||||
const categories = useMemo(() => {
|
||||
const cats = new Map<string, number>()
|
||||
apiTitles.forEach((t) => {
|
||||
const cat = t.category || "未分类"
|
||||
cats.set(cat, (cats.get(cat) || 0) + 1)
|
||||
})
|
||||
return [
|
||||
{ id: "cat-all", name: "全部标题", count: apiTitles.length },
|
||||
...Array.from(cats.entries()).map(([name, count]) => ({
|
||||
id: `cat-${name}`,
|
||||
name,
|
||||
count,
|
||||
})),
|
||||
]
|
||||
}, [apiTitles])
|
||||
|
||||
/* CRUD mutations */
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (content: string) => createTitle({ content }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["titles"] })
|
||||
},
|
||||
onError: () => message.error("创建标题失败"),
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, content }: { id: string; content: string }) => updateTitle(id, { content }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["titles"] })
|
||||
},
|
||||
onError: () => message.error("更新标题失败"),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteTitle(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["titles"] })
|
||||
},
|
||||
onError: () => message.error("删除标题失败"),
|
||||
})
|
||||
|
||||
/* 筛选 */
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [filterType, setFilterType] = useState<string>("all")
|
||||
const [filterIndustry, setFilterIndustry] = useState<string>("all")
|
||||
const [filterFrequency, setFilterFrequency] = useState<Frequency>("all")
|
||||
|
||||
/* 编辑状态 */
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editText, setEditText] = useState("")
|
||||
|
||||
/* 新建标题 */
|
||||
const [createTitleModalOpen, setCreateTitleModalOpen] = useState(false)
|
||||
const [newTitleContent, setNewTitleContent] = useState("")
|
||||
const [newTitleType, setNewTitleType] = useState<TitleType>("normal")
|
||||
|
||||
/* AI 生成 */
|
||||
const [aiModalOpen, setAiModalOpen] = useState(false)
|
||||
const [aiKeyword, setAiKeyword] = useState("")
|
||||
const [aiLoading, setAiLoading] = useState(false)
|
||||
const [aiResults, setAiResults] = useState<string[]>([])
|
||||
|
||||
/* 派生数据 */
|
||||
const activeCategory = categories.find((c) => c.id === activeCatId)
|
||||
|
||||
const filteredTitles = useMemo(() => {
|
||||
let list = titles
|
||||
|
||||
/* 按分类过滤("全部标题" 不过滤)— 直接匹配后端 category 字段 */
|
||||
if (activeCatId !== "cat-all") {
|
||||
const catName = activeCategory?.name || ""
|
||||
if (catName) {
|
||||
list = list.filter((t) => t.category === catName)
|
||||
}
|
||||
}
|
||||
|
||||
/* 按类型筛选 */
|
||||
if (filterType !== "all") {
|
||||
list = list.filter((t) => t.type === filterType)
|
||||
}
|
||||
|
||||
/* 按行业筛选 */
|
||||
if (filterIndustry !== "all") {
|
||||
list = list.filter((t) => t.industry === filterIndustry)
|
||||
}
|
||||
|
||||
/* 按使用频率筛选 */
|
||||
if (filterFrequency !== "all") {
|
||||
switch (filterFrequency) {
|
||||
case "high":
|
||||
list = list.filter((t) => t.usageCount >= 100)
|
||||
break
|
||||
case "medium":
|
||||
list = list.filter((t) => t.usageCount >= 30 && t.usageCount < 100)
|
||||
break
|
||||
case "low":
|
||||
list = list.filter((t) => t.usageCount < 30)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/* 搜索 */
|
||||
if (searchText.trim()) {
|
||||
const q = searchText.trim().toLowerCase()
|
||||
list = list.filter((t) => t.content.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
return list
|
||||
}, [titles, activeCatId, activeCategory, filterType, filterIndustry, filterFrequency, searchText])
|
||||
|
||||
/* 收藏切换(暂不支持,待后端 API) */
|
||||
const handleToggleFavorite = useCallback((_id: string) => {
|
||||
message.info("收藏功能即将上线")
|
||||
}, [])
|
||||
|
||||
/* 复制 */
|
||||
const handleCopy = useCallback(async (title: TitleData) => {
|
||||
const ok = await copyToClipboard(title.content)
|
||||
if (ok) {
|
||||
message.success("已复制到剪贴板")
|
||||
} else {
|
||||
message.error("复制失败")
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* 编辑 */
|
||||
const handleStartEdit = useCallback((title: TitleData) => {
|
||||
setEditingId(title.id)
|
||||
setEditText(title.content)
|
||||
}, [])
|
||||
|
||||
const handleSaveEdit = useCallback(() => {
|
||||
if (!editText.trim()) {
|
||||
message.warning("标题内容不能为空")
|
||||
return
|
||||
}
|
||||
if (editingId) {
|
||||
updateMutation.mutate({ id: editingId, content: editText.trim() })
|
||||
}
|
||||
setEditingId(null)
|
||||
setEditText("")
|
||||
message.success("标题已更新")
|
||||
}, [editingId, editText, updateMutation])
|
||||
|
||||
const handleCancelEdit = useCallback(() => {
|
||||
setEditingId(null)
|
||||
setEditText("")
|
||||
}, [])
|
||||
|
||||
/* 删除 */
|
||||
const handleDelete = useCallback(
|
||||
(id: string) => {
|
||||
deleteMutation.mutate(id)
|
||||
message.success("标题已删除")
|
||||
},
|
||||
[deleteMutation],
|
||||
)
|
||||
|
||||
/* 新建标题 */
|
||||
const handleCreateTitle = () => {
|
||||
if (!newTitleContent.trim()) {
|
||||
message.warning("请输入标题内容")
|
||||
return
|
||||
}
|
||||
createMutation.mutate(newTitleContent.trim(), {
|
||||
onSuccess: () => {
|
||||
setCreateTitleModalOpen(false)
|
||||
setNewTitleContent("")
|
||||
setNewTitleType("normal")
|
||||
message.success("标题创建成功")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/* AI 生成标题 */
|
||||
const handleAIGenerate = () => {
|
||||
if (!aiKeyword.trim()) {
|
||||
message.warning("请输入关键词或主题")
|
||||
return
|
||||
}
|
||||
setAiLoading(true)
|
||||
setAiResults([])
|
||||
|
||||
/* Mock AI 生成延迟 */
|
||||
setTimeout(() => {
|
||||
const keyword = aiKeyword.trim()
|
||||
const results = [
|
||||
`${keyword}:这个方法让我事半功倍!`,
|
||||
`关于${keyword},99%的人都不知道的事`,
|
||||
`${keyword}全攻略,看完这篇就够了`,
|
||||
`我花了 3 个月研究${keyword},总结出这些经验`,
|
||||
`${keyword}避坑指南,帮你省下 1000 块`,
|
||||
]
|
||||
setAiResults(results)
|
||||
setAiLoading(false)
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
/* 采纳 AI 生成的标题 */
|
||||
const handleAdoptAITitle = (text: string) => {
|
||||
createMutation.mutate(text, {
|
||||
onSuccess: () => {
|
||||
message.success("标题已采纳并添加到标题库")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/* 复制 AI 生成的标题 */
|
||||
const handleCopyAI = async (text: string) => {
|
||||
const ok = await copyToClipboard(text)
|
||||
if (ok) {
|
||||
message.success("已复制到剪贴板")
|
||||
} else {
|
||||
message.error("复制失败")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-titles-page">
|
||||
{/* 两栏布局 */}
|
||||
<div className="xx-titles-layout">
|
||||
{/* 左侧:分类列表 */}
|
||||
<CategorySidebar
|
||||
categories={categories}
|
||||
activeCatId={activeCatId}
|
||||
onSelect={setActiveCatId}
|
||||
/>
|
||||
{/* ─── 左侧:分类列表 ─── */}
|
||||
<div className="xx-title-category-list">
|
||||
{categories.map((cat) => (
|
||||
<div
|
||||
key={cat.id}
|
||||
className={`xx-title-category-item${cat.id === activeCatId ? " active" : ""}`}
|
||||
onClick={() => setActiveCatId(cat.id)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<h4 style={{ margin: 0 }}>
|
||||
<FileTextOutlined /> {cat.name}
|
||||
</h4>
|
||||
<span>{cat.count} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 右侧:内容区 */}
|
||||
{/* TODO: 新建分类功能待后端分类 API 就绪后启用 */}
|
||||
</div>
|
||||
|
||||
{/* ─── 右侧:内容区 ─── */}
|
||||
<div className="xx-titles-content">
|
||||
<FilterBar
|
||||
searchText={searchText}
|
||||
onSearchChange={setSearchText}
|
||||
filterType={filterType}
|
||||
onFilterTypeChange={setFilterType}
|
||||
filterIndustry={filterIndustry}
|
||||
onFilterIndustryChange={setFilterIndustry}
|
||||
filterFrequency={filterFrequency}
|
||||
onFilterFrequencyChange={setFilterFrequency}
|
||||
onCreateClick={() => setCreateTitleModalOpen(true)}
|
||||
onAIClick={() => setAiModalOpen(true)}
|
||||
/>
|
||||
{/* 筛选栏 */}
|
||||
<div className="xx-titles-filters">
|
||||
<div className="xx-titles-filters-left">
|
||||
<Input
|
||||
placeholder="搜索标题关键词..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
<Select
|
||||
value={filterType}
|
||||
onChange={setFilterType}
|
||||
style={{ width: 110 }}
|
||||
options={[
|
||||
{ value: "all", label: "全部类型" },
|
||||
{ value: "hot", label: "爆款" },
|
||||
{ value: "normal", label: "常规" },
|
||||
{ value: "creative", label: "创意" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={filterIndustry}
|
||||
onChange={setFilterIndustry}
|
||||
style={{ width: 110 }}
|
||||
options={[
|
||||
{ value: "all", label: "全部行业" },
|
||||
{ value: "food", label: "美食" },
|
||||
{ value: "tech", label: "科技" },
|
||||
{ value: "beauty", label: "美妆" },
|
||||
{ value: "education", label: "教育" },
|
||||
{ value: "travel", label: "旅行" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={filterFrequency}
|
||||
onChange={(v) => setFilterFrequency(v as Frequency)}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: "all", label: "全部频率" },
|
||||
{ value: "high", label: "高频使用" },
|
||||
{ value: "medium", label: "中频使用" },
|
||||
{ value: "low", label: "低频使用" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-titles-filters-right">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setCreateTitleModalOpen(true)}
|
||||
>
|
||||
新建标题
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
icon={<RobotOutlined />}
|
||||
onClick={() => setAiModalOpen(true)}
|
||||
>
|
||||
AI 生成标题
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TitleGrid
|
||||
titles={filteredTitles}
|
||||
editingId={editingId}
|
||||
editText={editText}
|
||||
searchText={searchText}
|
||||
onEditChange={setEditText}
|
||||
onStartEdit={handleStartEdit}
|
||||
onSaveEdit={handleSaveEdit}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onCopy={handleCopy}
|
||||
onDelete={handleDelete}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
/>
|
||||
{/* 标题卡片网格 */}
|
||||
{filteredTitles.length > 0 ? (
|
||||
<div className="xx-title-grid">
|
||||
{filteredTitles.map((title) => (
|
||||
<TitleCard
|
||||
key={title.id}
|
||||
title={title}
|
||||
isEditing={editingId === title.id}
|
||||
editText={editingId === title.id ? editText : ""}
|
||||
onEditChange={setEditText}
|
||||
onStartEdit={() => handleStartEdit(title)}
|
||||
onSaveEdit={handleSaveEdit}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onCopy={() => handleCopy(title)}
|
||||
onDelete={() => handleDelete(title.id)}
|
||||
onToggleFavorite={() => handleToggleFavorite(title.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="xx-titles-empty">
|
||||
<div className="xx-titles-empty-icon">
|
||||
<FileTextOutlined />
|
||||
</div>
|
||||
<p>
|
||||
{searchText
|
||||
? "未找到匹配的标题"
|
||||
: "暂无标题,点击「新建标题」或「AI 生成标题」开始"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 新建标题弹窗 */}
|
||||
<CreateTitleModal
|
||||
{/* ─── 新建标题弹窗 ─── */}
|
||||
<AntModal
|
||||
title="新建标题"
|
||||
open={createTitleModalOpen}
|
||||
newTitleContent={newTitleContent}
|
||||
newTitleType={newTitleType}
|
||||
onContentChange={setNewTitleContent}
|
||||
onTypeChange={setNewTitleType}
|
||||
onCancel={handleCloseCreateModal}
|
||||
onSubmit={handleCreateTitle}
|
||||
/>
|
||||
onCancel={() => setCreateTitleModalOpen(false)}
|
||||
onOk={handleCreateTitle}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
destroyOnClose
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
padding: "8px 0",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 6,
|
||||
fontSize: "var(--font-size-sm)",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
标题内容
|
||||
</div>
|
||||
<Input.TextArea
|
||||
placeholder="请输入标题内容"
|
||||
value={newTitleContent}
|
||||
onChange={(e) => setNewTitleContent(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={200}
|
||||
showCount
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 6,
|
||||
fontSize: "var(--font-size-sm)",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
标题类型
|
||||
</div>
|
||||
<Select
|
||||
value={newTitleType}
|
||||
onChange={(v) => setNewTitleType(v)}
|
||||
style={{ width: "100%" }}
|
||||
options={[
|
||||
{ value: "hot", label: "爆款" },
|
||||
{ value: "normal", label: "常规" },
|
||||
{ value: "creative", label: "创意" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AntModal>
|
||||
|
||||
{/* AI 生成标题弹窗 */}
|
||||
<AIGenerateModal
|
||||
{/* ─── AI 生成标题弹窗 ─── */}
|
||||
<AntModal
|
||||
title="AI 生成标题"
|
||||
open={aiModalOpen}
|
||||
aiKeyword={aiKeyword}
|
||||
aiLoading={aiLoading}
|
||||
aiResults={aiResults}
|
||||
onKeywordChange={setAiKeyword}
|
||||
onGenerate={handleAIGenerate}
|
||||
onCancel={handleCloseAIModal}
|
||||
onCopy={handleCopyAI}
|
||||
onAdopt={handleAdoptAITitle}
|
||||
/>
|
||||
onCancel={() => {
|
||||
setAiModalOpen(false)
|
||||
setAiLoading(false)
|
||||
setAiResults([])
|
||||
setAiKeyword("")
|
||||
}}
|
||||
onOk={handleAIGenerate}
|
||||
okText={aiLoading ? "生成中..." : "生成"}
|
||||
cancelText="关闭"
|
||||
okButtonProps={{ disabled: aiLoading }}
|
||||
destroyOnClose
|
||||
width={640}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
padding: "8px 0",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 6,
|
||||
fontSize: "var(--font-size-sm)",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
输入关键词或主题
|
||||
</div>
|
||||
<Input
|
||||
placeholder="例如:美食探店、科技评测、旅行攻略..."
|
||||
value={aiKeyword}
|
||||
onChange={(e) => setAiKeyword(e.target.value)}
|
||||
maxLength={100}
|
||||
onPressEnter={handleAIGenerate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* AI 加载动画 */}
|
||||
{aiLoading && (
|
||||
<div className="xx-ai-loading">
|
||||
<div className="xx-ai-loading-dots">
|
||||
<div className="xx-ai-loading-dot" />
|
||||
<div className="xx-ai-loading-dot" />
|
||||
<div className="xx-ai-loading-dot" />
|
||||
</div>
|
||||
<span>AI 正在生成标题候选...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI 生成结果列表 */}
|
||||
{aiResults.length > 0 && (
|
||||
<div className="xx-ai-results">
|
||||
<div
|
||||
style={{
|
||||
fontSize: "var(--font-size-sm)",
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
已生成 {aiResults.length} 个候选标题,点击采纳或复制:
|
||||
</div>
|
||||
{aiResults.map((text, idx) => (
|
||||
<div key={idx} className="xx-ai-result-item">
|
||||
<span className="xx-ai-result-text">{text}</span>
|
||||
<div className="xx-ai-result-actions">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => handleCopyAI(text)}
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
icon={<CheckOutlined />}
|
||||
onClick={() => handleAdoptAITitle(text)}
|
||||
>
|
||||
采纳
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AntModal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import React from "react"
|
||||
import { Modal as AntModal } from "antd"
|
||||
import { CopyOutlined, CheckOutlined } from "@ant-design/icons"
|
||||
import { Button, Input } from "@/components/ui"
|
||||
import { AI_KEYWORD_MAX_LENGTH } from "../../constants/titleLibrary"
|
||||
|
||||
interface AIGenerateModalProps {
|
||||
open: boolean
|
||||
aiKeyword: string
|
||||
aiLoading: boolean
|
||||
aiResults: string[]
|
||||
onKeywordChange: (keyword: string) => void
|
||||
onGenerate: () => void
|
||||
onCancel: () => void
|
||||
onCopy: (text: string) => void
|
||||
onAdopt: (text: string) => void
|
||||
}
|
||||
|
||||
export const AIGenerateModal: React.FC<AIGenerateModalProps> = ({
|
||||
open,
|
||||
aiKeyword,
|
||||
aiLoading,
|
||||
aiResults,
|
||||
onKeywordChange,
|
||||
onGenerate,
|
||||
onCancel,
|
||||
onCopy,
|
||||
onAdopt,
|
||||
}) => {
|
||||
return (
|
||||
<AntModal
|
||||
title="AI 生成标题"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={onGenerate}
|
||||
okText={aiLoading ? "生成中..." : "生成"}
|
||||
cancelText="关闭"
|
||||
okButtonProps={{ disabled: aiLoading }}
|
||||
destroyOnClose
|
||||
width={640}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
padding: "8px 0",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 6,
|
||||
fontSize: "var(--font-size-sm)",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
输入关键词或主题
|
||||
</div>
|
||||
<Input
|
||||
placeholder="例如:美食探店、科技评测、旅行攻略..."
|
||||
value={aiKeyword}
|
||||
onChange={(e) => onKeywordChange(e.target.value)}
|
||||
maxLength={AI_KEYWORD_MAX_LENGTH}
|
||||
onPressEnter={onGenerate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* AI 加载动画 */}
|
||||
{aiLoading && (
|
||||
<div className="xx-ai-loading">
|
||||
<div className="xx-ai-loading-dots">
|
||||
<div className="xx-ai-loading-dot" />
|
||||
<div className="xx-ai-loading-dot" />
|
||||
<div className="xx-ai-loading-dot" />
|
||||
</div>
|
||||
<span>AI 正在生成标题候选...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI 生成结果列表 */}
|
||||
{aiResults.length > 0 && (
|
||||
<div className="xx-ai-results">
|
||||
<div
|
||||
style={{
|
||||
fontSize: "var(--font-size-sm)",
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
已生成 {aiResults.length} 个候选标题,点击采纳或复制:
|
||||
</div>
|
||||
{aiResults.map((text, idx) => (
|
||||
<div key={idx} className="xx-ai-result-item">
|
||||
<span className="xx-ai-result-text">{text}</span>
|
||||
<div className="xx-ai-result-actions">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => onCopy(text)}
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
icon={<CheckOutlined />}
|
||||
onClick={() => onAdopt(text)}
|
||||
>
|
||||
采纳
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AntModal>
|
||||
)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import React from "react"
|
||||
import { FileTextOutlined } from "@ant-design/icons"
|
||||
import type { CategoryItem } from "../../types/titleLibrary"
|
||||
|
||||
interface CategorySidebarProps {
|
||||
categories: CategoryItem[]
|
||||
activeCatId: string
|
||||
onSelect: (catId: string) => void
|
||||
}
|
||||
|
||||
export const CategorySidebar: React.FC<CategorySidebarProps> = ({
|
||||
categories,
|
||||
activeCatId,
|
||||
onSelect,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-title-category-list">
|
||||
{categories.map((cat) => (
|
||||
<div
|
||||
key={cat.id}
|
||||
className={`xx-title-category-item${cat.id === activeCatId ? " active" : ""}`}
|
||||
onClick={() => onSelect(cat.id)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<h4 style={{ margin: 0 }}>
|
||||
<FileTextOutlined /> {cat.name}
|
||||
</h4>
|
||||
<span>{cat.count} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import React from "react"
|
||||
import { Modal as AntModal } from "antd"
|
||||
import { Input, Select } from "@/components/ui"
|
||||
import type { TitleType } from "../../types/titleLibrary"
|
||||
import { TITLE_MAX_LENGTH } from "../../constants/titleLibrary"
|
||||
|
||||
const TITLE_TYPE_CREATE_OPTIONS: Array<{ value: TitleType; label: string }> = [
|
||||
{ value: "hot", label: "爆款" },
|
||||
{ value: "normal", label: "常规" },
|
||||
{ value: "creative", label: "创意" },
|
||||
]
|
||||
|
||||
interface CreateTitleModalProps {
|
||||
open: boolean
|
||||
newTitleContent: string
|
||||
newTitleType: TitleType
|
||||
onContentChange: (content: string) => void
|
||||
onTypeChange: (type: TitleType) => void
|
||||
onCancel: () => void
|
||||
onSubmit: () => void
|
||||
}
|
||||
|
||||
export const CreateTitleModal: React.FC<CreateTitleModalProps> = ({
|
||||
open,
|
||||
newTitleContent,
|
||||
newTitleType,
|
||||
onContentChange,
|
||||
onTypeChange,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}) => {
|
||||
return (
|
||||
<AntModal
|
||||
title="新建标题"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={onSubmit}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
destroyOnClose
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
padding: "8px 0",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 6,
|
||||
fontSize: "var(--font-size-sm)",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
标题内容
|
||||
</div>
|
||||
<Input.TextArea
|
||||
placeholder="请输入标题内容"
|
||||
value={newTitleContent}
|
||||
onChange={(e) => onContentChange(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={TITLE_MAX_LENGTH}
|
||||
showCount
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 6,
|
||||
fontSize: "var(--font-size-sm)",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
标题类型
|
||||
</div>
|
||||
<Select
|
||||
value={newTitleType}
|
||||
onChange={(v) => onTypeChange(v as TitleType)}
|
||||
style={{ width: "100%" }}
|
||||
options={TITLE_TYPE_CREATE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AntModal>
|
||||
)
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import React from "react"
|
||||
import { SearchOutlined, PlusOutlined, RobotOutlined } from "@ant-design/icons"
|
||||
import { Button, Input, Select } from "@/components/ui"
|
||||
import type { Frequency } from "../../types/titleLibrary"
|
||||
import {
|
||||
TITLE_TYPE_OPTIONS,
|
||||
INDUSTRY_OPTIONS,
|
||||
FREQUENCY_OPTIONS,
|
||||
} from "../../constants/titleLibrary"
|
||||
|
||||
interface FilterBarProps {
|
||||
searchText: string
|
||||
onSearchChange: (text: string) => void
|
||||
filterType: string
|
||||
onFilterTypeChange: (value: string) => void
|
||||
filterIndustry: string
|
||||
onFilterIndustryChange: (value: string) => void
|
||||
filterFrequency: Frequency
|
||||
onFilterFrequencyChange: (value: Frequency) => void
|
||||
onCreateClick: () => void
|
||||
onAIClick: () => void
|
||||
}
|
||||
|
||||
export const FilterBar: React.FC<FilterBarProps> = ({
|
||||
searchText,
|
||||
onSearchChange,
|
||||
filterType,
|
||||
onFilterTypeChange,
|
||||
filterIndustry,
|
||||
onFilterIndustryChange,
|
||||
filterFrequency,
|
||||
onFilterFrequencyChange,
|
||||
onCreateClick,
|
||||
onAIClick,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-titles-filters">
|
||||
<div className="xx-titles-filters-left">
|
||||
<Input
|
||||
placeholder="搜索标题关键词..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
<Select
|
||||
value={filterType}
|
||||
onChange={onFilterTypeChange}
|
||||
style={{ width: 110 }}
|
||||
options={TITLE_TYPE_OPTIONS}
|
||||
/>
|
||||
<Select
|
||||
value={filterIndustry}
|
||||
onChange={onFilterIndustryChange}
|
||||
style={{ width: 110 }}
|
||||
options={INDUSTRY_OPTIONS}
|
||||
/>
|
||||
<Select
|
||||
value={filterFrequency}
|
||||
onChange={(v) => onFilterFrequencyChange(v as Frequency)}
|
||||
style={{ width: 120 }}
|
||||
options={FREQUENCY_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-titles-filters-right">
|
||||
<Button buttonType="ghost" buttonSize="sm" icon={<PlusOutlined />} onClick={onCreateClick}>
|
||||
新建标题
|
||||
</Button>
|
||||
<Button buttonType="primary" buttonSize="sm" icon={<RobotOutlined />} onClick={onAIClick}>
|
||||
AI 生成标题
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import React from "react"
|
||||
import { Popconfirm } from "antd"
|
||||
import {
|
||||
StarOutlined,
|
||||
StarFilled,
|
||||
EditOutlined,
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
CheckOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { TitleData } from "../../types/titleLibrary"
|
||||
import { typeLabel } from "../../utils/titleLibrary"
|
||||
|
||||
interface TitleCardProps {
|
||||
title: TitleData
|
||||
isEditing: boolean
|
||||
editText: string
|
||||
onEditChange: (text: string) => void
|
||||
onStartEdit: () => void
|
||||
onSaveEdit: () => void
|
||||
onCancelEdit: () => void
|
||||
onCopy: () => void
|
||||
onDelete: () => void
|
||||
onToggleFavorite: () => void
|
||||
}
|
||||
|
||||
export const TitleCard: React.FC<TitleCardProps> = ({
|
||||
title,
|
||||
isEditing,
|
||||
editText,
|
||||
onEditChange,
|
||||
onStartEdit,
|
||||
onSaveEdit,
|
||||
onCancelEdit,
|
||||
onCopy,
|
||||
onDelete,
|
||||
onToggleFavorite,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-title-card">
|
||||
{/* 收藏按钮 */}
|
||||
<button
|
||||
className="xx-title-fav-btn"
|
||||
onClick={onToggleFavorite}
|
||||
title={title.isFavorited ? "取消收藏" : "收藏"}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 12,
|
||||
right: 12,
|
||||
color: title.isFavorited ? "#f59e0b" : "var(--text-tertiary)",
|
||||
}}
|
||||
>
|
||||
{title.isFavorited ? <StarFilled /> : <StarOutlined />}
|
||||
</button>
|
||||
|
||||
{/* 标题文本 / 编辑区 */}
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
className="xx-title-card-edit"
|
||||
value={editText}
|
||||
onChange={(e) => onEditChange(e.target.value)}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
onSaveEdit()
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
onCancelEdit()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="xx-title-card-text" style={{ paddingRight: 24 }}>
|
||||
{title.content}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部元信息 */}
|
||||
<div className="xx-title-card-meta">
|
||||
<div className="xx-title-card-meta-left">
|
||||
<span className={`xx-title-type-tag ${title.type}`}>{typeLabel(title.type)}</span>
|
||||
<span className="xx-title-card-stat">使用 {title.usageCount} 次</span>
|
||||
<span className="xx-title-card-stat">{title.createdAt}</span>
|
||||
</div>
|
||||
|
||||
<div className="xx-title-card-actions">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button className="xx-title-card-action-btn" onClick={onSaveEdit} title="保存">
|
||||
<CheckOutlined />
|
||||
</button>
|
||||
<button className="xx-title-card-action-btn" onClick={onCancelEdit} title="取消">
|
||||
✕
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="xx-title-card-action-btn" onClick={onCopy} title="复制">
|
||||
<CopyOutlined />
|
||||
</button>
|
||||
<button className="xx-title-card-action-btn" onClick={onStartEdit} title="编辑">
|
||||
<EditOutlined />
|
||||
</button>
|
||||
<Popconfirm
|
||||
title="确定删除此标题?"
|
||||
onConfirm={onDelete}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<button className="xx-title-card-action-btn danger" title="删除">
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
</Popconfirm>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import React from "react"
|
||||
import { FileTextOutlined } from "@ant-design/icons"
|
||||
import { TitleCard } from "./TitleCard"
|
||||
import type { TitleData } from "../../types/titleLibrary"
|
||||
|
||||
interface TitleGridProps {
|
||||
titles: TitleData[]
|
||||
editingId: string | null
|
||||
editText: string
|
||||
searchText: string
|
||||
onEditChange: (text: string) => void
|
||||
onStartEdit: (title: TitleData) => void
|
||||
onSaveEdit: () => void
|
||||
onCancelEdit: () => void
|
||||
onCopy: (title: TitleData) => void
|
||||
onDelete: (id: string) => void
|
||||
onToggleFavorite: (id: string) => void
|
||||
}
|
||||
|
||||
export const TitleGrid: React.FC<TitleGridProps> = ({
|
||||
titles,
|
||||
editingId,
|
||||
editText,
|
||||
searchText,
|
||||
onEditChange,
|
||||
onStartEdit,
|
||||
onSaveEdit,
|
||||
onCancelEdit,
|
||||
onCopy,
|
||||
onDelete,
|
||||
onToggleFavorite,
|
||||
}) => {
|
||||
if (titles.length > 0) {
|
||||
return (
|
||||
<div className="xx-title-grid">
|
||||
{titles.map((title) => (
|
||||
<TitleCard
|
||||
key={title.id}
|
||||
title={title}
|
||||
isEditing={editingId === title.id}
|
||||
editText={editingId === title.id ? editText : ""}
|
||||
onEditChange={onEditChange}
|
||||
onStartEdit={() => onStartEdit(title)}
|
||||
onSaveEdit={onSaveEdit}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onCopy={() => onCopy(title)}
|
||||
onDelete={() => onDelete(title.id)}
|
||||
onToggleFavorite={() => onToggleFavorite(title.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-titles-empty">
|
||||
<div className="xx-titles-empty-icon">
|
||||
<FileTextOutlined />
|
||||
</div>
|
||||
<p>{searchText ? "未找到匹配的标题" : "暂无标题,点击「新建标题」或「AI 生成标题」开始"}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { TitleType, Industry, Frequency } from "../types/titleLibrary"
|
||||
|
||||
export const TITLE_TYPE_OPTIONS: Array<{ value: TitleType | "all"; label: string }> = [
|
||||
{ value: "all", label: "全部类型" },
|
||||
{ value: "hot", label: "爆款" },
|
||||
{ value: "normal", label: "常规" },
|
||||
{ value: "creative", label: "创意" },
|
||||
]
|
||||
|
||||
export const INDUSTRY_OPTIONS: Array<{ value: Industry | "all"; label: string }> = [
|
||||
{ value: "all", label: "全部行业" },
|
||||
{ value: "food", label: "美食" },
|
||||
{ value: "tech", label: "科技" },
|
||||
{ value: "beauty", label: "美妆" },
|
||||
{ value: "education", label: "教育" },
|
||||
{ value: "travel", label: "旅行" },
|
||||
]
|
||||
|
||||
export const FREQUENCY_OPTIONS: Array<{ value: Frequency; label: string }> = [
|
||||
{ value: "all", label: "全部频率" },
|
||||
{ value: "high", label: "高频使用" },
|
||||
{ value: "medium", label: "中频使用" },
|
||||
{ value: "low", label: "低频使用" },
|
||||
]
|
||||
|
||||
export const FREQUENCY_THRESHOLDS = {
|
||||
high: 100,
|
||||
medium: 30,
|
||||
} as const
|
||||
|
||||
export const AI_GENERATE_DELAY = 2000
|
||||
export const TITLE_MAX_LENGTH = 200
|
||||
export const AI_KEYWORD_MAX_LENGTH = 100
|
||||
export const ALL_CATEGORY_ID = "cat-all"
|
||||
@@ -1,84 +0,0 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import type { UseMutationResult } from "@tanstack/react-query"
|
||||
import type { TitleItem } from "@/api/titles"
|
||||
import { copyToClipboard } from "../utils/titleLibrary"
|
||||
import { AI_GENERATE_DELAY } from "../constants/titleLibrary"
|
||||
|
||||
interface UseTitleAIProps {
|
||||
createMutation: UseMutationResult<TitleItem, Error, string, unknown>
|
||||
}
|
||||
|
||||
const generateMockTitles = (keyword: string): string[] => [
|
||||
`${keyword}:这个方法让我事半功倍!`,
|
||||
`关于${keyword},99%的人都不知道的事`,
|
||||
`${keyword}全攻略,看完这篇就够了`,
|
||||
`我花了 3 个月研究${keyword},总结出这些经验`,
|
||||
`${keyword}避坑指南,帮你省下 1000 块`,
|
||||
]
|
||||
|
||||
export const useTitleAI = ({ createMutation }: UseTitleAIProps) => {
|
||||
const [aiModalOpen, setAiModalOpen] = useState(false)
|
||||
const [aiKeyword, setAiKeyword] = useState("")
|
||||
const [aiLoading, setAiLoading] = useState(false)
|
||||
const [aiResults, setAiResults] = useState<string[]>([])
|
||||
|
||||
/* AI 生成标题 */
|
||||
const handleAIGenerate = useCallback(() => {
|
||||
if (!aiKeyword.trim()) {
|
||||
message.warning("请输入关键词或主题")
|
||||
return
|
||||
}
|
||||
setAiLoading(true)
|
||||
setAiResults([])
|
||||
|
||||
setTimeout(() => {
|
||||
const results = generateMockTitles(aiKeyword.trim())
|
||||
setAiResults(results)
|
||||
setAiLoading(false)
|
||||
}, AI_GENERATE_DELAY)
|
||||
}, [aiKeyword])
|
||||
|
||||
/* 采纳 AI 生成的标题 */
|
||||
const handleAdoptAITitle = useCallback(
|
||||
(text: string) => {
|
||||
createMutation.mutate(text, {
|
||||
onSuccess: () => {
|
||||
message.success("标题已采纳并添加到标题库")
|
||||
},
|
||||
})
|
||||
},
|
||||
[createMutation],
|
||||
)
|
||||
|
||||
/* 复制 AI 生成的标题 */
|
||||
const handleCopyAI = useCallback(async (text: string) => {
|
||||
const ok = await copyToClipboard(text)
|
||||
if (ok) {
|
||||
message.success("已复制到剪贴板")
|
||||
} else {
|
||||
message.error("复制失败")
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* 关闭 AI 弹窗 */
|
||||
const handleCloseAIModal = useCallback(() => {
|
||||
setAiModalOpen(false)
|
||||
setAiLoading(false)
|
||||
setAiResults([])
|
||||
setAiKeyword("")
|
||||
}, [])
|
||||
|
||||
return {
|
||||
aiModalOpen,
|
||||
setAiModalOpen,
|
||||
aiKeyword,
|
||||
setAiKeyword,
|
||||
aiLoading,
|
||||
aiResults,
|
||||
handleAIGenerate,
|
||||
handleAdoptAITitle,
|
||||
handleCopyAI,
|
||||
handleCloseAIModal,
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import type { TitleData, TitleType } from "../types/titleLibrary"
|
||||
import type { UseMutationResult } from "@tanstack/react-query"
|
||||
import type { TitleItem } from "@/api/titles"
|
||||
|
||||
interface UseTitleEditProps {
|
||||
updateMutation: UseMutationResult<TitleItem, Error, { id: string; content: string }, unknown>
|
||||
createMutation: UseMutationResult<TitleItem, Error, string, unknown>
|
||||
}
|
||||
|
||||
export const useTitleEdit = ({ updateMutation, createMutation }: UseTitleEditProps) => {
|
||||
/* 编辑状态 */
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editText, setEditText] = useState("")
|
||||
|
||||
/* 新建标题弹窗 */
|
||||
const [createTitleModalOpen, setCreateTitleModalOpen] = useState(false)
|
||||
const [newTitleContent, setNewTitleContent] = useState("")
|
||||
const [newTitleType, setNewTitleType] = useState<TitleType>("normal")
|
||||
|
||||
/* 开始编辑 */
|
||||
const handleStartEdit = useCallback((title: TitleData) => {
|
||||
setEditingId(title.id)
|
||||
setEditText(title.content)
|
||||
}, [])
|
||||
|
||||
/* 保存编辑 */
|
||||
const handleSaveEdit = useCallback(() => {
|
||||
if (!editText.trim()) {
|
||||
message.warning("标题内容不能为空")
|
||||
return
|
||||
}
|
||||
if (editingId) {
|
||||
updateMutation.mutate({ id: editingId, content: editText.trim() })
|
||||
}
|
||||
setEditingId(null)
|
||||
setEditText("")
|
||||
message.success("标题已更新")
|
||||
}, [editingId, editText, updateMutation])
|
||||
|
||||
/* 取消编辑 */
|
||||
const handleCancelEdit = useCallback(() => {
|
||||
setEditingId(null)
|
||||
setEditText("")
|
||||
}, [])
|
||||
|
||||
/* 新建标题提交 */
|
||||
const handleCreateTitle = useCallback(() => {
|
||||
if (!newTitleContent.trim()) {
|
||||
message.warning("请输入标题内容")
|
||||
return
|
||||
}
|
||||
createMutation.mutate(newTitleContent.trim(), {
|
||||
onSuccess: () => {
|
||||
setCreateTitleModalOpen(false)
|
||||
setNewTitleContent("")
|
||||
setNewTitleType("normal")
|
||||
message.success("标题创建成功")
|
||||
},
|
||||
})
|
||||
}, [newTitleContent, newTitleType, createMutation])
|
||||
|
||||
/* 关闭新建弹窗 */
|
||||
const handleCloseCreateModal = useCallback(() => {
|
||||
setCreateTitleModalOpen(false)
|
||||
setNewTitleContent("")
|
||||
setNewTitleType("normal")
|
||||
}, [])
|
||||
|
||||
return {
|
||||
editingId,
|
||||
editText,
|
||||
setEditText,
|
||||
createTitleModalOpen,
|
||||
setCreateTitleModalOpen,
|
||||
newTitleContent,
|
||||
setNewTitleContent,
|
||||
newTitleType,
|
||||
setNewTitleType,
|
||||
handleStartEdit,
|
||||
handleSaveEdit,
|
||||
handleCancelEdit,
|
||||
handleCreateTitle,
|
||||
handleCloseCreateModal,
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
import { useMemo, useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { getTitles, createTitle, updateTitle, deleteTitle } from "@/api/titles"
|
||||
import type { TitleData, CategoryItem, Frequency } from "../types/titleLibrary"
|
||||
import { toTitleData, copyToClipboard } from "../utils/titleLibrary"
|
||||
import { ALL_CATEGORY_ID, FREQUENCY_THRESHOLDS } from "../constants/titleLibrary"
|
||||
|
||||
export const useTitleLibrary = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
/* 分类 */
|
||||
const [activeCatId, setActiveCatId] = useState<string>(ALL_CATEGORY_ID)
|
||||
|
||||
/* 数据获取 */
|
||||
const { data: apiTitles = [] } = useQuery({
|
||||
queryKey: ["titles"],
|
||||
queryFn: getTitles,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const titles: TitleData[] = useMemo(() => apiTitles.map(toTitleData), [apiTitles])
|
||||
|
||||
/* 动态派生分类 */
|
||||
const categories: CategoryItem[] = useMemo(() => {
|
||||
const cats = new Map<string, number>()
|
||||
apiTitles.forEach((t) => {
|
||||
const cat = t.category || "未分类"
|
||||
cats.set(cat, (cats.get(cat) || 0) + 1)
|
||||
})
|
||||
return [
|
||||
{ id: ALL_CATEGORY_ID, name: "全部标题", count: apiTitles.length },
|
||||
...Array.from(cats.entries()).map(([name, count]) => ({
|
||||
id: `cat-${name}`,
|
||||
name,
|
||||
count,
|
||||
})),
|
||||
]
|
||||
}, [apiTitles])
|
||||
|
||||
/* CRUD mutations */
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (content: string) => createTitle({ content }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["titles"] })
|
||||
},
|
||||
onError: () => message.error("创建标题失败"),
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, content }: { id: string; content: string }) => updateTitle(id, { content }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["titles"] })
|
||||
},
|
||||
onError: () => message.error("更新标题失败"),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteTitle(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["titles"] })
|
||||
},
|
||||
onError: () => message.error("删除标题失败"),
|
||||
})
|
||||
|
||||
/* 筛选状态 */
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [filterType, setFilterType] = useState<string>("all")
|
||||
const [filterIndustry, setFilterIndustry] = useState<string>("all")
|
||||
const [filterFrequency, setFilterFrequency] = useState<Frequency>("all")
|
||||
|
||||
/* 派生:筛选后的标题列表 */
|
||||
const activeCategory = categories.find((c) => c.id === activeCatId)
|
||||
|
||||
const filteredTitles = useMemo(() => {
|
||||
let list = titles
|
||||
|
||||
/* 按分类过滤 */
|
||||
if (activeCatId !== ALL_CATEGORY_ID) {
|
||||
const catName = activeCategory?.name || ""
|
||||
if (catName) {
|
||||
list = list.filter((t) => t.category === catName)
|
||||
}
|
||||
}
|
||||
|
||||
/* 按类型筛选 */
|
||||
if (filterType !== "all") {
|
||||
list = list.filter((t) => t.type === filterType)
|
||||
}
|
||||
|
||||
/* 按行业筛选 */
|
||||
if (filterIndustry !== "all") {
|
||||
list = list.filter((t) => t.industry === filterIndustry)
|
||||
}
|
||||
|
||||
/* 按使用频率筛选 */
|
||||
if (filterFrequency !== "all") {
|
||||
switch (filterFrequency) {
|
||||
case "high":
|
||||
list = list.filter((t) => t.usageCount >= FREQUENCY_THRESHOLDS.high)
|
||||
break
|
||||
case "medium":
|
||||
list = list.filter(
|
||||
(t) =>
|
||||
t.usageCount >= FREQUENCY_THRESHOLDS.medium &&
|
||||
t.usageCount < FREQUENCY_THRESHOLDS.high,
|
||||
)
|
||||
break
|
||||
case "low":
|
||||
list = list.filter((t) => t.usageCount < FREQUENCY_THRESHOLDS.medium)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/* 搜索 */
|
||||
if (searchText.trim()) {
|
||||
const q = searchText.trim().toLowerCase()
|
||||
list = list.filter((t) => t.content.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
return list
|
||||
}, [titles, activeCatId, activeCategory, filterType, filterIndustry, filterFrequency, searchText])
|
||||
|
||||
/* 操作:收藏 */
|
||||
const handleToggleFavorite = useCallback((_id: string) => {
|
||||
message.info("收藏功能即将上线")
|
||||
}, [])
|
||||
|
||||
/* 操作:复制 */
|
||||
const handleCopy = useCallback(async (title: TitleData) => {
|
||||
const ok = await copyToClipboard(title.content)
|
||||
if (ok) {
|
||||
message.success("已复制到剪贴板")
|
||||
} else {
|
||||
message.error("复制失败")
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* 操作:删除 */
|
||||
const handleDelete = useCallback(
|
||||
(id: string) => {
|
||||
deleteMutation.mutate(id)
|
||||
message.success("标题已删除")
|
||||
},
|
||||
[deleteMutation],
|
||||
)
|
||||
|
||||
return {
|
||||
/* 状态 */
|
||||
titles,
|
||||
categories,
|
||||
activeCatId,
|
||||
activeCategory,
|
||||
filteredTitles,
|
||||
searchText,
|
||||
filterType,
|
||||
filterIndustry,
|
||||
filterFrequency,
|
||||
/* mutations */
|
||||
createMutation,
|
||||
updateMutation,
|
||||
deleteMutation,
|
||||
/* setters */
|
||||
setActiveCatId,
|
||||
setSearchText,
|
||||
setFilterType,
|
||||
setFilterIndustry,
|
||||
setFilterFrequency,
|
||||
/* handlers */
|
||||
handleToggleFavorite,
|
||||
handleCopy,
|
||||
handleDelete,
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
export type TitleType = "hot" | "normal" | "creative"
|
||||
export type Industry = "general" | "food" | "tech" | "beauty" | "education" | "travel"
|
||||
export type Frequency = "all" | "high" | "medium" | "low"
|
||||
|
||||
export interface TitleData {
|
||||
id: string
|
||||
content: string
|
||||
type: TitleType
|
||||
industry: Industry
|
||||
category: string
|
||||
usageCount: number
|
||||
isFavorited: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface CategoryItem {
|
||||
id: string
|
||||
name: string
|
||||
count: number
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { TitleData, TitleType } from "../types/titleLibrary"
|
||||
import type { TitleItem } from "@/api/titles"
|
||||
|
||||
export const typeLabel = (type: TitleType): string => {
|
||||
switch (type) {
|
||||
case "hot":
|
||||
return "爆款"
|
||||
case "normal":
|
||||
return "常规"
|
||||
case "creative":
|
||||
return "创意"
|
||||
}
|
||||
}
|
||||
|
||||
/** 后端 TitleItem → 前端 TitleData 映射 */
|
||||
export const toTitleData = (item: TitleItem): TitleData => ({
|
||||
id: item.id,
|
||||
content: item.content,
|
||||
type: (item.category as TitleType) || "normal",
|
||||
industry: "general",
|
||||
category: item.category || "未分类",
|
||||
usageCount: 0,
|
||||
isFavorited: false,
|
||||
createdAt: item.created_at?.slice(0, 10) || "",
|
||||
})
|
||||
|
||||
/** 复制文本到剪贴板 */
|
||||
export const copyToClipboard = async (text: string): Promise<boolean> => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch {
|
||||
/* 降级方案 */
|
||||
const textarea = document.createElement("textarea")
|
||||
textarea.value = text
|
||||
textarea.style.position = "fixed"
|
||||
textarea.style.opacity = "0"
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
try {
|
||||
document.execCommand("copy")
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,18 +65,6 @@ vi.mock("@/store/authStore", () => ({
|
||||
vi.mock("@/pages/titles/titles.css", () => ({}))
|
||||
|
||||
import TitleLibrary from "@/pages/titles/TitleLibrary"
|
||||
import "@/pages/titles/types/titleLibrary"
|
||||
import "@/pages/titles/constants/titleLibrary"
|
||||
import "@/pages/titles/utils/titleLibrary"
|
||||
import "@/pages/titles/hooks/useTitleLibrary"
|
||||
import "@/pages/titles/hooks/useTitleEdit"
|
||||
import "@/pages/titles/hooks/useTitleAI"
|
||||
import "@/pages/titles/components/title-library/TitleCard"
|
||||
import "@/pages/titles/components/title-library/CategorySidebar"
|
||||
import "@/pages/titles/components/title-library/FilterBar"
|
||||
import "@/pages/titles/components/title-library/TitleGrid"
|
||||
import "@/pages/titles/components/title-library/CreateTitleModal"
|
||||
import "@/pages/titles/components/title-library/AIGenerateModal"
|
||||
|
||||
describe("TitleLibrary Page", () => {
|
||||
it("should render without crashing", () => {
|
||||
|
||||
Reference in New Issue
Block a user