From 19b8a595712027dbb1875f8845ac5c21829373b9 Mon Sep 17 00:00:00 2001 From: honlnk Date: Tue, 16 Jun 2026 17:57:13 +0800 Subject: [PATCH 01/36] =?UTF-8?q?docs(docs):=20=E6=9B=B4=E6=96=B0=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=AD=90=E6=A8=A1=E5=9D=97=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs b/docs index 1e1b571..610f2bb 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 1e1b571ed26967e46e2e4f0406c1e687d1141788 +Subproject commit 610f2bb3d37912f71417f4dd200cbfa76ff7e7e2 From b87270fde704590a0d4257e80a504c6745c2f505 Mon Sep 17 00:00:00 2001 From: honlnk Date: Wed, 17 Jun 2026 22:33:18 +0800 Subject: [PATCH 02/36] =?UTF-8?q?refactor(layout):=20=E7=94=A8=20Activity?= =?UTF-8?q?=20Bar=20=E5=92=8C=E5=88=86=E7=B1=BB=E9=9D=A2=E6=9D=BF=E6=9B=BF?= =?UTF-8?q?=E6=8D=A2=E6=96=87=E4=BB=B6=E6=A0=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/category/ChapterList.vue | 124 ++++++++++++++++ .../components/category/ConversationList.vue | 23 +++ .../src/components/category/ElementList.vue | 123 ++++++++++++++++ .../src/components/category/PromptList.vue | 118 +++++++++++++++ .../app/src/components/layout/ActivityBar.vue | 136 ++++++++++++++++++ .../src/components/layout/CategoryPanel.vue | 69 +++++++++ .../src/components/layout/FileTreeSidebar.vue | 113 --------------- packages/app/src/constants/category.ts | 15 ++ packages/app/src/constants/elements.ts | 31 ++++ packages/app/src/stores/project.ts | 30 ++++ packages/app/src/utils/file-tree.ts | 76 ++++++++++ packages/app/src/views/ProjectView.vue | 89 ++++++++---- 12 files changed, 810 insertions(+), 137 deletions(-) create mode 100644 packages/app/src/components/category/ChapterList.vue create mode 100644 packages/app/src/components/category/ConversationList.vue create mode 100644 packages/app/src/components/category/ElementList.vue create mode 100644 packages/app/src/components/category/PromptList.vue create mode 100644 packages/app/src/components/layout/ActivityBar.vue create mode 100644 packages/app/src/components/layout/CategoryPanel.vue delete mode 100644 packages/app/src/components/layout/FileTreeSidebar.vue create mode 100644 packages/app/src/constants/category.ts create mode 100644 packages/app/src/constants/elements.ts create mode 100644 packages/app/src/utils/file-tree.ts diff --git a/packages/app/src/components/category/ChapterList.vue b/packages/app/src/components/category/ChapterList.vue new file mode 100644 index 0000000..8dc59bb --- /dev/null +++ b/packages/app/src/components/category/ChapterList.vue @@ -0,0 +1,124 @@ + + + diff --git a/packages/app/src/components/category/ConversationList.vue b/packages/app/src/components/category/ConversationList.vue new file mode 100644 index 0000000..3cddae0 --- /dev/null +++ b/packages/app/src/components/category/ConversationList.vue @@ -0,0 +1,23 @@ + + + diff --git a/packages/app/src/components/category/ElementList.vue b/packages/app/src/components/category/ElementList.vue new file mode 100644 index 0000000..9128ae4 --- /dev/null +++ b/packages/app/src/components/category/ElementList.vue @@ -0,0 +1,123 @@ + + + diff --git a/packages/app/src/components/category/PromptList.vue b/packages/app/src/components/category/PromptList.vue new file mode 100644 index 0000000..ef16827 --- /dev/null +++ b/packages/app/src/components/category/PromptList.vue @@ -0,0 +1,118 @@ + + + diff --git a/packages/app/src/components/layout/ActivityBar.vue b/packages/app/src/components/layout/ActivityBar.vue new file mode 100644 index 0000000..d74a9ca --- /dev/null +++ b/packages/app/src/components/layout/ActivityBar.vue @@ -0,0 +1,136 @@ + + + diff --git a/packages/app/src/components/layout/CategoryPanel.vue b/packages/app/src/components/layout/CategoryPanel.vue new file mode 100644 index 0000000..f42caf5 --- /dev/null +++ b/packages/app/src/components/layout/CategoryPanel.vue @@ -0,0 +1,69 @@ + + + diff --git a/packages/app/src/components/layout/FileTreeSidebar.vue b/packages/app/src/components/layout/FileTreeSidebar.vue deleted file mode 100644 index ac84aef..0000000 --- a/packages/app/src/components/layout/FileTreeSidebar.vue +++ /dev/null @@ -1,113 +0,0 @@ - - - diff --git a/packages/app/src/constants/category.ts b/packages/app/src/constants/category.ts new file mode 100644 index 0000000..5d872a9 --- /dev/null +++ b/packages/app/src/constants/category.ts @@ -0,0 +1,15 @@ +/** + * Activity Bar 的分类类型。 + * + * 对应左侧 Activity Bar 的 5 个图标,每个分类切换右侧 CategoryPanel 的内容。 + * 注意:`settings` 是动作型入口(打开模态框),不会作为 CategoryPanel 的渲染分支, + * 仅在此保留以统一 Activity Bar 的图标语义。 + */ +export type Category = 'conversation' | 'chapter' | 'element' | 'prompt' + +/** + * 设置是动作型入口,不切换 CategoryPanel,单独标识以便 ActivityBar 区分行为。 + */ +export type SettingsAction = 'settings' + +export type ActivityItem = Category | SettingsAction diff --git a/packages/app/src/constants/elements.ts b/packages/app/src/constants/elements.ts new file mode 100644 index 0000000..99c7c22 --- /dev/null +++ b/packages/app/src/constants/elements.ts @@ -0,0 +1,31 @@ +import type { ElementType } from '@novai/core' + +/** + * 要素分组的 UI 常量定义。 + * + * core 层只有 `ElementType` 类型和散落的目录映射(writer.ts 内私有常量), + * 没有集中维护「type ↔ 目录 ↔ 中文标题 ↔ 图标」的常量。 + * 分类面板需要固定的中文标题展示,因此在 app 层统一维护这份映射, + * 不依赖磁盘目录名做中文翻译。 + * + * 顺序固定,对应 UI 中 6 个可折叠分组的展示顺序。 + */ +export interface ElementCategory { + /** core 的 ElementType,作为稳定 key */ + key: ElementType + /** UI 展示的中文标题(写死) */ + label: string + /** 对应的磁盘目录前缀,用于从 currentProject.files 过滤 */ + directory: string + /** UI 展示的图标(emoji,后续可替换为 svg) */ + icon: string +} + +export const ELEMENT_CATEGORIES: ElementCategory[] = [ + { key: 'character', label: '人物', directory: 'elements/characters', icon: '👤' }, + { key: 'location', label: '地点', directory: 'elements/locations', icon: '📍' }, + { key: 'entity', label: '其他实体', directory: 'elements/entities', icon: '🔶' }, + { key: 'timeline', label: '时间线', directory: 'elements/timeline', icon: '📅' }, + { key: 'plot', label: '情节', directory: 'elements/plots', icon: '📌' }, + { key: 'worldbuilding', label: '设定', directory: 'elements/worldbuilding', icon: '🌐' }, +] diff --git a/packages/app/src/stores/project.ts b/packages/app/src/stores/project.ts index f56b7df..d9814c3 100644 --- a/packages/app/src/stores/project.ts +++ b/packages/app/src/stores/project.ts @@ -17,6 +17,7 @@ import { readFile, refreshFiles, } from '@novai/core/services/file-service' +import { updateConfig } from '@novai/core/services/settings-service' import type { FileContentView, LastProjectSummaryView, @@ -220,6 +221,34 @@ export const useProjectStore = defineStore('project', () => { } } + /** + * 切换当前激活的场景提示词。 + * + * 这条 action 解决了一个已知坑点:直接调 settingsStore.saveConfig 只会更新 settingsStore + * 自己的 config 副本,不会同步到 projectStore.currentProject.config(后者是 setCurrentProject + * 那一刻生成的快照)。分类面板读取的是 projectStore 侧,因此这里在写盘成功后显式调 + * updateCurrentProjectConfig 同步本地,避免 UI 显示过期数据。 + * + * @param path 场景提示词路径,传 null 表示关闭场景 + */ + async function changeActiveScenePromptPath(projectId: string, path: string | null) { + errorMessage.value = '' + + try { + const savedConfig = await updateConfig(projectId, { + settings: { activeScenePromptPath: path }, + }) + updateCurrentProjectConfig(savedConfig) + statusMessage.value = path + ? '已切换场景提示词,新建会话后生效' + : '已关闭场景提示词,新建会话后生效' + return savedConfig + } catch (error) { + errorMessage.value = toMessage(error, '切换场景提示词失败') + return null + } + } + async function runProjectAction(action: () => Promise) { errorMessage.value = '' isBusy.value = true @@ -244,6 +273,7 @@ export const useProjectStore = defineStore('project', () => { lastProjectSummary, recentProjects, statusMessage, + changeActiveScenePromptPath, closeCurrentProject, createNewProject, forgetLastOpenedProject, diff --git a/packages/app/src/utils/file-tree.ts b/packages/app/src/utils/file-tree.ts new file mode 100644 index 0000000..d8cd57e --- /dev/null +++ b/packages/app/src/utils/file-tree.ts @@ -0,0 +1,76 @@ +import type { ProjectFileNodeView } from '@novai/core/services/types' + +/** + * 文件树工具函数。 + * + * currentProject.files 是嵌套结构:顶层是项目根目录的直接条目(chapters/、elements/ 等 + * 目录节点 + novel.config.json 文件),目录的子项在 node.children 里。 + * + * 分类面板需要按业务前缀从这棵嵌套树里提取内容,这里集中维护提取逻辑。 + */ + +/** + * 在嵌套树里找到路径精确匹配 prefix 的目录节点,返回它的直接子节点(保持目录层级)。 + * + * 用于章节页:取 chapters/ 目录的直接子项(可能含子目录「卷/部」+ 章节文件)。 + * 用于提示词页:取 prompts/ 目录的直接子项(system.md + scenes/ 子目录)。 + * + * @param files 全量嵌套树 + * @param prefix 目录路径,如 'chapters' 或 'prompts' + * @returns 该目录的直接子节点数组;目录不存在时返回 [] + */ +export function pickDirectoryChildren( + files: ProjectFileNodeView[], + prefix: string, +): ProjectFileNodeView[] { + for (const node of files) { + if (node.kind === 'directory' && node.path === prefix) { + return node.children ?? [] + } + // 递归往子目录里找(应对嵌套较深的场景) + if (node.kind === 'directory' && node.children?.length) { + const found = pickDirectoryChildren(node.children, prefix) + if (found.length > 0) { + return found + } + } + } + return [] +} + +/** + * 递归收集某目录前缀下的所有文件节点(拍平,不含目录)。 + * + * 用于要素页:取 elements/characters/ 等目录下的所有 .md 文件, + * 无论嵌套多深都收集为扁平列表。 + * + * 注意:必须**无条件递归遍历所有目录**,因为目标目录的祖先(如 elements/) + * 自身路径不以 'elements/characters/' 开头,但它的 children 里藏着目标文件。 + * 只在收集 file 节点时做前缀判断即可。 + * + * @param files 全量嵌套树 + * @param prefix 目录路径前缀,如 'elements/characters' + * @returns 该前缀下所有 file 节点(已扁平化);目录不存在或为空时返回 [] + */ +export function collectFilesByPrefix( + files: ProjectFileNodeView[], + prefix: string, +): ProjectFileNodeView[] { + const result: ProjectFileNodeView[] = [] + const visit = (nodes: ProjectFileNodeView[]) => { + for (const node of nodes) { + // file 节点:路径前缀匹配则收集 + if (node.kind === 'file' && node.path.startsWith(`${prefix}/`)) { + result.push(node) + continue + } + // directory 节点:无条件递归进 children(祖先目录的 path 不匹配前缀, + // 但目标文件可能藏在更深层) + if (node.kind === 'directory' && node.children?.length) { + visit(node.children) + } + } + } + visit(files) + return result +} diff --git a/packages/app/src/views/ProjectView.vue b/packages/app/src/views/ProjectView.vue index 75c8a30..bddeffa 100644 --- a/packages/app/src/views/ProjectView.vue +++ b/packages/app/src/views/ProjectView.vue @@ -4,11 +4,13 @@ import { useRoute, useRouter } from 'vue-router' import { useProjectStore } from '../stores/project' import { useChatStore } from '../stores/chat' import { useToast } from '../composables/useToast' -import FileTreeSidebar from '../components/layout/FileTreeSidebar.vue' +import ActivityBar from '../components/layout/ActivityBar.vue' +import CategoryPanel from '../components/layout/CategoryPanel.vue' import ChatPanel from '../components/layout/ChatPanel.vue' import ContentPanel from '../components/layout/ContentPanel.vue' import Toast from '../components/ui/Toast.vue' import FirstTimeGuide from '../components/ui/FirstTimeGuide.vue' +import type { Category } from '../constants/category' const route = useRoute() const router = useRouter() @@ -17,11 +19,17 @@ const chatStore = useChatStore() const toast = useToast() const projectId = computed(() => route.params.id as string) -const isSidebarOpen = ref(true) +const activeCategory = ref('chapter') +const isCategoryOpen = ref(true) +const isMobileCategoryOpen = ref(false) const isContentPanelOpen = ref(false) -const isMobileSidebarOpen = ref(false) const showGuide = ref(false) +/** 从 currentProject 取配置里的激活场景路径(分类面板需读取它做高亮联动) */ +const activeScenePromptPath = computed(() => { + return projectStore.currentProject?.config.settings.activeScenePromptPath ?? null +}) + onMounted(async () => { // 如果没有当前项目,尝试恢复 if (!projectStore.currentProject) { @@ -46,13 +54,13 @@ onMounted(async () => { } }) -// 监听文件变更,自动刷新文件树 +// 监听文件变更,自动刷新分类面板数据 watch( () => chatStore.changedFiles.length, async (newLength, oldLength) => { if (newLength > oldLength && projectStore.currentProject) { await projectStore.refreshTree() - toast.success('文件树已更新') + toast.success('文件列表已更新') } }, ) @@ -74,8 +82,20 @@ function handleBackToHome() { router.push('/') } +/** + * 打开设置。 + * R1 阶段设置模态框尚未实现(R2 的工作),这里先给提示,保留入口。 + */ function handleOpenSettings() { - router.push(`/project/${projectId.value}/settings`) + toast.info('设置入口将在下一阶段改为弹窗') +} + +/** + * 功能占位项统一反馈。 + * 校对、章节整理、版本管理暂未实现,保留入口但提示「即将推出」。 + */ +function handleNotImplemented(name: string) { + toast.info(`${name}功能即将推出`) } async function handleSelectFile(path: string) { @@ -83,13 +103,23 @@ async function handleSelectFile(path: string) { isContentPanelOpen.value = true } -async function handleRefreshTree() { - await projectStore.refreshTree() - toast.success('文件树已刷新') +async function handleChangeScene(path: string | null) { + const saved = await projectStore.changeActiveScenePromptPath(projectId.value, path) + if (saved) { + toast.info(path ? '已切换场景提示词,新建会话后生效' : '已关闭场景提示词,新建会话后生效') + } else { + toast.error(projectStore.errorMessage || '切换场景提示词失败') + } +} + +function selectCategory(category: Category) { + activeCategory.value = category + // 切换分类时确保面板展开 + isCategoryOpen.value = true } function toggleSidebar() { - isSidebarOpen.value = !isSidebarOpen.value + isCategoryOpen.value = !isCategoryOpen.value } function toggleContentPanel() { @@ -97,7 +127,7 @@ function toggleContentPanel() { } function toggleMobileSidebar() { - isMobileSidebarOpen.value = !isMobileSidebarOpen.value + isMobileCategoryOpen.value = !isMobileCategoryOpen.value } function handleCloseGuide() { @@ -117,30 +147,41 @@ async function handleElementsWritten() {