app-layout.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. import { useCallback, useEffect, useRef, useState } from "react"
  2. import { useWikiStore } from "@/stores/wiki-store"
  3. import { refreshProjectFileTree } from "@/lib/project-file-tree-refresh"
  4. import { IconSidebar } from "./icon-sidebar"
  5. import { SidebarPanel } from "./sidebar-panel"
  6. import { ContentArea } from "./content-area"
  7. import { ActivityPanel } from "./activity-panel"
  8. import { useOutlineGenerationStore, type OutlineGenerationTask, type OutlineGenerationState } from "@/stores/outline-generation-store"
  9. import { useBookAnalysisStore } from "@/stores/book-analysis-store"
  10. import { ErrorBoundary } from "@/components/error-boundary"
  11. import { clampSidebarWidth } from "@/lib/workspace-layout"
  12. import { useTranslation } from "react-i18next"
  13. import { HelpCircle, X } from "lucide-react"
  14. const USAGE_GUIDE_PROMPT_DISMISSED_KEY = "qmai-usage-guide-prompt-dismissed"
  15. interface AppLayoutProps {
  16. onSwitchProject: () => void
  17. }
  18. export function AppLayout({ onSwitchProject }: AppLayoutProps) {
  19. const { t } = useTranslation()
  20. const project = useWikiStore((s) => s.project)
  21. const activeView = useWikiStore((s) => s.activeView)
  22. const setActiveView = useWikiStore((s) => s.setActiveView)
  23. const setActiveSettingsCategory = useWikiStore((s) => s.setActiveSettingsCategory)
  24. const outlineTasks = useOutlineGenerationStore((s: OutlineGenerationState) => s.tasks)
  25. const removeOutlineTask = useOutlineGenerationStore((s: OutlineGenerationState) => s.removeTask)
  26. const bookAnalysisTasks = useBookAnalysisStore((s) => s.tasks)
  27. const removeBookAnalysisTask = useBookAnalysisStore((s) => s.removeTask)
  28. const [leftWidth, setLeftWidth] = useState(220)
  29. const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
  30. const [usageGuidePromptDismissed, setUsageGuidePromptDismissed] = useState(() => {
  31. if (typeof localStorage === "undefined") return false
  32. return localStorage.getItem(USAGE_GUIDE_PROMPT_DISMISSED_KEY) === "1"
  33. })
  34. // 拆书分析通知:用户手动关闭的任务ID集合(运行中关闭后不再显示,完成后重新弹出)
  35. const [dismissedBookAnalysisTaskIds, setDismissedBookAnalysisTaskIds] = useState<Set<string>>(new Set())
  36. const isDraggingLeft = useRef(false)
  37. const containerRef = useRef<HTMLDivElement>(null)
  38. const latestOutlineTask = outlineTasks
  39. .filter((task: OutlineGenerationTask) => (
  40. task.status === "generating" ||
  41. task.status === "generated" ||
  42. task.status === "error"
  43. ))
  44. .sort((a: OutlineGenerationTask, b: OutlineGenerationTask) => b.updatedAt - a.updatedAt)[0] ?? null
  45. // 拆书分析后台任务:正在运行或刚完成
  46. // 运行中的任务如果被用户关闭则不显示,但完成后重新弹出
  47. const latestBookAnalysisTask = bookAnalysisTasks
  48. .filter((task) => {
  49. if (task.status !== "running" && task.status !== "completed" && task.status !== "error") return false
  50. // 完成或出错时总是显示
  51. if (task.status === "completed" || task.status === "error") return true
  52. // 运行中:如果用户关闭过则不显示
  53. return !dismissedBookAnalysisTaskIds.has(task.id)
  54. })
  55. .sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? null
  56. const loadFileTree = useCallback(async () => {
  57. if (!project) return
  58. await refreshProjectFileTree(project.path, {
  59. projectId: project.id,
  60. clearDisplayTreeFirst: false,
  61. })
  62. }, [project])
  63. useEffect(() => {
  64. loadFileTree()
  65. }, [loadFileTree])
  66. const startDrag = useCallback(
  67. (side: "left") => (e: React.MouseEvent) => {
  68. e.preventDefault()
  69. if (side === "left") isDraggingLeft.current = true
  70. document.body.style.cursor = "col-resize"
  71. document.body.style.userSelect = "none"
  72. document.body.dataset.panelResizing = "true"
  73. const handleMouseMove = (e: MouseEvent) => {
  74. if (!containerRef.current) return
  75. const rect = containerRef.current.getBoundingClientRect()
  76. if (isDraggingLeft.current) {
  77. const newWidth = e.clientX - rect.left
  78. setLeftWidth(clampSidebarWidth(newWidth))
  79. }
  80. }
  81. const handleMouseUp = () => {
  82. isDraggingLeft.current = false
  83. document.body.style.cursor = ""
  84. document.body.style.userSelect = ""
  85. delete document.body.dataset.panelResizing
  86. document.removeEventListener("mousemove", handleMouseMove)
  87. document.removeEventListener("mouseup", handleMouseUp)
  88. }
  89. document.addEventListener("mousemove", handleMouseMove)
  90. document.addEventListener("mouseup", handleMouseUp)
  91. },
  92. []
  93. )
  94. // Settings is a full-width admin view — the file tree / activity panel
  95. // are irrelevant there and their narrow column makes the settings form
  96. // cramped. Hide both the left sidebar (and the file preview on the
  97. // right) so the settings screen uses the whole content area.
  98. const isSettings = activeView === "settings"
  99. // Novel mode keeps the writing editor inside the chapter workspace only.
  100. // Outline/search/graph views no longer mount a secondary preview panel.
  101. useEffect(() => {
  102. const savedCollapsed = localStorage.getItem("lk-sidebar-collapsed")
  103. if (savedCollapsed === "1") {
  104. setSidebarCollapsed(true)
  105. }
  106. }, [])
  107. useEffect(() => {
  108. localStorage.setItem("lk-sidebar-collapsed", sidebarCollapsed ? "1" : "0")
  109. }, [sidebarCollapsed])
  110. const dismissUsageGuidePrompt = useCallback(() => {
  111. setUsageGuidePromptDismissed(true)
  112. localStorage.setItem(USAGE_GUIDE_PROMPT_DISMISSED_KEY, "1")
  113. }, [])
  114. const bookAnalysisTitle = latestBookAnalysisTask
  115. ? latestBookAnalysisTask.status === "running"
  116. ? latestBookAnalysisTask.progress.stage === "extracting_characters"
  117. ? t("appLayout.bookAnalysis.extractingCharacters")
  118. : latestBookAnalysisTask.progress.stage === "analyzing_six_dimension"
  119. ? t("appLayout.bookAnalysis.analyzingSixDimension")
  120. : latestBookAnalysisTask.progress.stage === "generating_skills"
  121. ? t("appLayout.bookAnalysis.generatingSkills")
  122. : t("appLayout.bookAnalysis.running")
  123. : latestBookAnalysisTask.status === "error"
  124. ? t("appLayout.bookAnalysis.error")
  125. : t("appLayout.bookAnalysis.completed")
  126. : ""
  127. const bookAnalysisDescription = latestBookAnalysisTask
  128. ? latestBookAnalysisTask.status === "running"
  129. ? latestBookAnalysisTask.progress.stageLabel || t("appLayout.bookAnalysis.processing")
  130. : latestBookAnalysisTask.status === "error"
  131. ? latestBookAnalysisTask.error || t("appLayout.bookAnalysis.errorDescription")
  132. : latestBookAnalysisTask.progress.stageLabel || t("appLayout.bookAnalysis.completedDescription")
  133. : ""
  134. const bookAnalysisDismissLabel = latestBookAnalysisTask?.status === "running"
  135. ? t("appLayout.bookAnalysis.handleLater")
  136. : t("common.close")
  137. return (
  138. <div className="flex h-screen flex-col bg-background text-foreground">
  139. {latestOutlineTask && (
  140. <div className="fixed bottom-3 right-3 z-50 w-80 rounded-lg border bg-background p-3 shadow-lg">
  141. <div className="text-sm font-medium">
  142. {latestOutlineTask.status === "generating"
  143. ? latestOutlineTask.kind === "refine"
  144. ? (latestOutlineTask.displayTitle && latestOutlineTask.displayTitle !== t("novel.outlineGenerator.refineTitle")
  145. ? t("novel.outlineGenerator.sectionGenerating", { title: latestOutlineTask.displayTitle })
  146. : t("novel.outlineGenerator.refining"))
  147. : t("novel.outlineGenerator.generatingTitle")
  148. : latestOutlineTask.status === "error"
  149. ? t("novel.outlineGenerator.error")
  150. : latestOutlineTask.kind === "refine"
  151. ? t("novel.outlineGenerator.refineTitle")
  152. : t("novel.outlineGenerator.generatedTitle")}
  153. </div>
  154. <div className="mt-1 text-xs text-muted-foreground">
  155. {latestOutlineTask.status === "generating"
  156. ? t("novel.outlineGenerator.generationMayTakeLong")
  157. : latestOutlineTask.status === "error"
  158. ? latestOutlineTask.message
  159. : latestOutlineTask.kind === "refine"
  160. ? latestOutlineTask.message
  161. : t("novel.outlineGenerator.generatedDescription")}
  162. </div>
  163. <div className="mt-3 flex gap-2">
  164. {latestOutlineTask.outlinePath ? (
  165. <>
  166. <button
  167. type="button"
  168. className="rounded-md border px-3 py-1.5 text-xs"
  169. onClick={async () => {
  170. const { openGeneratedOutline } = await import("@/lib/novel/outline-generation")
  171. await openGeneratedOutline(latestOutlineTask.id)
  172. }}
  173. >
  174. {t("novel.outlineGenerator.openOutline")}
  175. </button>
  176. {latestOutlineTask.kind === "outline" ? (
  177. <button
  178. type="button"
  179. className="rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground"
  180. onClick={async () => {
  181. const { runOutlineIngestTask } = await import("@/lib/novel/outline-generation")
  182. await runOutlineIngestTask(latestOutlineTask.id)
  183. }}
  184. >
  185. {t("novel.outlineGenerator.ingestNow")}
  186. </button>
  187. ) : null}
  188. </>
  189. ) : null}
  190. <button
  191. type="button"
  192. className="rounded-md px-3 py-1.5 text-xs text-muted-foreground"
  193. onClick={() => removeOutlineTask(latestOutlineTask.id)}
  194. >
  195. {t("novel.outlineGenerator.handleLater")}
  196. </button>
  197. </div>
  198. </div>
  199. )}
  200. {/* 拆书分析后台任务通知 */}
  201. {latestBookAnalysisTask && (
  202. <div className={`fixed z-50 w-80 rounded-lg border bg-background p-3 shadow-lg ${latestOutlineTask ? "bottom-[calc(0.75rem+13rem)]" : "bottom-3"} right-3`}>
  203. <button
  204. type="button"
  205. onClick={() => {
  206. if (latestBookAnalysisTask.status === "running") {
  207. // 运行中关闭:记录ID,完成后重新弹出
  208. setDismissedBookAnalysisTaskIds((prev) => new Set(prev).add(latestBookAnalysisTask.id))
  209. } else {
  210. // 完成/出错关闭:从列表中移除
  211. removeBookAnalysisTask(latestBookAnalysisTask.id)
  212. }
  213. }}
  214. className="absolute right-2 top-2 rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
  215. title={t("common.close")}
  216. aria-label={t("appLayout.bookAnalysis.closeNotification")}
  217. >
  218. <X className="h-3.5 w-3.5" />
  219. </button>
  220. <div className="text-sm font-medium pr-5">
  221. {bookAnalysisTitle}
  222. </div>
  223. <div className="mt-1 text-xs text-muted-foreground">
  224. {bookAnalysisDescription}
  225. </div>
  226. {latestBookAnalysisTask.status === "running" && (
  227. <div className="mt-2">
  228. <div className="h-1.5 bg-secondary rounded-full overflow-hidden">
  229. <div
  230. className="h-full bg-primary transition-all duration-300"
  231. style={{ width: `${latestBookAnalysisTask.progress.percentage ?? 0}%` }}
  232. />
  233. </div>
  234. {latestBookAnalysisTask.progress.currentItem && (
  235. <div className="mt-1 text-xs text-muted-foreground truncate">
  236. {latestBookAnalysisTask.progress.currentItem}
  237. </div>
  238. )}
  239. </div>
  240. )}
  241. <div className="mt-3 flex gap-2">
  242. {latestBookAnalysisTask.status === "completed" && (
  243. <button
  244. type="button"
  245. className="rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground"
  246. onClick={() => {
  247. setActiveView("bookAnalysis")
  248. removeBookAnalysisTask(latestBookAnalysisTask.id)
  249. }}
  250. >
  251. {t("appLayout.bookAnalysis.viewResult")}
  252. </button>
  253. )}
  254. <button
  255. type="button"
  256. className="rounded-md px-3 py-1.5 text-xs text-muted-foreground"
  257. onClick={() => {
  258. if (latestBookAnalysisTask.status === "running") {
  259. setDismissedBookAnalysisTaskIds((prev) => new Set(prev).add(latestBookAnalysisTask.id))
  260. } else {
  261. removeBookAnalysisTask(latestBookAnalysisTask.id)
  262. }
  263. }}
  264. >
  265. {bookAnalysisDismissLabel}
  266. </button>
  267. </div>
  268. </div>
  269. )}
  270. {!isSettings && !usageGuidePromptDismissed && (
  271. <div className="fixed bottom-4 left-14 z-50 w-56 rounded-lg border border-primary/40 bg-background/95 p-3 shadow-xl backdrop-blur">
  272. <button
  273. type="button"
  274. onClick={dismissUsageGuidePrompt}
  275. className="absolute right-2 top-2 rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
  276. title={t("common.close")}
  277. aria-label={t("appLayout.usageGuidePrompt.close")}
  278. >
  279. <X className="h-3.5 w-3.5" />
  280. </button>
  281. <button
  282. type="button"
  283. onClick={() => {
  284. dismissUsageGuidePrompt()
  285. setActiveSettingsCategory("usage-guide")
  286. setActiveView("settings")
  287. }}
  288. className="flex w-full items-start gap-2 pr-5 text-left"
  289. >
  290. <span className="mt-0.5 rounded-md bg-primary/10 p-1.5 text-primary">
  291. <HelpCircle className="h-4 w-4" />
  292. </span>
  293. <span className="min-w-0">
  294. <span className="block text-sm font-medium">{t("appLayout.usageGuidePrompt.title")}</span>
  295. <span className="mt-1 block text-xs leading-5 text-muted-foreground">
  296. {t("appLayout.usageGuidePrompt.description")}
  297. </span>
  298. </span>
  299. </button>
  300. </div>
  301. )}
  302. <div className="flex min-h-0 flex-1">
  303. <IconSidebar
  304. onToggleSidebar={() => setSidebarCollapsed((v) => !v)}
  305. onOpenSidebar={() => setSidebarCollapsed(false)}
  306. onSwitchProject={onSwitchProject}
  307. />
  308. <div ref={containerRef} className="flex min-w-0 flex-1 overflow-hidden">
  309. {!isSettings && !sidebarCollapsed && (
  310. <>
  311. <div
  312. className="flex shrink-0 flex-col overflow-hidden border-r"
  313. style={{ width: leftWidth }}
  314. >
  315. <div className="flex-1 overflow-hidden">
  316. <SidebarPanel />
  317. </div>
  318. <ActivityPanel />
  319. </div>
  320. <div
  321. className="w-1.5 shrink-0 cursor-col-resize bg-border/40 transition-colors hover:bg-primary/30 active:bg-primary/40"
  322. onMouseDown={startDrag("left")}
  323. />
  324. </>
  325. )}
  326. <div className="min-w-0 flex-1 overflow-hidden">
  327. <ErrorBoundary>
  328. <ContentArea />
  329. </ErrorBoundary>
  330. </div>
  331. </div>
  332. </div>
  333. </div>
  334. )
  335. }