knowledge-tree.tsx 65 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696
  1. import { useCallback, useEffect, useMemo, useRef, useState } from "react"
  2. import { BookOpen, ChevronDown, ChevronRight, FileText, Folder, FolderInput, FolderOpen, Globe, Loader2, Pencil, Plus, Sparkles, Trash2, Check, X } from "lucide-react"
  3. import { useTranslation } from "react-i18next"
  4. import { ScrollArea } from "@/components/ui/scroll-area"
  5. import { Button } from "@/components/ui/button"
  6. import { useWikiStore } from "@/stores/wiki-store"
  7. import { deleteFile, fileExists, listDirectory, readFile, writeFile, openFileLocation, copyFile } from "@/commands/fs"
  8. import type { FileNode } from "@/types/wiki"
  9. import { buildChapterWordCountLabel, getChapterStatusLabel } from "@/lib/chapter-display"
  10. import { normalizePath } from "@/lib/path-utils"
  11. import { countChapterBodyWords } from "@/lib/chapter-word-count"
  12. import { normalizeChapterStatus, type ChapterStatus } from "@/lib/novel/chapter-meta"
  13. import { moveFileToTrash } from "@/lib/trash"
  14. import { makeChapterFileName, makeDefaultChapterTitle, makeSafeFileSlug } from "@/lib/wiki-filename"
  15. import { useImportProgressStore, type ImportProgressTask } from "@/stores/import-progress-store"
  16. import { useOutlineGenerationStore } from "@/stores/outline-generation-store"
  17. import { startOutlineIngestTask } from "@/lib/novel/outline-generation"
  18. import { getOutlineFileName, outlineSnapshotExists } from "@/lib/novel/outline-ingest-utils"
  19. import { saveLastReadChapter } from "@/lib/project-store"
  20. function formatImportProgressRunningLabel(task: ImportProgressTask, kindLabel: string): string {
  21. if (task.cancelling) return `正在取消${kindLabel}提取...`
  22. const activeTitles = task.activeTitles?.filter(Boolean) ?? []
  23. if (activeTitles.length > 1) {
  24. const preview = activeTitles.slice(0, 2).join("、")
  25. return activeTitles.length > 2 ? `${preview} 等${activeTitles.length}个` : preview
  26. }
  27. if (activeTitles.length === 1) return activeTitles[0]!
  28. return task.currentTitle || `${kindLabel}提取中`
  29. }
  30. function formatImportProgressDetail(task: ImportProgressTask): string {
  31. const base = `${task.completed}/${task.total}`
  32. const activeCount = task.activeTitles?.length ?? 0
  33. if ((task.concurrency ?? 1) > 1 && activeCount > 1) {
  34. return `${base} · ${activeCount} 路并行`
  35. }
  36. return base
  37. }
  38. interface WikiPageInfo {
  39. path: string
  40. title: string
  41. type: "chapter" | "outline"
  42. chapterNumber?: number
  43. tags: string[]
  44. origin?: string
  45. status?: ChapterStatus
  46. statusLabel?: string
  47. wordCount?: number
  48. wordCountLabel?: string
  49. }
  50. export interface KnowledgeCreateRequest {
  51. kind: "chapter" | "outline" | "volume" | "folder"
  52. parentDir?: string
  53. }
  54. interface KnowledgeTreeProps {
  55. filterType: "chapter" | "outline"
  56. refreshKey?: number
  57. pendingPages?: WikiPageInfo[]
  58. onRemovePendingPage?: (pagePath: string) => void
  59. onRequestCreate?: (request: KnowledgeCreateRequest) => void
  60. }
  61. interface CreateMenuState {
  62. x: number
  63. y: number
  64. parentDir?: string
  65. targetFolderName?: string
  66. targetFolderPath?: string
  67. }
  68. function parseChineseNumber(input: string): number | null {
  69. const digitMap: Record<string, number> = {
  70. 零: 0,
  71. 一: 1,
  72. 二: 2,
  73. 两: 2,
  74. 三: 3,
  75. 四: 4,
  76. 五: 5,
  77. 六: 6,
  78. 七: 7,
  79. 八: 8,
  80. 九: 9,
  81. }
  82. if (/^\d+$/.test(input)) return Number.parseInt(input, 10)
  83. if (input === "十") return 10
  84. const tenIndex = input.indexOf("十")
  85. if (tenIndex >= 0) {
  86. const left = input.slice(0, tenIndex)
  87. const right = input.slice(tenIndex + 1)
  88. const tens = left ? (digitMap[left] ?? 0) : 1
  89. const units = right ? (digitMap[right] ?? 0) : 0
  90. return tens * 10 + units
  91. }
  92. let value = 0
  93. for (const ch of input) {
  94. const digit = digitMap[ch]
  95. if (digit === undefined) return null
  96. value = value * 10 + digit
  97. }
  98. return value
  99. }
  100. function extractPageOrderFromTitle(title: string): number | null {
  101. const titleMatch = title.match(/第\s*([0-9零一二两三四五六七八九十]+)\s*[章节卷]/)
  102. if (titleMatch?.[1]) {
  103. const value = parseChineseNumber(titleMatch[1])
  104. if (value !== null) return value
  105. }
  106. const numberMatch = title.match(/(\d+)/)
  107. if (numberMatch?.[1]) return Number.parseInt(numberMatch[1], 10)
  108. return null
  109. }
  110. function extractChapterNumberFromContent(content: string): number | null {
  111. const match = content.match(/^chapter_number:\s*(\d+)\s*$/m)
  112. if (!match?.[1]) return null
  113. const parsed = Number.parseInt(match[1], 10)
  114. return Number.isFinite(parsed) && parsed > 0 ? parsed : null
  115. }
  116. function getDirName(path: string): string {
  117. const normalized = normalizePath(path)
  118. const index = normalized.lastIndexOf("/")
  119. return index >= 0 ? normalized.slice(0, index) : ""
  120. }
  121. async function getUniquePagePath(dir: string, fileName: string, excludePath?: string): Promise<string> {
  122. const firstPath = `${dir}/${fileName}`
  123. if (firstPath === excludePath || !(await fileExists(firstPath))) return firstPath
  124. const extensionIndex = fileName.lastIndexOf(".")
  125. const stem = extensionIndex > 0 ? fileName.slice(0, extensionIndex) : fileName
  126. const extension = extensionIndex > 0 ? fileName.slice(extensionIndex) : ""
  127. for (let index = 2; index <= 99; index += 1) {
  128. const candidate = `${dir}/${stem}-${index}${extension}`
  129. if (candidate === excludePath || !(await fileExists(candidate))) return candidate
  130. }
  131. return `${dir}/${stem}-${Date.now()}${extension}`
  132. }
  133. function flattenMdFiles(nodes: FileNode[]): FileNode[] {
  134. const files: FileNode[] = []
  135. for (const node of nodes) {
  136. if (node.is_dir && node.children) {
  137. files.push(...flattenMdFiles(node.children))
  138. continue
  139. }
  140. if (!node.is_dir && node.name.endsWith(".md")) {
  141. files.push(node)
  142. }
  143. }
  144. return files
  145. }
  146. function flattenAllFiles(nodes: FileNode[]): FileNode[] {
  147. const files: FileNode[] = []
  148. for (const node of nodes) {
  149. if (node.is_dir && node.children) {
  150. files.push(...flattenAllFiles(node.children))
  151. continue
  152. }
  153. if (!node.is_dir) files.push(node)
  154. }
  155. return files
  156. }
  157. async function cleanupDeletedSourceMemory(
  158. projectPath: string,
  159. input: { kind: "chapter" | "outline"; pagePath: string; content?: string },
  160. ): Promise<void> {
  161. const { deleteNovelSourceMemory } = await import("@/lib/novel/delete-source-memory")
  162. await deleteNovelSourceMemory(projectPath, input)
  163. }
  164. function parsePageInfo(path: string, fileName: string, content: string): WikiPageInfo | null {
  165. const normalizedPath = normalizePath(path)
  166. const type = normalizedPath.includes("/wiki/chapters/") ? "chapter" : normalizedPath.includes("/wiki/outlines/") ? "outline" : null
  167. if (!type) return null
  168. let title = fileName.replace(/\.md$/, "").replace(/-/g, " ")
  169. const tags: string[] = []
  170. let origin: string | undefined
  171. let status: ChapterStatus | undefined
  172. const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/)
  173. if (frontmatterMatch) {
  174. const frontmatter = frontmatterMatch[1]
  175. const titleMatch = frontmatter.match(/^title:\s*["']?(.+?)["']?\s*$/m)
  176. if (titleMatch?.[1]) title = titleMatch[1].trim()
  177. const tagsMatch = frontmatter.match(/^tags:\s*\[(.+?)\]/m)
  178. if (tagsMatch?.[1]) {
  179. tags.push(...tagsMatch[1].split(",").map((value) => value.trim().replace(/["']/g, "")))
  180. }
  181. const originMatch = frontmatter.match(/^origin:\s*(.+)$/m)
  182. if (originMatch?.[1]) origin = originMatch[1].trim()
  183. if (type === "chapter") {
  184. const statusMatch = frontmatter.match(/^chapter_status:\s*["']?(.+?)["']?\s*$/m)
  185. status = normalizeChapterStatus(statusMatch?.[1])
  186. }
  187. }
  188. if (title === fileName.replace(/\.md$/, "").replace(/-/g, " ")) {
  189. const headingMatch = content.match(/^#\s+(.+)$/m)
  190. if (headingMatch?.[1]) title = headingMatch[1].trim()
  191. }
  192. const wordCount = type === "chapter" ? countChapterBodyWords(content) : undefined
  193. const chapterNumber = type === "chapter"
  194. ? (extractChapterNumberFromContent(content) ?? extractPageOrderFromTitle(title) ?? undefined)
  195. : undefined
  196. return {
  197. path: normalizedPath,
  198. title,
  199. type,
  200. chapterNumber,
  201. tags,
  202. origin,
  203. status,
  204. statusLabel: status ? getChapterStatusLabel(status) : undefined,
  205. wordCount,
  206. wordCountLabel: typeof wordCount === "number" ? buildChapterWordCountLabel(wordCount) : undefined,
  207. }
  208. }
  209. function findNodeByPath(nodes: FileNode[], targetPath: string): FileNode | null {
  210. for (const node of nodes) {
  211. if (normalizePath(node.path) === targetPath) return node
  212. if (node.is_dir && node.children) {
  213. const childMatch = findNodeByPath(node.children, targetPath)
  214. if (childMatch) return childMatch
  215. }
  216. }
  217. return null
  218. }
  219. function sortFileNodes(
  220. nodes: readonly FileNode[],
  221. pageInfoByPath: Map<string, WikiPageInfo>,
  222. filterType: "chapter" | "outline",
  223. ): FileNode[] {
  224. return [...nodes].sort((left, right) => {
  225. if (left.is_dir && !right.is_dir) return -1
  226. if (!left.is_dir && right.is_dir) return 1
  227. if (left.is_dir && right.is_dir) return left.name.localeCompare(right.name, "zh-CN")
  228. const leftInfo = pageInfoByPath.get(normalizePath(left.path))
  229. const rightInfo = pageInfoByPath.get(normalizePath(right.path))
  230. if (filterType === "chapter" || filterType === "outline") {
  231. const leftOrder = leftInfo?.chapterNumber ?? extractPageOrderFromTitle(leftInfo?.title ?? left.name)
  232. const rightOrder = rightInfo?.chapterNumber ?? extractPageOrderFromTitle(rightInfo?.title ?? right.name)
  233. if (leftOrder !== null && rightOrder !== null && leftOrder !== rightOrder) {
  234. return leftOrder - rightOrder
  235. }
  236. if (leftOrder !== null && rightOrder === null) return -1
  237. if (leftOrder === null && rightOrder !== null) return 1
  238. }
  239. return (leftInfo?.title ?? left.name).localeCompare(rightInfo?.title ?? right.name, "zh-CN")
  240. })
  241. }
  242. function countMarkdownDescendants(node: FileNode): number {
  243. if (!node.is_dir) return node.name.endsWith(".md") ? 1 : 0
  244. return (node.children ?? []).reduce((total, child) => total + countMarkdownDescendants(child), 0)
  245. }
  246. export function KnowledgeTree({
  247. filterType,
  248. refreshKey,
  249. pendingPages = [],
  250. onRemovePendingPage,
  251. onRequestCreate,
  252. }: KnowledgeTreeProps) {
  253. const { t } = useTranslation()
  254. const project = useWikiStore((s) => s.project)
  255. const novelMode = useWikiStore((s) => s.novelMode)
  256. const selectedFile = useWikiStore((s) => s.selectedFile)
  257. const setSelectedFile = useWikiStore((s) => s.setSelectedFile)
  258. const fileTree = useWikiStore((s) => s.fileTree)
  259. const setFileTree = useWikiStore((s) => s.setFileTree)
  260. const bumpDataVersion = useWikiStore((s) => s.bumpDataVersion)
  261. const dataVersion = useWikiStore((s) => s.dataVersion)
  262. const outlineTasks = useOutlineGenerationStore((s) => s.tasks)
  263. const outlineImportTasks = useImportProgressStore((s) => s.tasks)
  264. const [pages, setPages] = useState<WikiPageInfo[]>([])
  265. const [extractedOutlinePaths, setExtractedOutlinePaths] = useState<Set<string>>(() => new Set())
  266. const [collapsedFolders, setCollapsedFolders] = useState<Record<string, boolean>>({})
  267. const [armedPath, setArmedPath] = useState<string | null>(null)
  268. const [deletingPath, setDeletingPath] = useState<string | null>(null)
  269. const [createMenu, setCreateMenu] = useState<CreateMenuState | null>(null)
  270. const [pageMenu, setPageMenu] = useState<{ path: string; x: number; y: number } | null>(null)
  271. const [renamingPath, setRenamingPath] = useState<string | null>(null)
  272. const [renameValue, setRenameValue] = useState("")
  273. const [renamingBusy, setRenamingBusy] = useState(false)
  274. const [moveMenuTarget, setMoveMenuTarget] = useState<string | null>(null)
  275. const [chapterBatchMode, setChapterBatchMode] = useState(false)
  276. const [selectedChapterPaths, setSelectedChapterPaths] = useState<Set<string>>(() => new Set())
  277. const [dragSource, setDragSource] = useState<string | null>(null)
  278. const [dragInsertIndex, setDragInsertIndex] = useState<number | null>(null)
  279. const [isDragging, setIsDragging] = useState(false)
  280. const dragSourceRef = useRef<string | null>(null)
  281. const dragInsertIndexRef = useRef<number | null>(null)
  282. const dragTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
  283. const activePointerIdRef = useRef<number | null>(null)
  284. const pendingPointerPositionRef = useRef<{ x: number; y: number } | null>(null)
  285. const lastPointerTypeRef = useRef<string>("mouse")
  286. const removeGlobalPointerListenersRef = useRef<(() => void) | null>(null)
  287. const containerRef = useRef<HTMLDivElement>(null)
  288. useEffect(() => { dragSourceRef.current = dragSource }, [dragSource])
  289. useEffect(() => { dragInsertIndexRef.current = dragInsertIndex }, [dragInsertIndex])
  290. const loadPages = useCallback(async () => {
  291. if (!project) return
  292. const projectPath = normalizePath(project.path)
  293. try {
  294. const wikiTree = await listDirectory(`${projectPath}/wiki`)
  295. const nextPages: WikiPageInfo[] = []
  296. for (const file of flattenMdFiles(wikiTree)) {
  297. if (file.name === "index.md" || file.name === "log.md") continue
  298. try {
  299. const content = await readFile(file.path)
  300. const info = parsePageInfo(file.path, file.name, content)
  301. if (info) nextPages.push(info)
  302. } catch {
  303. // Ignore unreadable pages in the navigator.
  304. }
  305. }
  306. setPages(nextPages)
  307. } catch (error) {
  308. console.error("[KnowledgeTree] loadPages failed:", error)
  309. setPages([])
  310. }
  311. }, [project])
  312. useEffect(() => {
  313. void loadPages()
  314. }, [loadPages, fileTree, dataVersion, refreshKey])
  315. useEffect(() => {
  316. if (!armedPath) return
  317. const handlePointerDown = (event: MouseEvent) => {
  318. const target = event.target
  319. if (!(target instanceof Node)) return
  320. if (containerRef.current?.contains(target)) return
  321. setArmedPath(null)
  322. }
  323. const handleEscape = (event: KeyboardEvent) => {
  324. if (event.key === "Escape") setArmedPath(null)
  325. }
  326. document.addEventListener("mousedown", handlePointerDown)
  327. document.addEventListener("keydown", handleEscape)
  328. return () => {
  329. document.removeEventListener("mousedown", handlePointerDown)
  330. document.removeEventListener("keydown", handleEscape)
  331. }
  332. }, [armedPath])
  333. useEffect(() => {
  334. const closeMenus = () => {
  335. setCreateMenu(null)
  336. setPageMenu(null)
  337. }
  338. document.addEventListener("mousedown", closeMenus)
  339. document.addEventListener("keydown", closeMenus)
  340. return () => {
  341. document.removeEventListener("mousedown", closeMenus)
  342. document.removeEventListener("keydown", closeMenus)
  343. }
  344. }, [])
  345. const pageInfoByPath = useMemo(() => {
  346. const map = new Map<string, WikiPageInfo>()
  347. for (const page of pages) {
  348. if (page.type === filterType) map.set(page.path, page)
  349. }
  350. for (const page of pendingPages) {
  351. if (page.type === filterType) map.set(normalizePath(page.path), page)
  352. }
  353. return map
  354. }, [filterType, pages, pendingPages])
  355. const effectivePages = useMemo(() => [...pageInfoByPath.values()], [pageInfoByPath])
  356. const outlinePages = useMemo(
  357. () => effectivePages.filter((page): page is WikiPageInfo & { type: "outline" } => page.type === "outline"),
  358. [effectivePages],
  359. )
  360. useEffect(() => {
  361. if (!project || filterType !== "outline" || !novelMode) {
  362. setExtractedOutlinePaths(new Set())
  363. return
  364. }
  365. let cancelled = false
  366. void (async () => {
  367. const extracted = new Set<string>()
  368. await Promise.all(outlinePages.map(async (page) => {
  369. if (await outlineSnapshotExists(project.path, page.path)) {
  370. extracted.add(normalizePath(page.path))
  371. }
  372. }))
  373. if (!cancelled) setExtractedOutlinePaths(extracted)
  374. })()
  375. return () => {
  376. cancelled = true
  377. }
  378. }, [filterType, novelMode, outlinePages, project, dataVersion, outlineTasks, outlineImportTasks])
  379. const isOutlinePathIngesting = useCallback((outlinePath: string) => {
  380. if (!project) return false
  381. const normalizedPath = normalizePath(outlinePath)
  382. const fileName = getOutlineFileName(normalizedPath)
  383. const pp = normalizePath(project.path)
  384. const importRunning = outlineImportTasks.find((task) => (
  385. task.projectPath === pp &&
  386. task.kind === "outline" &&
  387. task.status === "running"
  388. ))
  389. if (importRunning) {
  390. if (importRunning.total === 1 && importRunning.currentTitle === fileName) return true
  391. if (importRunning.activeTitles?.includes(fileName)) return true
  392. }
  393. return outlineTasks.some((task) => (
  394. task.projectPath === pp &&
  395. task.kind === "ingest" &&
  396. task.outlinePath != null &&
  397. normalizePath(task.outlinePath) === normalizedPath &&
  398. task.status === "ingesting"
  399. ))
  400. }, [outlineImportTasks, outlineTasks, project])
  401. const handleOutlineIngest = useCallback((outlinePath: string) => {
  402. if (!project || !novelMode || isOutlinePathIngesting(outlinePath)) return
  403. startOutlineIngestTask(project.path, outlinePath)
  404. }, [isOutlinePathIngesting, novelMode, project])
  405. const sortedChapterPages = useMemo(() => {
  406. return effectivePages
  407. .filter((page): page is WikiPageInfo & { type: "chapter" } => page.type === "chapter")
  408. .sort((left, right) => {
  409. const leftOrder = left.chapterNumber ?? extractPageOrderFromTitle(left.title)
  410. const rightOrder = right.chapterNumber ?? extractPageOrderFromTitle(right.title)
  411. if (leftOrder !== null && rightOrder !== null && leftOrder !== rightOrder) return leftOrder - rightOrder
  412. if (leftOrder !== null && rightOrder === null) return -1
  413. if (leftOrder === null && rightOrder !== null) return 1
  414. return left.title.localeCompare(right.title, "zh-CN")
  415. })
  416. }, [effectivePages])
  417. const selectableChapterPaths = useMemo(() => sortedChapterPages.map((page) => page.path), [sortedChapterPages])
  418. const selectedChapterCount = selectedChapterPaths.size
  419. const allChaptersSelected = selectableChapterPaths.length > 0
  420. && selectableChapterPaths.every((path) => selectedChapterPaths.has(path))
  421. useEffect(() => {
  422. if (filterType !== "chapter") {
  423. setChapterBatchMode(false)
  424. setSelectedChapterPaths((previous) => previous.size > 0 ? new Set() : previous)
  425. return
  426. }
  427. const validPaths = new Set(selectableChapterPaths)
  428. setSelectedChapterPaths((previous) => {
  429. const next = new Set<string>()
  430. for (const path of previous) {
  431. if (validPaths.has(path)) next.add(path)
  432. }
  433. return next.size === previous.size ? previous : next
  434. })
  435. }, [filterType, selectableChapterPaths])
  436. const sectionRootPath = useMemo(() => {
  437. if (!project) return null
  438. return `${normalizePath(project.path)}/wiki/${filterType === "chapter" ? "chapters" : "outlines"}`
  439. }, [filterType, project])
  440. const sectionNodes = useMemo(() => {
  441. if (!sectionRootPath) return []
  442. const sectionNode = findNodeByPath(fileTree, sectionRootPath)
  443. return sectionNode?.children ?? []
  444. }, [fileTree, sectionRootPath])
  445. const volumeFolders = useMemo(() => {
  446. return sectionNodes.filter((node) => node.is_dir).map((node) => ({
  447. name: node.name,
  448. path: normalizePath(node.path),
  449. }))
  450. }, [sectionNodes])
  451. const handleMoveChaptersToVolume = useCallback(async (sourcePaths: string[], targetVolumePath: string) => {
  452. if (!project) return
  453. const normalizedTargetVolumePath = normalizePath(targetVolumePath)
  454. const normalizedSources = Array.from(new Set(sourcePaths.map((sourcePath) => normalizePath(sourcePath))))
  455. const plans: Array<{ source: string; dest: string }> = []
  456. const plannedDestinations = new Set<string>()
  457. for (const normalizedSource of normalizedSources) {
  458. if (getDirName(normalizedSource) === normalizedTargetVolumePath) continue
  459. const fileName = normalizedSource.split("/").pop()
  460. if (!fileName) continue
  461. const destPath = `${normalizedTargetVolumePath}/${fileName}`
  462. if (normalizedSource === destPath) continue
  463. if (plannedDestinations.has(destPath) || await fileExists(destPath)) {
  464. window.alert(t("knowledgeTree.moveTargetExists"))
  465. return
  466. }
  467. plannedDestinations.add(destPath)
  468. plans.push({ source: normalizedSource, dest: destPath })
  469. }
  470. if (plans.length === 0) {
  471. setMoveMenuTarget(null)
  472. setPageMenu(null)
  473. return
  474. }
  475. try {
  476. for (const plan of plans) {
  477. await copyFile(plan.source, plan.dest)
  478. await deleteFile(plan.source)
  479. }
  480. await loadPages()
  481. const tree = await listDirectory(normalizePath(project.path))
  482. setFileTree(tree)
  483. bumpDataVersion()
  484. setSelectedChapterPaths(new Set())
  485. setChapterBatchMode(false)
  486. const selectedMove = selectedFile ? plans.find((plan) => plan.source === normalizePath(selectedFile)) : undefined
  487. if (selectedMove) {
  488. setSelectedFile(selectedMove.dest)
  489. }
  490. } catch (error) {
  491. console.error("[KnowledgeTree] move to volume failed:", error)
  492. } finally {
  493. setMoveMenuTarget(null)
  494. setPageMenu(null)
  495. }
  496. }, [project, t, loadPages, setFileTree, bumpDataVersion, selectedFile, setSelectedFile])
  497. const toggleChapterSelection = useCallback((pagePath: string) => {
  498. const normalizedPath = normalizePath(pagePath)
  499. setSelectedChapterPaths((previous) => {
  500. const next = new Set(previous)
  501. if (next.has(normalizedPath)) next.delete(normalizedPath)
  502. else next.add(normalizedPath)
  503. return next
  504. })
  505. }, [])
  506. const handleToggleAllChapters = useCallback(() => {
  507. setSelectedChapterPaths((previous) => {
  508. if (selectableChapterPaths.length > 0 && selectableChapterPaths.every((path) => previous.has(path))) {
  509. return new Set()
  510. }
  511. return new Set(selectableChapterPaths)
  512. })
  513. }, [selectableChapterPaths])
  514. const exitChapterBatchMode = useCallback(() => {
  515. setChapterBatchMode(false)
  516. setSelectedChapterPaths(new Set())
  517. }, [])
  518. const handleDeleteClick = useCallback(async (pagePath: string) => {
  519. if (!project) return
  520. if (armedPath !== pagePath) {
  521. setArmedPath(pagePath)
  522. return
  523. }
  524. setArmedPath(null)
  525. setDeletingPath(pagePath)
  526. try {
  527. const projectPath = normalizePath(project.path)
  528. let sourceContent: string | undefined
  529. if (filterType === "chapter") {
  530. try {
  531. sourceContent = await readFile(pagePath)
  532. } catch { /* ignore */ }
  533. }
  534. await moveFileToTrash(projectPath, pagePath, filterType)
  535. try {
  536. await cleanupDeletedSourceMemory(projectPath, {
  537. kind: filterType,
  538. pagePath,
  539. content: sourceContent,
  540. })
  541. } catch (e) {
  542. // 记忆清理失败不影响文件删除本身
  543. console.warn("[KnowledgeTree] 清理关联记忆失败:", e)
  544. }
  545. await loadPages()
  546. onRemovePendingPage?.(pagePath)
  547. const tree = await listDirectory(projectPath)
  548. setFileTree(tree)
  549. bumpDataVersion()
  550. if (selectedFile === pagePath) setSelectedFile(null)
  551. } catch (error) {
  552. console.error("[KnowledgeTree] delete failed:", error)
  553. } finally {
  554. setDeletingPath(null)
  555. }
  556. }, [project, armedPath, filterType, loadPages, onRemovePendingPage, setFileTree, bumpDataVersion, selectedFile, setSelectedFile])
  557. const handleDeleteFolder = useCallback(async (folderPath: string) => {
  558. if (!project) return
  559. const normalizedFolderPath = normalizePath(folderPath)
  560. const folderNode = findNodeByPath(sectionNodes, normalizedFolderPath)
  561. if (!folderNode?.is_dir) return
  562. const mdFiles = flattenMdFiles([folderNode]).map((file) => normalizePath(file.path))
  563. const folderName = folderNode.name
  564. const fileKind = filterType === "chapter" ? "chapter" : "outline"
  565. const confirmed = window.confirm(
  566. mdFiles.length > 0
  567. ? t("knowledgeTree.deleteFolderConfirm", { name: folderName, count: mdFiles.length })
  568. : t("knowledgeTree.deleteEmptyFolderConfirm", { name: folderName }),
  569. )
  570. if (!confirmed) return
  571. setArmedPath(null)
  572. setDeletingPath(normalizedFolderPath)
  573. try {
  574. const projectPath = normalizePath(project.path)
  575. for (const filePath of mdFiles) {
  576. try {
  577. await moveFileToTrash(projectPath, filePath, fileKind)
  578. await cleanupDeletedSourceMemory(projectPath, {
  579. kind: fileKind,
  580. pagePath: filePath,
  581. })
  582. } catch (e) {
  583. // 单个文件删除失败不影响整体流程(如文件已不存在的幽灵条目)
  584. console.warn("[KnowledgeTree] 删除文件失败,继续处理下一个:", filePath, e)
  585. }
  586. onRemovePendingPage?.(filePath)
  587. }
  588. // 尝试删除文件夹:如果目录不存在或已空,直接删除;
  589. // 这样能清理掉那些文件已丢失、只剩空壳目录的幽灵条目
  590. try {
  591. const remainingNodes = await listDirectory(normalizedFolderPath).catch(() => [])
  592. if (flattenAllFiles(remainingNodes).length === 0) {
  593. await deleteFile(normalizedFolderPath)
  594. } else {
  595. window.alert(t("knowledgeTree.deleteFolderBlocked", { name: folderName }))
  596. }
  597. } catch {
  598. // 目录可能已经不存在了,直接视为删除成功
  599. }
  600. await loadPages()
  601. const tree = await listDirectory(projectPath)
  602. setFileTree(tree)
  603. bumpDataVersion()
  604. if (selectedFile?.startsWith(`${normalizedFolderPath}/`)) {
  605. setSelectedFile(null)
  606. }
  607. } catch (error) {
  608. console.error("[KnowledgeTree] folder delete failed:", error)
  609. } finally {
  610. setDeletingPath(null)
  611. }
  612. }, [project, filterType, sectionNodes, t, loadPages, setFileTree, bumpDataVersion, selectedFile, setSelectedFile, onRemovePendingPage])
  613. const updatePageTitleContent = useCallback((content: string, newTitle: string, newChapterNumber?: number | null) => {
  614. const escapedTitle = newTitle.replace(/"/g, '\\"')
  615. let next = content
  616. const frontmatterMatch = next.match(/^---\n([\s\S]*?)\n---/)
  617. if (frontmatterMatch) {
  618. let frontmatterBody = frontmatterMatch[1]
  619. if (/^title:\s*.*$/m.test(frontmatterBody)) {
  620. frontmatterBody = frontmatterBody.replace(/^title:\s*.*$/m, `title: "${escapedTitle}"`)
  621. } else {
  622. frontmatterBody = `${frontmatterBody}\ntitle: "${escapedTitle}"`
  623. }
  624. if (typeof newChapterNumber === "number" && newChapterNumber > 0) {
  625. if (/^chapter_number:\s*.*$/m.test(frontmatterBody)) {
  626. frontmatterBody = frontmatterBody.replace(/^chapter_number:\s*.*$/m, `chapter_number: ${newChapterNumber}`)
  627. } else {
  628. frontmatterBody = `${frontmatterBody}\nchapter_number: ${newChapterNumber}`
  629. }
  630. }
  631. next = next.replace(/^---\n[\s\S]*?\n---/, `---\n${frontmatterBody}\n---`)
  632. }
  633. if (/^#\s+.+$/m.test(next)) {
  634. next = next.replace(/^#\s+.+$/m, `# ${newTitle}`)
  635. }
  636. return next
  637. }, [])
  638. const updateChapterNumberContent = useCallback((content: string, newChapterNumber: number) => {
  639. if (!Number.isFinite(newChapterNumber) || newChapterNumber <= 0) return content
  640. const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/)
  641. if (!frontmatterMatch) {
  642. return `---\nchapter_number: ${newChapterNumber}\n---\n${content}`
  643. }
  644. let frontmatterBody = frontmatterMatch[1]
  645. if (/^chapter_number:\s*.*$/m.test(frontmatterBody)) {
  646. frontmatterBody = frontmatterBody.replace(/^chapter_number:\s*.*$/m, `chapter_number: ${newChapterNumber}`)
  647. } else {
  648. frontmatterBody = `${frontmatterBody}\nchapter_number: ${newChapterNumber}`
  649. }
  650. return content.replace(/^---\n[\s\S]*?\n---/, `---\n${frontmatterBody}\n---`)
  651. }, [])
  652. const startRenamePage = useCallback((page: WikiPageInfo) => {
  653. setPageMenu(null)
  654. setIsDragging(false)
  655. setDragSource(null)
  656. setDragInsertIndex(null)
  657. dragSourceRef.current = null
  658. dragInsertIndexRef.current = null
  659. if (dragTimerRef.current) {
  660. clearTimeout(dragTimerRef.current)
  661. dragTimerRef.current = null
  662. }
  663. removeGlobalPointerListenersRef.current?.()
  664. removeGlobalPointerListenersRef.current = null
  665. activePointerIdRef.current = null
  666. pendingPointerPositionRef.current = null
  667. setRenamingPath(page.path)
  668. setRenameValue(page.title)
  669. }, [])
  670. const submitRenamePage = useCallback(async () => {
  671. if (!renamingPath || renamingBusy) return
  672. const newTitle = renameValue.trim()
  673. if (!newTitle) {
  674. setRenamingPath(null)
  675. setRenameValue("")
  676. return
  677. }
  678. const current = effectivePages.find((page) => page.path === renamingPath)
  679. if (!current || current.title === newTitle) {
  680. setRenamingPath(null)
  681. setRenameValue("")
  682. return
  683. }
  684. setRenamingBusy(true)
  685. try {
  686. const duplicate = effectivePages.some((page) => page.path !== renamingPath && page.type === current.type && page.title.trim() === newTitle)
  687. if (duplicate) return
  688. const content = await readFile(renamingPath)
  689. const oldChapterNumber = extractChapterNumberFromContent(content) ?? extractPageOrderFromTitle(current.title)
  690. const userChapterNumber = extractPageOrderFromTitle(newTitle)
  691. const chapterNumber = (current.type === "chapter" && userChapterNumber !== null) ? userChapterNumber : oldChapterNumber
  692. const chapterNumberConflict = current.type === "chapter"
  693. && typeof chapterNumber === "number"
  694. && effectivePages.some((page) => (
  695. page.path !== renamingPath
  696. && page.type === "chapter"
  697. && page.chapterNumber === chapterNumber
  698. ))
  699. if (chapterNumberConflict) return
  700. const normalizedTitle = current.type === "chapter" && chapterNumber
  701. ? makeDefaultChapterTitle(chapterNumber, newTitle)
  702. : newTitle
  703. const nextContent = updatePageTitleContent(content, normalizedTitle, current.type === "chapter" ? chapterNumber : null)
  704. const targetFileName = current.type === "chapter"
  705. ? makeChapterFileName(normalizedTitle, chapterNumber)
  706. : `${makeSafeFileSlug(newTitle)}.md`
  707. const targetPath = await getUniquePagePath(getDirName(renamingPath), targetFileName, renamingPath)
  708. await writeFile(targetPath, nextContent)
  709. if (targetPath !== renamingPath) {
  710. await deleteFile(renamingPath)
  711. if (selectedFile === renamingPath) setSelectedFile(targetPath)
  712. onRemovePendingPage?.(renamingPath)
  713. }
  714. await loadPages()
  715. if (project) {
  716. const tree = await listDirectory(normalizePath(project.path))
  717. setFileTree(tree)
  718. }
  719. bumpDataVersion()
  720. } catch (error) {
  721. console.error("[KnowledgeTree] rename failed:", error)
  722. } finally {
  723. setRenamingBusy(false)
  724. setRenamingPath(null)
  725. setRenameValue("")
  726. }
  727. }, [renamingPath, renamingBusy, renameValue, effectivePages, updatePageTitleContent, selectedFile, setSelectedFile, onRemovePendingPage, loadPages, project, setFileTree, bumpDataVersion])
  728. const cancelRenamePage = useCallback(() => {
  729. if (renamingBusy) return
  730. setRenamingPath(null)
  731. setRenameValue("")
  732. }, [renamingBusy])
  733. const executeChapterReorder = useCallback(async (sourcePath: string, targetIndex: number) => {
  734. if (!project) return
  735. const sourceIndex = sortedChapterPages.findIndex((page) => page.path === sourcePath)
  736. if (sourceIndex < 0) return
  737. const effectiveTarget = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex
  738. if (sourceIndex === effectiveTarget) return
  739. const reordered = [...sortedChapterPages]
  740. const [moved] = reordered.splice(sourceIndex, 1)
  741. reordered.splice(effectiveTarget, 0, moved)
  742. try {
  743. const writes: { path: string; content: string; nextNumber: number }[] = []
  744. for (let i = 0; i < reordered.length; i += 1) {
  745. const page = reordered[i]
  746. const nextNumber = i + 1
  747. const currentNumber = page.chapterNumber ?? extractPageOrderFromTitle(page.title)
  748. if (currentNumber === nextNumber) continue
  749. const content = await readFile(page.path)
  750. writes.push({ path: page.path, content, nextNumber })
  751. }
  752. if (writes.length === 0) return
  753. const appliedWrites: { path: string; content: string }[] = []
  754. try {
  755. for (const item of writes) {
  756. await writeFile(item.path, updateChapterNumberContent(item.content, item.nextNumber))
  757. appliedWrites.push({ path: item.path, content: item.content })
  758. }
  759. } catch (error) {
  760. for (const item of appliedWrites.reverse()) {
  761. try {
  762. await writeFile(item.path, item.content)
  763. } catch {
  764. // Ignore rollback failures and preserve the original error.
  765. }
  766. }
  767. throw error
  768. }
  769. await loadPages()
  770. const tree = await listDirectory(normalizePath(project.path))
  771. setFileTree(tree)
  772. bumpDataVersion()
  773. } catch (error) {
  774. console.error("[KnowledgeTree] chapter reorder failed:", error)
  775. }
  776. }, [project, sortedChapterPages, updateChapterNumberContent, loadPages, setFileTree, bumpDataVersion])
  777. const finishDragInteraction = useCallback(() => {
  778. if (dragTimerRef.current) {
  779. clearTimeout(dragTimerRef.current)
  780. dragTimerRef.current = null
  781. }
  782. const sourcePath = dragSourceRef.current
  783. const targetIndex = dragInsertIndexRef.current
  784. removeGlobalPointerListenersRef.current?.()
  785. removeGlobalPointerListenersRef.current = null
  786. activePointerIdRef.current = null
  787. pendingPointerPositionRef.current = null
  788. dragSourceRef.current = null
  789. dragInsertIndexRef.current = null
  790. setIsDragging(false)
  791. setDragSource(null)
  792. setDragInsertIndex(null)
  793. if (sourcePath && targetIndex !== null) {
  794. void executeChapterReorder(sourcePath, targetIndex)
  795. }
  796. }, [executeChapterReorder])
  797. const updateDragInsertFromPoint = useCallback((clientX: number, clientY: number) => {
  798. const target = document.elementFromPoint(clientX, clientY)
  799. const row = target instanceof HTMLElement ? target.closest<HTMLElement>("[data-page-path]") : null
  800. if (!row) return
  801. const pagePath = row.dataset.pagePath
  802. if (!pagePath) return
  803. const rowIndex = sortedChapterPages.findIndex((page) => page.path === pagePath)
  804. if (rowIndex < 0) return
  805. const rect = row.getBoundingClientRect()
  806. const isBottomHalf = (clientY - rect.top) >= rect.height / 2
  807. const insertIndex = isBottomHalf ? rowIndex + 1 : rowIndex
  808. dragInsertIndexRef.current = insertIndex
  809. setDragInsertIndex(insertIndex)
  810. }, [sortedChapterPages])
  811. const handleContainerPointerMove = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
  812. if (activePointerIdRef.current !== event.pointerId) return
  813. pendingPointerPositionRef.current = { x: event.clientX, y: event.clientY }
  814. if (!dragSourceRef.current) return
  815. updateDragInsertFromPoint(event.clientX, event.clientY)
  816. }, [updateDragInsertFromPoint])
  817. const handleItemPointerDown = useCallback((event: React.PointerEvent, pagePath: string) => {
  818. if (filterType !== "chapter" || renamingPath) return
  819. lastPointerTypeRef.current = event.pointerType || "mouse"
  820. if (event.pointerType !== "mouse") {
  821. event.preventDefault()
  822. return
  823. }
  824. if (event.button !== 0 || selectedFile !== pagePath || isDragging) return
  825. event.preventDefault()
  826. dragTimerRef.current && clearTimeout(dragTimerRef.current)
  827. activePointerIdRef.current = event.pointerId
  828. pendingPointerPositionRef.current = { x: event.clientX, y: event.clientY }
  829. removeGlobalPointerListenersRef.current?.()
  830. const handlePointerFinish = (pointerEvent: PointerEvent) => {
  831. if (activePointerIdRef.current !== pointerEvent.pointerId) return
  832. pendingPointerPositionRef.current = { x: pointerEvent.clientX, y: pointerEvent.clientY }
  833. finishDragInteraction()
  834. }
  835. const handleGlobalPointerMove = (pointerEvent: PointerEvent) => {
  836. if (activePointerIdRef.current !== pointerEvent.pointerId) return
  837. pendingPointerPositionRef.current = { x: pointerEvent.clientX, y: pointerEvent.clientY }
  838. if (!dragSourceRef.current) return
  839. updateDragInsertFromPoint(pointerEvent.clientX, pointerEvent.clientY)
  840. pointerEvent.preventDefault()
  841. }
  842. window.addEventListener("pointerup", handlePointerFinish)
  843. window.addEventListener("pointercancel", handlePointerFinish)
  844. window.addEventListener("pointermove", handleGlobalPointerMove)
  845. removeGlobalPointerListenersRef.current = () => {
  846. window.removeEventListener("pointerup", handlePointerFinish)
  847. window.removeEventListener("pointercancel", handlePointerFinish)
  848. window.removeEventListener("pointermove", handleGlobalPointerMove)
  849. }
  850. dragTimerRef.current = setTimeout(() => {
  851. dragTimerRef.current = null
  852. dragSourceRef.current = pagePath
  853. setDragSource(pagePath)
  854. setIsDragging(true)
  855. const pointerPosition = pendingPointerPositionRef.current
  856. if (pointerPosition) {
  857. updateDragInsertFromPoint(pointerPosition.x, pointerPosition.y)
  858. }
  859. }, 300)
  860. }, [filterType, renamingPath, selectedFile, isDragging, finishDragInteraction, updateDragInsertFromPoint])
  861. const handlePageClick = useCallback((pagePath: string) => {
  862. setArmedPath(null)
  863. if (renamingPath === pagePath) return
  864. setSelectedFile(pagePath)
  865. // 保存最后阅读的章节路径,用于启动时自动打开(仅章节保存,大纲不保存)
  866. if (pagePath.replace(/\\/g, "/").includes("/wiki/chapters/")) {
  867. saveLastReadChapter(pagePath).catch(() => {})
  868. }
  869. // 同时保存到sessionStorage,用于视图切换时恢复章节定位
  870. if (pagePath.replace(/\\/g, "/").includes("/wiki/chapters/")) {
  871. try {
  872. sessionStorage.setItem("lk-last-chapter-path", pagePath)
  873. } catch { /* ignore quota errors */ }
  874. }
  875. }, [renamingPath, setSelectedFile])
  876. const toggleFolder = useCallback((folderPath: string) => {
  877. setCollapsedFolders((previous) => ({ ...previous, [folderPath]: !previous[folderPath] }))
  878. }, [])
  879. const openCreateMenu = useCallback((event: React.MouseEvent, parentDir?: string, targetFolderName?: string) => {
  880. event.preventDefault()
  881. event.stopPropagation()
  882. const rect = containerRef.current?.getBoundingClientRect()
  883. setCreateMenu({
  884. parentDir,
  885. targetFolderName,
  886. targetFolderPath: parentDir,
  887. x: rect ? event.clientX - rect.left : event.clientX,
  888. y: rect ? event.clientY - rect.top : event.clientY,
  889. })
  890. setPageMenu(null)
  891. }, [])
  892. const openPageMenu = useCallback((event: React.MouseEvent, pagePath: string) => {
  893. event.preventDefault()
  894. event.stopPropagation()
  895. const rect = containerRef.current?.getBoundingClientRect()
  896. setPageMenu({
  897. path: pagePath,
  898. x: rect ? event.clientX - rect.left : event.clientX,
  899. y: rect ? event.clientY - rect.top : event.clientY,
  900. })
  901. setCreateMenu(null)
  902. }, [])
  903. const handlePageContextMenu = useCallback((event: React.MouseEvent, pagePath: string) => {
  904. if (filterType === "chapter" && lastPointerTypeRef.current !== "mouse") {
  905. event.preventDefault()
  906. event.stopPropagation()
  907. return
  908. }
  909. openPageMenu(event, pagePath)
  910. }, [filterType, openPageMenu])
  911. const handleBlankContextMenu = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
  912. const target = event.target
  913. if (!(target instanceof HTMLElement)) return
  914. if (target.closest("[data-knowledge-interactive='true']")) return
  915. openCreateMenu(event)
  916. }, [openCreateMenu])
  917. useEffect(() => {
  918. const container = containerRef.current
  919. if (!container || filterType !== "chapter") return
  920. const handleSelectStart = (event: Event) => {
  921. const target = event.target
  922. if (!(target instanceof HTMLElement)) return
  923. if (!target.closest("[data-page-path]")) return
  924. event.preventDefault()
  925. }
  926. container.addEventListener("selectstart", handleSelectStart)
  927. return () => {
  928. container.removeEventListener("selectstart", handleSelectStart)
  929. }
  930. }, [filterType])
  931. useEffect(() => {
  932. return () => {
  933. if (dragTimerRef.current) {
  934. clearTimeout(dragTimerRef.current)
  935. dragTimerRef.current = null
  936. }
  937. removeGlobalPointerListenersRef.current?.()
  938. removeGlobalPointerListenersRef.current = null
  939. activePointerIdRef.current = null
  940. pendingPointerPositionRef.current = null
  941. }
  942. }, [])
  943. const rootLabel = filterType === "chapter" ? t("sidebar.knowledge") : t("sidebar.files")
  944. const emptyLabel = filterType === "chapter"
  945. ? t("knowledgeTree.emptyFiltered", { label: t("trash.kindChapter") })
  946. : t("knowledgeTree.emptyFiltered", { label: t("trash.kindOutline") })
  947. const renderNodes = useCallback((nodes: FileNode[], depth = 0) => {
  948. const chapterIndexMap = new Map<string, number>()
  949. sortedChapterPages.forEach((page, index) => chapterIndexMap.set(page.path, index))
  950. return sortFileNodes(nodes, pageInfoByPath, filterType).flatMap((node) => {
  951. const normalizedPath = normalizePath(node.path)
  952. if (node.is_dir) {
  953. const isCollapsed = collapsedFolders[normalizedPath] ?? false
  954. const folderRow = (
  955. <div key={normalizedPath}>
  956. <div
  957. data-knowledge-interactive="true"
  958. className="group flex items-center gap-1 rounded-md px-2 py-1.5 text-sm text-muted-foreground qm-hover"
  959. style={{ paddingLeft: `${depth * 16 + 8}px` }}
  960. onContextMenu={(event) => openCreateMenu(event, normalizedPath, node.name)}
  961. >
  962. <button
  963. type="button"
  964. onClick={() => toggleFolder(normalizedPath)}
  965. className="flex flex-1 items-center gap-1.5 text-left"
  966. >
  967. {isCollapsed ? (
  968. <ChevronRight className="h-3.5 w-3.5 shrink-0" />
  969. ) : (
  970. <ChevronDown className="h-3.5 w-3.5 shrink-0" />
  971. )}
  972. <Folder className="h-4 w-4 shrink-0 text-amber-500" />
  973. <span className="truncate font-medium">{node.name}</span>
  974. <span className="ml-auto text-[10px] text-muted-foreground/60">{countMarkdownDescendants(node)}</span>
  975. </button>
  976. </div>
  977. {!isCollapsed && node.children && renderNodes(node.children, depth + 1)}
  978. </div>
  979. )
  980. return [folderRow]
  981. }
  982. if (!node.name.endsWith(".md")) return []
  983. const page = pageInfoByPath.get(normalizedPath) ?? {
  984. path: normalizedPath,
  985. title: node.name.replace(/\.md$/, "").replace(/-/g, " "),
  986. type: filterType,
  987. tags: [],
  988. }
  989. const isSelected = selectedFile === normalizedPath
  990. const isChapterChecked = filterType === "chapter" && selectedChapterPaths.has(normalizedPath)
  991. const isArmed = armedPath === normalizedPath
  992. const isDeleting = deletingPath === normalizedPath
  993. const isDragSource = dragSource === normalizedPath
  994. const chapterIndex = chapterIndexMap.get(normalizedPath)
  995. const isInsertTarget = isDragging && dragInsertIndex !== null && chapterIndex !== undefined && chapterIndex === dragInsertIndex && !isDragSource
  996. const isOutlineExtracted = filterType === "outline" && extractedOutlinePaths.has(normalizedPath)
  997. const isOutlineIngesting = filterType === "outline" && isOutlinePathIngesting(normalizedPath)
  998. return [
  999. <div
  1000. key={normalizedPath}
  1001. data-knowledge-interactive="true"
  1002. data-page-path={normalizedPath}
  1003. className={`group flex items-center gap-1 rounded-md ${isSelected ? "qm-selected" : "qm-hover"} ${isChapterChecked ? "ring-1 ring-primary/35" : ""} ${isDragSource ? "ring-2 ring-primary/50" : ""} ${isInsertTarget ? "border-t-[3px] border-primary/70" : ""}`}
  1004. onContextMenu={(event) => handlePageContextMenu(event, normalizedPath)}
  1005. onPointerDown={(event) => handleItemPointerDown(event, normalizedPath)}
  1006. style={{
  1007. marginLeft: `${depth * 16}px`,
  1008. userSelect: filterType === "chapter" ? "none" : undefined,
  1009. WebkitUserSelect: filterType === "chapter" ? "none" : undefined,
  1010. WebkitTouchCallout: filterType === "chapter" ? "none" : undefined,
  1011. }}
  1012. >
  1013. {filterType === "chapter" && chapterBatchMode && (
  1014. <input
  1015. type="checkbox"
  1016. checked={isChapterChecked}
  1017. aria-label={t("knowledgeTree.selectChapter", { title: page.title })}
  1018. className="ml-1 h-3.5 w-3.5 shrink-0 accent-primary"
  1019. onPointerDown={(event) => event.stopPropagation()}
  1020. onClick={(event) => event.stopPropagation()}
  1021. onChange={(event) => {
  1022. event.stopPropagation()
  1023. toggleChapterSelection(normalizedPath)
  1024. }}
  1025. />
  1026. )}
  1027. <button
  1028. type="button"
  1029. onClick={() => handlePageClick(normalizedPath)}
  1030. disabled={renamingPath === normalizedPath}
  1031. className={`flex min-w-0 flex-1 items-center justify-between gap-2 px-2 py-1 text-left text-sm ${
  1032. isSelected ? "qm-selected-muted" : "text-muted-foreground group-hover:text-foreground"
  1033. }`}
  1034. title={normalizedPath}
  1035. >
  1036. {page.origin === "web-clip" ? <Globe className="h-3 w-3 shrink-0 text-blue-400" /> : <FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
  1037. {renamingPath === normalizedPath ? (
  1038. <input
  1039. type="text"
  1040. value={renameValue}
  1041. onChange={(event) => setRenameValue(event.target.value)}
  1042. onMouseDown={(event) => event.stopPropagation()}
  1043. onClick={(event) => event.stopPropagation()}
  1044. onFocus={(event) => event.stopPropagation()}
  1045. onBlur={() => void submitRenamePage()}
  1046. onKeyDown={(event) => {
  1047. event.stopPropagation()
  1048. if (event.key === "Enter") {
  1049. event.preventDefault()
  1050. void submitRenamePage()
  1051. } else if (event.key === "Escape") {
  1052. event.preventDefault()
  1053. cancelRenamePage()
  1054. }
  1055. }}
  1056. className="w-full rounded border bg-background px-1.5 py-0.5 text-xs outline-none focus:ring-1 focus:ring-ring"
  1057. autoFocus
  1058. disabled={renamingBusy}
  1059. />
  1060. ) : (
  1061. <>
  1062. <span className="min-w-0 flex-1 truncate">{page.title}</span>
  1063. {page.type === "chapter" && page.wordCountLabel && (
  1064. <span className={`shrink-0 text-right text-[11px] ${isSelected ? "qm-selected-muted" : "text-muted-foreground"}`}>
  1065. {page.wordCountLabel}
  1066. </span>
  1067. )}
  1068. </>
  1069. )}
  1070. </button>
  1071. {filterType === "outline" && novelMode ? (
  1072. <Button
  1073. variant="ghost"
  1074. size="icon"
  1075. className={`mr-0 h-7 w-7 shrink-0 ${isOutlineExtracted ? "text-emerald-600 hover:text-emerald-700" : ""}`}
  1076. title={isOutlineExtracted ? t("novel.outlineGenerator.reingestTitle") : t("novel.outlineGenerator.ingest")}
  1077. disabled={isOutlineIngesting}
  1078. onClick={(event) => {
  1079. event.stopPropagation()
  1080. handleOutlineIngest(normalizedPath)
  1081. }}
  1082. >
  1083. {isOutlineIngesting ? (
  1084. <Loader2 className="h-4 w-4 animate-spin" />
  1085. ) : isOutlineExtracted ? (
  1086. <Check className="h-4 w-4" />
  1087. ) : (
  1088. <Sparkles className="h-4 w-4" />
  1089. )}
  1090. </Button>
  1091. ) : null}
  1092. <DeleteButton
  1093. armed={isArmed}
  1094. deleting={isDeleting}
  1095. className={`mr-1 transition-opacity ${isArmed || isDeleting ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}
  1096. onClick={() => void handleDeleteClick(normalizedPath)}
  1097. name={page.title}
  1098. />
  1099. </div>,
  1100. ]
  1101. })
  1102. }, [
  1103. pageInfoByPath,
  1104. filterType,
  1105. collapsedFolders,
  1106. selectedFile,
  1107. armedPath,
  1108. deletingPath,
  1109. renamingPath,
  1110. renameValue,
  1111. renamingBusy,
  1112. openCreateMenu,
  1113. toggleFolder,
  1114. submitRenamePage,
  1115. cancelRenamePage,
  1116. handleDeleteClick,
  1117. handleItemPointerDown,
  1118. handlePageContextMenu,
  1119. handlePageClick,
  1120. chapterBatchMode,
  1121. selectedChapterPaths,
  1122. toggleChapterSelection,
  1123. dragSource,
  1124. dragInsertIndex,
  1125. isDragging,
  1126. sortedChapterPages,
  1127. novelMode,
  1128. extractedOutlinePaths,
  1129. isOutlinePathIngesting,
  1130. handleOutlineIngest,
  1131. t,
  1132. ])
  1133. if (!project) {
  1134. return (
  1135. <div className="flex h-full items-center justify-center p-4 text-sm text-muted-foreground">
  1136. {t("knowledgeTree.noProject")}
  1137. </div>
  1138. )
  1139. }
  1140. return (
  1141. <ScrollArea className="h-full">
  1142. <div
  1143. ref={containerRef}
  1144. className="relative flex min-h-full flex-col p-2 select-none"
  1145. style={{
  1146. userSelect: filterType === "chapter" ? "none" : undefined,
  1147. WebkitUserSelect: filterType === "chapter" ? "none" : undefined,
  1148. WebkitTouchCallout: filterType === "chapter" ? "none" : undefined,
  1149. }}
  1150. onClick={() => {
  1151. setCreateMenu(null)
  1152. setPageMenu(null)
  1153. }}
  1154. onContextMenu={handleBlankContextMenu}
  1155. onPointerMove={handleContainerPointerMove}
  1156. >
  1157. <div className="mb-2 flex items-center justify-between gap-2 px-2 text-xs font-semibold uppercase text-muted-foreground">
  1158. <span>{rootLabel}</span>
  1159. </div>
  1160. {filterType === "chapter" && chapterBatchMode && selectableChapterPaths.length > 0 && (
  1161. <div className="mb-2 flex items-center gap-2 px-2 text-xs">
  1162. <button
  1163. type="button"
  1164. className="rounded border px-2 py-1 hover:bg-accent"
  1165. onClick={(event) => {
  1166. event.stopPropagation()
  1167. handleToggleAllChapters()
  1168. }}
  1169. >
  1170. {allChaptersSelected ? t("knowledgeTree.clearChapterSelection") : t("knowledgeTree.selectAllChapters")}
  1171. </button>
  1172. <span className="min-w-0 flex-1 truncate text-muted-foreground">
  1173. {selectedChapterCount > 0
  1174. ? t("knowledgeTree.selectedChapters", { count: selectedChapterCount })
  1175. : t("knowledgeTree.selectChaptersHint")}
  1176. </span>
  1177. {selectedChapterCount > 0 && volumeFolders.length > 0 && (
  1178. <select
  1179. aria-label={t("knowledgeTree.moveSelectedToVolume")}
  1180. value=""
  1181. className="h-7 max-w-[120px] rounded border bg-background px-2 text-xs"
  1182. onClick={(event) => event.stopPropagation()}
  1183. onChange={(event) => {
  1184. const targetVolumePath = event.target.value
  1185. if (targetVolumePath) {
  1186. void handleMoveChaptersToVolume(Array.from(selectedChapterPaths), targetVolumePath)
  1187. }
  1188. }}
  1189. >
  1190. <option value="">{t("knowledgeTree.moveSelectedToVolume")}</option>
  1191. {volumeFolders.map((vol) => (
  1192. <option key={vol.path} value={vol.path}>{vol.name}</option>
  1193. ))}
  1194. </select>
  1195. )}
  1196. <button
  1197. type="button"
  1198. className="rounded border px-2 py-1 hover:bg-accent"
  1199. onClick={(event) => {
  1200. event.stopPropagation()
  1201. exitChapterBatchMode()
  1202. }}
  1203. >
  1204. {t("knowledgeTree.exitBatchMode")}
  1205. </button>
  1206. </div>
  1207. )}
  1208. {sectionNodes.length === 0 && pendingPages.length === 0 ? (
  1209. <div className="px-2 py-4 text-center text-xs text-muted-foreground">{emptyLabel}</div>
  1210. ) : (
  1211. renderNodes(sectionNodes)
  1212. )}
  1213. {createMenu && (
  1214. <div
  1215. className="absolute z-20 w-40 rounded-md border bg-background py-1 text-xs shadow-lg"
  1216. style={{ left: createMenu.x, top: createMenu.y }}
  1217. onMouseDown={(event) => event.stopPropagation()}
  1218. onClick={(event) => event.stopPropagation()}
  1219. >
  1220. <button
  1221. type="button"
  1222. className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent"
  1223. onClick={() => {
  1224. onRequestCreate?.({ kind: filterType, parentDir: createMenu.parentDir })
  1225. setCreateMenu(null)
  1226. }}
  1227. >
  1228. <Plus className="h-3.5 w-3.5" />
  1229. {filterType === "chapter" ? t("sidebar.newChapter") : t("sidebar.newOutline")}
  1230. </button>
  1231. <button
  1232. type="button"
  1233. className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent"
  1234. onClick={() => {
  1235. onRequestCreate?.({ kind: filterType === "chapter" ? "volume" : "folder", parentDir: createMenu.parentDir })
  1236. setCreateMenu(null)
  1237. }}
  1238. >
  1239. <Folder className="h-3.5 w-3.5" />
  1240. {filterType === "chapter" ? t("sidebar.newVolume") : t("sidebar.newFolder")}
  1241. </button>
  1242. {createMenu.targetFolderPath ? (
  1243. <button
  1244. type="button"
  1245. className="flex w-full items-center gap-2 px-3 py-2 text-left text-destructive hover:bg-accent"
  1246. onClick={() => {
  1247. const targetFolderPath = createMenu.targetFolderPath
  1248. setCreateMenu(null)
  1249. if (targetFolderPath) {
  1250. void handleDeleteFolder(targetFolderPath)
  1251. }
  1252. }}
  1253. >
  1254. <Trash2 className="h-3.5 w-3.5" />
  1255. {filterType === "chapter" ? t("knowledgeTree.deleteVolume") : t("knowledgeTree.deleteFolder")}
  1256. </button>
  1257. ) : null}
  1258. </div>
  1259. )}
  1260. {pageMenu && (
  1261. <div
  1262. className="absolute z-20 w-40 rounded-md border bg-background py-1 text-xs shadow-lg"
  1263. style={{ left: pageMenu.x, top: pageMenu.y }}
  1264. onMouseDown={(event) => event.stopPropagation()}
  1265. onClick={(event) => event.stopPropagation()}
  1266. >
  1267. <button
  1268. type="button"
  1269. className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent"
  1270. onClick={() => {
  1271. const target = pageInfoByPath.get(pageMenu.path)
  1272. onRequestCreate?.({ kind: filterType, parentDir: target ? getDirName(target.path) : undefined })
  1273. setPageMenu(null)
  1274. }}
  1275. >
  1276. <Plus className="h-3.5 w-3.5" />
  1277. {filterType === "chapter" ? t("sidebar.newChapter") : t("sidebar.newOutline")}
  1278. </button>
  1279. <button
  1280. type="button"
  1281. className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent"
  1282. onClick={() => {
  1283. const target = pageInfoByPath.get(pageMenu.path)
  1284. onRequestCreate?.({
  1285. kind: filterType === "chapter" ? "volume" : "folder",
  1286. parentDir: target ? getDirName(target.path) : undefined,
  1287. })
  1288. setPageMenu(null)
  1289. }}
  1290. >
  1291. <Folder className="h-3.5 w-3.5" />
  1292. {filterType === "chapter" ? t("sidebar.newVolume") : t("sidebar.newFolder")}
  1293. </button>
  1294. <button
  1295. type="button"
  1296. className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent"
  1297. onClick={() => {
  1298. const target = pageInfoByPath.get(pageMenu.path)
  1299. if (target) startRenamePage(target)
  1300. }}
  1301. >
  1302. <Pencil className="h-3.5 w-3.5" />
  1303. {t("knowledgeTree.rename")}
  1304. </button>
  1305. {filterType === "chapter" && volumeFolders.length > 0 && (
  1306. <div>
  1307. <button
  1308. type="button"
  1309. className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent"
  1310. onClick={() => setMoveMenuTarget(moveMenuTarget === pageMenu.path ? null : pageMenu.path)}
  1311. >
  1312. <FolderInput className="h-3.5 w-3.5" />
  1313. {t("knowledgeTree.moveToVolume")}
  1314. <ChevronRight className="ml-auto h-3 w-3" />
  1315. </button>
  1316. {moveMenuTarget === pageMenu.path && (
  1317. <div className="max-h-48 overflow-y-auto border-t bg-background py-1 text-xs">
  1318. <button
  1319. type="button"
  1320. className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent"
  1321. onClick={() => {
  1322. setChapterBatchMode(true)
  1323. setSelectedChapterPaths(new Set())
  1324. setMoveMenuTarget(null)
  1325. setPageMenu(null)
  1326. }}
  1327. >
  1328. <Check className="h-3 w-3 shrink-0 text-muted-foreground" />
  1329. <span>{t("knowledgeTree.batchChapters")}</span>
  1330. </button>
  1331. {volumeFolders.map((vol) => {
  1332. const sourcePaths = selectedChapterPaths.has(pageMenu.path)
  1333. ? Array.from(selectedChapterPaths)
  1334. : [pageMenu.path]
  1335. const isCurrentVolume = sourcePaths.every((sourcePath) => getDirName(sourcePath) === vol.path)
  1336. return (
  1337. <button
  1338. key={vol.path}
  1339. type="button"
  1340. className={`flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent ${isCurrentVolume ? "opacity-50 cursor-not-allowed" : ""}`}
  1341. disabled={isCurrentVolume}
  1342. onClick={() => {
  1343. if (!isCurrentVolume) {
  1344. void handleMoveChaptersToVolume(sourcePaths, vol.path)
  1345. }
  1346. }}
  1347. >
  1348. <Folder className="h-3 w-3 shrink-0 text-amber-500" />
  1349. <span className="truncate">{vol.name}</span>
  1350. {isCurrentVolume && <span className="ml-auto text-[10px] text-muted-foreground">当前</span>}
  1351. </button>
  1352. )
  1353. })}
  1354. </div>
  1355. )}
  1356. </div>
  1357. )}
  1358. <button
  1359. type="button"
  1360. className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-accent"
  1361. onClick={() => {
  1362. openFileLocation(pageMenu.path).catch((err) => console.error("打开文件位置失败:", err))
  1363. }}
  1364. >
  1365. <FolderOpen className="h-4 w-4" />
  1366. 打开文件所在位置
  1367. </button>
  1368. </div>
  1369. )}
  1370. </div>
  1371. </ScrollArea>
  1372. )
  1373. }
  1374. export function RawSourcesSection({ onCancelExtraction }: { onCancelExtraction?: () => void }) {
  1375. const project = useWikiStore((s) => s.project)
  1376. const tasks = useImportProgressStore((s) => s.tasks)
  1377. const [expanded, setExpanded] = useState(false)
  1378. const projectTasks = useMemo(() => {
  1379. if (!project) return []
  1380. const projectPath = normalizePath(project.path)
  1381. return tasks
  1382. .filter((task) => task.projectPath === projectPath)
  1383. .sort((a, b) => b.updatedAt - a.updatedAt)
  1384. }, [project, tasks])
  1385. const runningTasks = projectTasks.filter((t) => t.status === "running")
  1386. const hasRunning = runningTasks.length > 0
  1387. const hasAnyTask = projectTasks.length > 0
  1388. useEffect(() => {
  1389. if (hasRunning) setExpanded(true)
  1390. }, [hasRunning])
  1391. return (
  1392. <div className="shrink-0 border-t bg-background/95 p-2 backdrop-blur">
  1393. <button
  1394. type="button"
  1395. onClick={() => setExpanded((previous) => !previous)}
  1396. className="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-sm qm-hover"
  1397. >
  1398. {expanded ? (
  1399. <ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
  1400. ) : (
  1401. <ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
  1402. )}
  1403. <BookOpen className="h-3.5 w-3.5 shrink-0 text-amber-600" />
  1404. <span className="flex-1 text-left font-medium text-muted-foreground">提取中</span>
  1405. {hasRunning ? (
  1406. <span className="text-xs text-primary">{runningTasks.length} 个任务运行中</span>
  1407. ) : null}
  1408. </button>
  1409. {expanded && (
  1410. <div className="ml-3 max-h-64 space-y-2 overflow-y-auto pr-1 text-xs text-muted-foreground">
  1411. {hasAnyTask ? (
  1412. projectTasks.slice(0, 20).map((task) => {
  1413. const kindLabel =
  1414. task.kind === "outline" ? "AI 大纲" :
  1415. task.kind === "outline_generation" ? "生成大纲" :
  1416. task.kind === "outline_refinement" ? "细化生成" :
  1417. "章节"
  1418. const progressPercent = task.total > 0
  1419. ? Math.round((task.completed / task.total) * 100)
  1420. : 0
  1421. const isRunning = task.status === "running"
  1422. return (
  1423. <div key={task.id} className="space-y-1 rounded-md bg-muted/30 px-2 py-1.5">
  1424. <div className="flex items-center justify-between gap-2">
  1425. <span className="min-w-0 truncate flex items-center gap-1">
  1426. {isRunning ? (
  1427. <Loader2 className="h-3 w-3 shrink-0 animate-spin text-primary" />
  1428. ) : task.status === "done" ? (
  1429. <Check className="h-3 w-3 shrink-0 text-emerald-500" />
  1430. ) : task.status === "error" ? (
  1431. <X className="h-3 w-3 shrink-0 text-destructive" />
  1432. ) : task.status === "cancelled" ? (
  1433. <X className="h-3 w-3 shrink-0 text-muted-foreground" />
  1434. ) : null}
  1435. <span className={isRunning ? "text-foreground font-medium" : ""}>
  1436. {isRunning
  1437. ? formatImportProgressRunningLabel(task, kindLabel)
  1438. : task.status === "done"
  1439. ? `${kindLabel}提取完成`
  1440. : task.status === "error"
  1441. ? `${kindLabel}提取失败`
  1442. : task.status === "cancelled"
  1443. ? `${kindLabel}提取已取消`
  1444. : task.message ?? ""}
  1445. </span>
  1446. </span>
  1447. <span className="flex items-center gap-1.5 shrink-0">
  1448. {isRunning && (
  1449. <>
  1450. <span className="text-muted-foreground">{progressPercent}%</span>
  1451. <Button
  1452. type="button"
  1453. size="sm"
  1454. variant="outline"
  1455. className="h-5 px-1.5 text-xs text-destructive border-destructive/30 hover:bg-destructive/10"
  1456. onClick={() => {
  1457. useImportProgressStore.getState().cancelTask(task.id)
  1458. onCancelExtraction?.()
  1459. }}
  1460. disabled={task.cancelling}
  1461. >
  1462. 停止
  1463. </Button>
  1464. </>
  1465. )}
  1466. </span>
  1467. </div>
  1468. {isRunning && (
  1469. <div className="h-1 overflow-hidden rounded-full bg-border">
  1470. <div
  1471. className="h-full rounded-full bg-primary transition-all"
  1472. style={{ width: `${progressPercent}%` }}
  1473. />
  1474. </div>
  1475. )}
  1476. {isRunning && (
  1477. <span className="text-muted-foreground">
  1478. {formatImportProgressDetail(task)}
  1479. </span>
  1480. )}
  1481. </div>
  1482. )
  1483. })
  1484. ) : (
  1485. <div className="rounded-md bg-muted/40 px-2 py-2">暂无提取任务</div>
  1486. )}
  1487. </div>
  1488. )}
  1489. </div>
  1490. )
  1491. }
  1492. function DeleteButton({
  1493. armed,
  1494. deleting,
  1495. onClick,
  1496. name,
  1497. className = "",
  1498. }: {
  1499. armed: boolean
  1500. deleting: boolean
  1501. onClick: () => void
  1502. name: string
  1503. className?: string
  1504. }) {
  1505. const { t } = useTranslation()
  1506. if (deleting) {
  1507. return (
  1508. <Button
  1509. variant="ghost"
  1510. size="icon"
  1511. className={`h-6 w-6 shrink-0 cursor-default ${className}`}
  1512. disabled
  1513. title={t("knowledgeTree.deletingTitle", { name })}
  1514. >
  1515. <Trash2 className="h-3 w-3 animate-pulse text-destructive" />
  1516. </Button>
  1517. )
  1518. }
  1519. if (armed) {
  1520. return (
  1521. <Button
  1522. variant="destructive"
  1523. size="sm"
  1524. className={`h-6 shrink-0 px-1.5 text-[10px] font-semibold animate-pulse ${className}`}
  1525. onClick={(event) => {
  1526. event.stopPropagation()
  1527. onClick()
  1528. }}
  1529. title={t("knowledgeTree.confirmDeleteTitle", { name })}
  1530. >
  1531. <Trash2 className="mr-0.5 h-3 w-3" />
  1532. {t("knowledgeTree.confirmDelete")}
  1533. </Button>
  1534. )
  1535. }
  1536. return (
  1537. <Button
  1538. variant="ghost"
  1539. size="icon"
  1540. className={`h-6 w-6 shrink-0 text-muted-foreground hover:text-destructive ${className}`}
  1541. onClick={(event) => {
  1542. event.stopPropagation()
  1543. onClick()
  1544. }}
  1545. title={t("knowledgeTree.deleteTitle", { name })}
  1546. >
  1547. <Trash2 className="h-3 w-3" />
  1548. </Button>
  1549. )
  1550. }