fix: distinguish Apps and Settings in the sidebar footer

Keep the horizontal footer layout with visible labels, matching button heights, a subtle divider, and an Apps dropdown indicator. Use a layout icon for Manage Apps so it is distinct from global Settings.

Release note: Make the bottom-left Apps and Settings entries easier to distinguish with labeled buttons and separate visual boundaries.
This commit is contained in:
mesalogo
2026-09-28 18:08:52 +08:00
parent c11f204b17
commit 8650907b75
14 changed files with 71 additions and 45 deletions
+3 -2
View File
@@ -141,10 +141,11 @@ records are listed separately and do not introduce another feature status.
models, approval policy, budgets, and timeouts in a Project, with reusable models, approval policy, budgets, and timeouts in a Project, with reusable
templates. templates.
- [x] **Application center and navigation**: - [x] **Application center and navigation**:
Clicking the bottom App Center opens a lightweight upward anchored popup with enabled apps, The footer shows equal-height Apps and Settings buttons with visible labels and a subtle vertical divider.
Clicking Apps, marked with a downward chevron, opens a lightweight upward anchored popup with enabled apps,
regardless of pinning or opening history, and no modal backdrop. Clicking an app row closes regardless of pinning or opening history, and no modal backdrop. Clicking an app row closes
the popup; Local Inference Monitor opens a separate modal preserving the workspace, while other apps the popup; Local Inference Monitor opens a separate modal preserving the workspace, while other apps
open their main content pages. Manage Apps opens searchable cards with settings details; open their main content pages. Manage Apps uses a LayoutGrid icon and opens Application Center with searchable cards and settings details;
layout and interaction rules are defined in the [UI design](./docs/features/application-tool-navigation/ui-design.md). layout and interaction rules are defined in the [UI design](./docs/features/application-tool-navigation/ui-design.md).
All four application cards support arrow and drag reordering regardless of enablement or pinning. All four application cards support arrow and drag reordering regardless of enablement or pinning.
Cards, the launcher, and the filtered sidebar share one persisted order. Knowledge Cards, the launcher, and the filtered sidebar share one persisted order. Knowledge
+1 -1
View File
@@ -36,7 +36,7 @@
- [x] **AI 回复与完整会话复制**:已完成的 AI 回复可从消息底部复制不含推理、工具日志和引用元数据的 Markdown 原文;完整会话复制复用同一条经过 Preload/Main 校验的剪贴板路径。 - [x] **AI 回复与完整会话复制**:已完成的 AI 回复可从消息底部复制不含推理、工具日志和引用元数据的 Markdown 原文;完整会话复制复用同一条经过 Preload/Main 校验的剪贴板路径。
- [x] **助手工作栏、多终端与可调布局**:工作栏开关位于明暗主题按钮旁;右侧工作栏使用持久“+”能力目录和应用 Tab,任务中心与工作区为不可关闭的单实例,浏览器可打开多个独立实例。任务中心可切换当前项目和所有项目,范围随布局保存;无项目任务在所有项目中标注“未绑定项目”。用户可为当前本机或托管 SSH 项目打开多个独立终端,获得有界输出、调整尺寸、结束与显式重连。关闭终端 Tab 会结束 Shell,应用重启只恢复已结束的 Tab 描述,不自动重启 Shell。主侧栏和魔法笔记编辑器的 AI 评论栏支持鼠标与键盘调宽;AI 评论栏记住显示状态及宽度,窄窗口下移至编辑器底部。 - [x] **助手工作栏、多终端与可调布局**:工作栏开关位于明暗主题按钮旁;右侧工作栏使用持久“+”能力目录和应用 Tab,任务中心与工作区为不可关闭的单实例,浏览器可打开多个独立实例。任务中心可切换当前项目和所有项目,范围随布局保存;无项目任务在所有项目中标注“未绑定项目”。用户可为当前本机或托管 SSH 项目打开多个独立终端,获得有界输出、调整尺寸、结束与显式重连。关闭终端 Tab 会结束 Shell,应用重启只恢复已结束的 Tab 描述,不自动重启 Shell。主侧栏和魔法笔记编辑器的 AI 评论栏支持鼠标与键盘调宽;AI 评论栏记住显示状态及宽度,窄窗口下移至编辑器底部。
- [ ] **项目 Agent Space**(规划中):在 Project 中统一角色、知识、Skills/MCP、模型、审批策略、预算和超时,并支持模板复用。 - [ ] **项目 Agent Space**(规划中):在 Project 中统一角色、知识、Skills/MCP、模型、审批策略、预算和超时,并支持模板复用。
- [x] **应用中心与导航**:底部应用中心向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻筛选。点击应用行关闭菜单,本机推理监控打开保留底层工作区的独立 Modal,其余应用打开主内容区;底部“管理应用”打开可搜索卡片及设置详情,布局与交互见 [UI 设计](./docs/features/application-tool-navigation/ui-design.md)。四张应用卡片均支持上下移动和拖动排序,不受启用或常驻状态限制;卡片、菜单和经过过滤的侧栏共用一份保存顺序。知识库始终启用并常驻,提供“打开”和排序;监督者、魔法笔记和本机推理监控可设置启用与常驻。监督者沿用智能心跳身份,缺失启用设置时默认关闭,已明确保存的选择保留;关闭后保留计划和历史,拒绝新回顾并隐藏侧栏反馈,在途运行可完成。启用应用不会自动创建计划。旧应用顺序补齐默认项并保留已有相对顺序。设置持久化后的事件同步各入口,包括工具配置写入;重开中心刷新期间锁定修改,新快照覆盖迟到的旧读取。`local-inference` Modal 直接显示服务列表,不展示任务历史或外部连接;向量服务显示独立进程 CPU 与工作集内存,ASR/OCR 说明共享进程无法独立统计的原因。ASR 就绪状态未知,不从活动请求推断运行中。受支持的服务操作保留影响确认,失败后刷新列表并要求重新确认;TTS 显示不可用。取消中的推理在 Worker 确认或退出前仍计入活动任务及服务停止影响。实现已接入生产 App;验收记录分别列出完整 App、组件 fixture、真实本地引擎和剩余平台/安装包覆盖,不以历史失败代替当前全量结果,详见[验证进度](./docs/features/application-tool-navigation/progress.md)。 - [x] **应用中心与导航**:底部“应用”和“设置”为显示文字的等高按钮,中间使用弱竖分隔线。“应用”带向下箭头,点击后向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻筛选。点击应用行关闭菜单,本机推理监控打开保留底层工作区的独立 Modal,其余应用打开主内容区;底部“管理应用”使用 `LayoutGrid` 图标,打开标题仍为“应用中心”的可搜索卡片及设置详情,布局与交互见 [UI 设计](./docs/features/application-tool-navigation/ui-design.md)。四张应用卡片均支持上下移动和拖动排序,不受启用或常驻状态限制;卡片、菜单和经过过滤的侧栏共用一份保存顺序。知识库始终启用并常驻,提供“打开”和排序;监督者、魔法笔记和本机推理监控可设置启用与常驻。监督者沿用智能心跳身份,缺失启用设置时默认关闭,已明确保存的选择保留;关闭后保留计划和历史,拒绝新回顾并隐藏侧栏反馈,在途运行可完成。启用应用不会自动创建计划。旧应用顺序补齐默认项并保留已有相对顺序。设置持久化后的事件同步各入口,包括工具配置写入;重开中心刷新期间锁定修改,新快照覆盖迟到的旧读取。`local-inference` Modal 直接显示服务列表,不展示任务历史或外部连接;向量服务显示独立进程 CPU 与工作集内存,ASR/OCR 说明共享进程无法独立统计的原因。ASR 就绪状态未知,不从活动请求推断运行中。受支持的服务操作保留影响确认,失败后刷新列表并要求重新确认;TTS 显示不可用。取消中的推理在 Worker 确认或退出前仍计入活动任务及服务停止影响。实现已接入生产 App;验收记录分别列出完整 App、组件 fixture、真实本地引擎和剩余平台/安装包覆盖,不以历史失败代替当前全量结果,详见[验证进度](./docs/features/application-tool-navigation/progress.md)。
- [ ] **私有化应用市场**(规划中):在应用管理基础上提供组织自己的目录与应用分发。当前只管理内置应用,尚无市场浏览或安装能力,详见 [FR-15](./docs/features/application-tool-navigation/prd.md#fr-15-私有化市场与-yaml-交换后续)。 - [ ] **私有化应用市场**(规划中):在应用管理基础上提供组织自己的目录与应用分发。当前只管理内置应用,尚无市场浏览或安装能力,详见 [FR-15](./docs/features/application-tool-navigation/prd.md#fr-15-私有化市场与-yaml-交换后续)。
- [ ] **通用助手工作栏与执行空间后续能力**(规划中):在现有工作栏和多终端基础上继续加入更完整的执行监督、统一 Runtime 监控、受管进程、可固定目标的工作区/浏览器/成果实例、底部停靠与独立窗口。Task Center 保持 Task 的单例索引,附件与知识库继续由会话输入区管理,未来的记忆与历史执行上下文归入关联 Task。详见 [Feature PRD](./docs/features/assistant-workbar/prd.md)。 - [ ] **通用助手工作栏与执行空间后续能力**(规划中):在现有工作栏和多终端基础上继续加入更完整的执行监督、统一 Runtime 监控、受管进程、可固定目标的工作区/浏览器/成果实例、底部停靠与独立窗口。Task Center 保持 Task 的单例索引,附件与知识库继续由会话输入区管理,未来的记忆与历史执行上下文归入关联 Task。详见 [Feature PRD](./docs/features/assistant-workbar/prd.md)。
- [ ] **ShareServer Office 协同编辑**(设计中):通过可选 ShareServer 集成 ONLYOFFICE Docs,在助手工作栏中以文件名打开多个文档 Tab,支持 DOCX、XLSX、PPTX 的人工编辑、保存、撤销、源文件冲突保护和后续 AI 选区修改。Office 正文会由所选 ShareServer 和编辑引擎处理;Desktop 不内置或启动 Document Server,未配置服务时不宣称离线编辑。详见[设计文档](./docs/features/office-document-editing/README.md)。 - [ ] **ShareServer Office 协同编辑**(设计中):通过可选 ShareServer 集成 ONLYOFFICE Docs,在助手工作栏中以文件名打开多个文档 Tab,支持 DOCX、XLSX、PPTX 的人工编辑、保存、撤销、源文件冲突保护和后续 AI 选区修改。Office 正文会由所选 ShareServer 和编辑引擎处理;Desktop 不内置或启动 Document Server,未配置服务时不宣称离线编辑。详见[设计文档](./docs/features/office-document-editing/README.md)。
+1 -1
View File
@@ -329,7 +329,7 @@
接受用户本机选择的 PNG、JPEG 或 WebP 内联数据,宽高不超过 `4096 × 4096`、总像素不 接受用户本机选择的 PNG、JPEG 或 WebP 内联数据,宽高不超过 `4096 × 4096`、总像素不
超过 1600 万且大小不超过 `512 KB`;不得读取远程 Logo URL。名称或副标题溢出品牌区时 超过 1600 万且大小不超过 `512 KB`;不得读取远程 Logo URL。名称或副标题溢出品牌区时
单行截断,不能撑宽或挤压侧栏。 单行截断,不能撑宽或挤压侧栏。
- 侧栏底部提供应用中心入口,设置按钮独立靠右。点击应用中心向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻状态筛选;点击行关闭菜单,知识库、智能心跳和魔法笔记在主内容区打开页面。本机推理监控从菜单、常驻入口或管理 Modal 打开时均使用独立 Modal,保留底层工作区。菜单底部“管理应用”打开既有整窗管理 Modal,共同应用设置沿用设置 Modal。 - 侧栏底部提供“应用 / Apps”和“设置 / Settings”两个横向等高 `nav-item` 按钮,均显示图标和文字,中间使用弱竖分隔线。“应用”带向下箭头,点击后向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻状态筛选;点击行关闭菜单,知识库、智能心跳和魔法笔记在主内容区打开页面。本机推理监控从菜单、常驻入口或管理 Modal 打开时均使用独立 Modal,保留底层工作区。菜单底部“管理应用”使用 `LayoutGrid` 图标,打开既有整窗管理 Modal,标题仍为“应用中心 / Application Center”,共同应用设置沿用设置 Modal。
四个内置应用的管理列表、菜单和侧栏共用保存顺序,菜单按启用过滤,侧栏按启用且常驻过滤。知识库、智能心跳始终启用并常驻,可调整位置;管理 Modal 提供“打开”和排序,不提供启用、常驻开关或通用空设置,标签为“始终显示”(英文 `Always shown`)。原工作区配置及心跳单条计划 enabled 保持不变。只有魔法笔记、本机推理监控可修改启用和常驻,取消常驻不关闭功能。当前对话、运行记录和设置保持固定系统位置,不参与应用排序。 四个内置应用的管理列表、菜单和侧栏共用保存顺序,菜单按启用过滤,侧栏按启用且常驻过滤。知识库、智能心跳始终启用并常驻,可调整位置;管理 Modal 提供“打开”和排序,不提供启用、常驻开关或通用空设置,标签为“始终显示”(英文 `Always shown`)。原工作区配置及心跳单条计划 enabled 保持不变。只有魔法笔记、本机推理监控可修改启用和常驻,取消常驻不关闭功能。当前对话、运行记录和设置保持固定系统位置,不参与应用排序。
设置持久化后的事件同步各入口及工具配置写入;重开中心刷新期间锁定修改,新快照不被旧读取覆盖。文件、终端、浏览器和资源监控只通过右侧工作栏访问,不在左侧复制入口。 设置持久化后的事件同步各入口及工具配置写入;重开中心刷新期间锁定修改,新快照不被旧读取覆盖。文件、终端、浏览器和资源监控只通过右侧工作栏访问,不在左侧复制入口。
管理 Modal 始终使用紧凑单列横向列表,纵向对应保存顺序;左侧为图标、名称及简短用途,右侧为打开、可用的设置及上下移动操作,行间使用弱分隔线,不加卡片外框。窄窗口操作换行且不溢出,保留共享键盘焦点环。可选应用关闭时禁用“打开”。启用、常驻位于紧凑设置详情;四行均提供上下移动及拖动排序,不受启用或常驻影响,箭头按完整顺序判断边界。搜索时禁用拖动,箭头仍按完整顺序移动,不丢失未命中项。尺寸、返回与滚动重置规则见[应用中心 UI 设计](./docs/features/application-tool-navigation/ui-design.md)。已有稳定设置入口时,不在顶栏重复提供同一入口。 管理 Modal 始终使用紧凑单列横向列表,纵向对应保存顺序;左侧为图标、名称及简短用途,右侧为打开、可用的设置及上下移动操作,行间使用弱分隔线,不加卡片外框。窄窗口操作换行且不溢出,保留共享键盘焦点环。可选应用关闭时禁用“打开”。启用、常驻位于紧凑设置详情;四行均提供上下移动及拖动排序,不受启用或常驻影响,箭头按完整顺序判断边界。搜索时禁用拖动,箭头仍按完整顺序移动,不丢失未命中项。尺寸、返回与滚动重置规则见[应用中心 UI 设计](./docs/features/application-tool-navigation/ui-design.md)。已有稳定设置入口时,不在顶栏重复提供同一入口。
@@ -1,7 +1,7 @@
# 应用导航与系统工具入口 # 应用导航与系统工具入口
本功能整理 GoodBuddy 主侧栏的入口层级:系统入口保持固定,四个内置应用共用可调整的保存顺序,知识库始终启用并常驻; 本功能整理 GoodBuddy 主侧栏的入口层级:系统入口保持固定,四个内置应用共用可调整的保存顺序,知识库始终启用并常驻;
智能心跳、魔法笔记和本机推理监控可通过应用管理控制启用和常驻。底部应用中心向上展开轻量锚定菜单,列出已启用应用;底部“管理应用”打开既有管理 Modal。系统工具由右侧工作栏 Tab 承载。应用商店是后续功能,首期不提供 智能心跳、魔法笔记和本机推理监控可通过应用管理控制启用和常驻。底部“应用”向上展开轻量锚定菜单,列出已启用应用;底部“管理应用”打开既有“应用中心”管理 Modal。系统工具由右侧工作栏 Tab 承载。应用商店是后续功能,首期不提供
商店入口、推荐位或安装流程。 商店入口、推荐位或安装流程。
当前分期见 [PRD 第 8 节](./prd.md#8-分期),实施步骤见[技术设计第 11 节](./technical-design.md#11-实施顺序)。 当前分期见 [PRD 第 8 节](./prd.md#8-分期),实施步骤见[技术设计第 11 节](./technical-design.md#11-实施顺序)。
@@ -86,7 +86,7 @@
### FR-5 应用中心入口 ### FR-5 应用中心入口
侧栏底部提供应用中心和独立设置入口。点击应用中心向上展开锚定按钮的轻量菜单,无模态遮罩。菜单列出已启用应用,不按是否打开过、历史记录或常驻状态筛选;点击应用行关闭菜单,本机推理监控打开 Modal,其余应用在主内容区打开页面。 侧栏底部提供“应用”(英文 `Apps`)和独立“设置”入口,两个按钮均显示文字。点击“应用”向上展开锚定按钮的轻量菜单,无模态遮罩。菜单列出已启用应用,不按是否打开过、历史记录或常驻状态筛选;点击应用行关闭菜单,本机推理监控打开 Modal,其余应用在主内容区打开页面。
菜单底部“管理应用”关闭菜单并打开既有管理 Modal,在其中浏览、搜索全部内置应用,为已关闭的可选应用提供设置与重新开启入口。无搜索结果时显示空状态并可清除搜索。 菜单底部“管理应用”关闭菜单并打开既有管理 Modal,在其中浏览、搜索全部内置应用,为已关闭的可选应用提供设置与重新开启入口。无搜索结果时显示空状态并可清除搜索。
首期不展示商店、安装、推荐或系统工具清单。入口具有可访问名称,Escape 关闭后恢复触发位置的焦点。 首期不展示商店、安装、推荐或系统工具清单。入口具有可访问名称,Escape 关闭后恢复触发位置的焦点。
@@ -175,17 +175,21 @@ Main 继续校验可信 sender、Zod 输入,复用 `ApplicationSettingsStore`
原 `user-card` 整行设置按钮已替换为容器和相邻独立按钮: 原 `user-card` 整行设置按钮已替换为容器和相邻独立按钮:
```tsx ```tsx
<div className="sidebar-footer"> <div className="sidebar-footer sidebar-footer--applications">
<button onClick={toggleApplicationMenu} aria-haspopup="menu" aria-expanded={applicationMenuOpen} ...> <button className="nav-item" onClick={toggleApplicationMenu} aria-haspopup="menu" aria-expanded={applicationMenuOpen} ...>
{t('navigation.applicationCenter')} <Grid2X2 aria-hidden="true" />
<span>{t('applications.menuLabel')}</span>
<ChevronDown aria-hidden="true" />
</button> </button>
<button aria-label={t('navigation.settings')} ...> <div className="sidebar-footer__divider" aria-hidden="true" />
<button className="nav-item" aria-label={t('navigation.settings')} ...>
<Settings aria-hidden="true" /> <Settings aria-hidden="true" />
<span>{t('navigation.settings')}</span>
</button> </button>
</div> </div>
``` ```
应用中心和设置为相邻独立按钮。设置按钮继续调用 `navigateFromSidebar('settings', trigger)`, “应用”(`applications.menuLabel`:中文“应用”、英文 `Apps`)和设置为横向等高独立按钮,使用 `nav-item` 样式和弱竖分隔线;“管理应用”菜单项使用 `LayoutGrid` 图标,管理 Modal 标题保持“应用中心” / `Application Center`。设置按钮继续调用 `navigateFromSidebar('settings', trigger)`,
保留设置预加载、焦点恢复和未保存离开确认。该调用只打开设置 Modal,不更新当前工作区或 保留设置预加载、焦点恢复和未保存离开确认。该调用只打开设置 Modal,不更新当前工作区或
KeepAlive 缓存;`settingsOpen` 独立管理打开状态。关闭后卸载设置内容,下次按入口指定的分类 KeepAlive 缓存;`settingsOpen` 独立管理打开状态。关闭后卸载设置内容,下次按入口指定的分类
和消息通道重新挂载,避免缓存上次分类覆盖本次直达目标。底层工作区继续挂载,普通关闭不跳回聊天。 和消息通道重新挂载,避免缓存上次分类覆盖本次直达目标。底层工作区继续挂载,普通关闭不跳回聊天。
@@ -32,7 +32,7 @@
... ...
──────────────────────── ────────────────────────
[应用中心] [设置] [应用 v] | [设置]
``` ```
“当前对话”和“运行记录”属于固定系统入口,常驻应用位于二者之间。首期应用清单为魔法笔记、 “当前对话”和“运行记录”属于固定系统入口,常驻应用位于二者之间。首期应用清单为魔法笔记、
@@ -41,15 +41,15 @@
## 2. 底部应用中心入口 ## 2. 底部应用中心入口
底部使用连续平整表面和顶部分隔线,应用中心在前,固定设置靠右,二者为独立按钮: 底部使用连续平整表面和顶部分隔线,“应用”在前,“设置”靠右,二者为横向排列的等高 `nav-item` 独立按钮,中间使用弱竖分隔线:
| 位置 | 图标 | 可见工具提示 | 点击结果 | | 位置 | 图标 | 可见文字 | 点击结果 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 1 | `Grid2X2` | 应用中心 | 向上展开已启用应用菜单,底部“管理应用”进入管理 Modal | | 1 | `Grid2X2` + `ChevronDown` | 应用 / Apps | 向上展开已启用应用菜单,底部“管理应用”进入管理 Modal |
| 末位 | `Settings` | 设置 | 在当前工作区上打开设置 Modal | | 末位 | `Settings` | 设置 / Settings | 在当前工作区上打开设置 Modal |
图标使用项目现有 Lucide 库。应用中心保留文字,设置图标采用统一的 `34px × 34px` 点击区域。 图标使用项目现有 Lucide 库。两个按钮均保留可见文字,沿用主导航的高度、间距和焦点样式;
底部使用共享间距和焦点样式,设置通过弹性空白靠右;不得把整个容器做成设置按钮。 “应用”文字后显示向下箭头,菜单仍向上展开。不得把整个容器做成设置按钮。
### 2.1 状态 ### 2.1 状态
@@ -84,7 +84,7 @@
菜单通过共享 `AnchoredMenu` 挂载到 body,宽度最多 `280px`,距窗口边缘至少 `16px`,与按钮上缘间隔 `8px`;短窗口限制高度并内部滚动,滚动、窗口和内容尺寸变化时重算位置。原生浏览器相交遮挡复用共享机制。 菜单通过共享 `AnchoredMenu` 挂载到 body,宽度最多 `280px`,距窗口边缘至少 `16px`,与按钮上缘间隔 `8px`;短窗口限制高度并内部滚动,滚动、窗口和内容尺寸变化时重算位置。原生浏览器相交遮挡复用共享机制。
管理 Modal 沿用设置 Modal 的表面、遮罩和焦点规则,提供全部内置应用的名称及用途搜索、打开,以及可选应用的常驻和设置: “管理应用”菜单项使用 `LayoutGrid` 图标。管理 Modal 标题仍为“应用中心”(英文 `Application Center`),沿用设置 Modal 的表面、遮罩和焦点规则,提供全部内置应用的名称及用途搜索、打开,以及可选应用的常驻和设置:
应用管理始终采用紧凑单列横向列表,从上到下对应保存顺序;行间使用 `--border-subtle` 弱分隔线,不加卡片外框。概览与设置详情共用 `640px` 最大宽度,窄窗口保留两侧各 `16px` 外边距,高度随内容增长并受窗口限制。 应用管理始终采用紧凑单列横向列表,从上到下对应保存顺序;行间使用 `--border-subtle` 弱分隔线,不加卡片外框。概览与设置详情共用 `640px` 最大宽度,窄窗口保留两侧各 `16px` 外边距,高度随内容增长并受窗口限制。
每行左侧显示图标、名称、状态和简短用途,右侧对齐打开、可用的设置及上下移动操作;空间不足时操作组换到下一行并保持右对齐,组内也可换行,上下箭头保持成组。名称旁区分 每行左侧显示图标、名称、状态和简短用途,右侧对齐打开、可用的设置及上下移动操作;空间不足时操作组换到下一行并保持右对齐,组内也可换行,上下箭头保持成组。名称旁区分
@@ -189,7 +189,8 @@ CPU 4% · 内存 620 MB · 桌面进程 7
| 对象 | 文案 | | 对象 | 文案 |
| --- | --- | | --- | --- |
| 底部入口 | 应用中心 | | 底部入口 | 应用 / Apps |
| 管理 Modal 标题 | 应用中心 / Application Center |
| 菜单底部管理入口 | 管理应用 | | 菜单底部管理入口 | 管理应用 |
| 应用设置开关 | 启用应用 | | 应用设置开关 | 启用应用 |
| 文件系统工具 | 文件 | | 文件系统工具 | 文件 |
@@ -42,7 +42,7 @@
- Given 任意主页面和任意侧栏宽度 - Given 任意主页面和任意侧栏宽度
- When 用户查看侧栏底部 - When 用户查看侧栏底部
- Then 设置与应用中心为始终可见的独立按钮,点击区域不重叠 - Then “设置”与“应用”为始终显示文字的独立按钮,点击区域不重叠
- And 点击后继续遵守未保存内容的离开确认 - And 点击后继续遵守未保存内容的离开确认
## 3. Epic B:管理应用入口 ## 3. Epic B:管理应用入口
+21 -18
View File
@@ -6204,7 +6204,7 @@ describe("App", () => {
name: "选择知识库,本次已启用 1 个", name: "选择知识库,本次已启用 1 个",
}), }),
).toBeInTheDocument(); ).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '应用中心' })); fireEvent.click(screen.getByRole('button', { name: '应用' }));
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' })); fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }));
fireEvent.change(screen.getByRole('searchbox'), { target: { value: '知识库' } }); fireEvent.change(screen.getByRole('searchbox'), { target: { value: '知识库' } });
expect(within(screen.getByRole('dialog')).queryByRole('switch')).not.toBeInTheDocument(); expect(within(screen.getByRole('dialog')).queryByRole('switch')).not.toBeInTheDocument();
@@ -13904,7 +13904,7 @@ describe("App", () => {
expect(screen.queryByRole("button", { name: "应用设置" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "应用设置" })).not.toBeInTheDocument();
expect(api.updates.updateSettings).not.toHaveBeenCalled(); expect(api.updates.updateSettings).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "关闭设置" })); fireEvent.click(screen.getByRole("button", { name: "关闭设置" }));
fireEvent.click(screen.getByRole("button", { name: "应用中心" })); fireEvent.click(screen.getByRole("button", { name: "应用" }));
fireEvent.click(screen.getByRole("menuitem", { name: "管理应用" })); fireEvent.click(screen.getByRole("menuitem", { name: "管理应用" }));
fireEvent.click(screen.getByRole("button", { name: "魔法笔记 应用设置" })); fireEvent.click(screen.getByRole("button", { name: "魔法笔记 应用设置" }));
const toggle = await screen.findByRole("switch", { const toggle = await screen.findByRole("switch", {
@@ -13942,7 +13942,7 @@ describe("App", () => {
await updates.updateSettings({ magicNotesEnabled: true }) await updates.updateSettings({ magicNotesEnabled: true })
render(<App />) render(<App />)
await screen.findByRole('button', { name: '魔法笔记' }) await screen.findByRole('button', { name: '魔法笔记' })
fireEvent.click(screen.getByRole('button', { name: '应用中心' })) fireEvent.click(screen.getByRole('button', { name: '应用' }))
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' })) fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
const move = screen.getByRole('button', { name: '下移 知识库' }) const move = screen.getByRole('button', { name: '下移 知识库' })
await waitFor(() => expect(move).toBeEnabled()) await waitFor(() => expect(move).toBeEnabled())
@@ -13962,7 +13962,7 @@ describe("App", () => {
expect(within(screen.getByRole('navigation', { name: '主导航' })).getAllByRole('button').map(button => button.textContent)).toEqual([ expect(within(screen.getByRole('navigation', { name: '主导航' })).getAllByRole('button').map(button => button.textContent)).toEqual([
'对话', '监督者', '知识库', '魔法笔记', '运行记录', '对话', '监督者', '知识库', '魔法笔记', '运行记录',
]) ])
fireEvent.click(screen.getByRole('button', { name: '应用中心' })) fireEvent.click(screen.getByRole('button', { name: '应用' }))
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '管理应用']) expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '管理应用'])
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' })) fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记']) expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记'])
@@ -13982,7 +13982,7 @@ describe("App", () => {
}, },
} }
vi.mocked(updates.getSettings).mockResolvedValue({ ...external, applicationNavigation: { ...external.applicationNavigation, order: [...external.applicationNavigation.order] } }) vi.mocked(updates.getSettings).mockResolvedValue({ ...external, applicationNavigation: { ...external.applicationNavigation, order: [...external.applicationNavigation.order] } })
fireEvent.click(screen.getByRole('button', { name: '应用中心' })) fireEvent.click(screen.getByRole('button', { name: '应用' }))
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' })) fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' })) fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' }))
const pin = screen.getByRole('switch', { name: '常驻左侧菜单' }) const pin = screen.getByRole('switch', { name: '常驻左侧菜单' })
@@ -14014,7 +14014,7 @@ describe("App", () => {
await screen.findByRole('button', { name: '监督者' }) await screen.findByRole('button', { name: '监督者' })
const reply = deferred<ApplicationSettings>() const reply = deferred<ApplicationSettings>()
if (operation === 'read') vi.mocked(updates.getSettings).mockReturnValueOnce(reply.promise) if (operation === 'read') vi.mocked(updates.getSettings).mockReturnValueOnce(reply.promise)
fireEvent.click(screen.getByRole('button', { name: '应用中心' })) fireEvent.click(screen.getByRole('button', { name: '应用' }))
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' })) fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' })) fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' }))
const pin = screen.getByRole('switch', { name: '常驻左侧菜单' }) const pin = screen.getByRole('switch', { name: '常驻左侧菜单' })
@@ -14181,7 +14181,7 @@ describe("App", () => {
fireEvent.click(screen.getByRole('button', { name: /^笔记搜索/ })) fireEvent.click(screen.getByRole('button', { name: /^笔记搜索/ }))
fireEvent.click(await screen.findByRole('button', { name: '新建笔记' })) fireEvent.click(await screen.findByRole('button', { name: '新建笔记' }))
fireEvent.change(screen.getByLabelText('笔记标题'), { target: { value: 'Unsaved quick note' } }) fireEvent.change(screen.getByLabelText('笔记标题'), { target: { value: 'Unsaved quick note' } })
fireEvent.click(screen.getByRole('button', { name: '应用中心' })) fireEvent.click(screen.getByRole('button', { name: '应用' }))
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' })) fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
const center = await screen.findByRole('dialog', { name: '应用中心' }) const center = await screen.findByRole('dialog', { name: '应用中心' })
fireEvent.click(within(center).getByRole('button', { name: /魔法笔记.*设置|设置.*魔法笔记/ })) fireEvent.click(within(center).getByRole('button', { name: /魔法笔记.*设置|设置.*魔法笔记/ }))
@@ -14235,7 +14235,7 @@ describe("App", () => {
expect(await screen.findByRole('alertdialog')).toBeVisible() expect(await screen.findByRole('alertdialog')).toBeVisible()
expect(screen.queryByRole('button', { name: '关闭设置' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: '关闭设置' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '继续编辑' })) fireEvent.click(screen.getByRole('button', { name: '继续编辑' }))
fireEvent.click(screen.getByRole('button', { name: '应用中心' })) fireEvent.click(screen.getByRole('button', { name: '应用' }))
fireEvent.click(screen.getByRole('menuitem', { name: '知识库' })) fireEvent.click(screen.getByRole('menuitem', { name: '知识库' }))
expect(await screen.findByRole('alertdialog')).toBeVisible() expect(await screen.findByRole('alertdialog')).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: '放弃草稿并切换' })) fireEvent.click(screen.getByRole('button', { name: '放弃草稿并切换' }))
@@ -14263,7 +14263,7 @@ describe("App", () => {
if (entry === 'sidebar') await api.updates!.updateSettings({ applicationNavigation: { ...defaultTestApplicationNavigation, pinned: { ...defaultTestApplicationNavigation.pinned, 'local-inference': true } } }) if (entry === 'sidebar') await api.updates!.updateSettings({ applicationNavigation: { ...defaultTestApplicationNavigation, pinned: { ...defaultTestApplicationNavigation.pinned, 'local-inference': true } } })
render(<App />) render(<App />)
await waitFor(() => expect(api.updates!.getSettings).toHaveBeenCalled()) await waitFor(() => expect(api.updates!.getSettings).toHaveBeenCalled())
const nav = entry === 'sidebar' ? await screen.findByRole('button', { name: '本机推理监控' }) : screen.getByRole('button', { name: '应用中心' }) const nav = entry === 'sidebar' ? await screen.findByRole('button', { name: '本机推理监控' }) : screen.getByRole('button', { name: '应用' })
if (entry !== 'sidebar') expect(within(screen.getByRole('navigation', { name: '主导航' })).getAllByRole('button').map(button => button.textContent)).toEqual(['对话', '知识库', '监督者', '运行记录']) if (entry !== 'sidebar') expect(within(screen.getByRole('navigation', { name: '主导航' })).getAllByRole('button').map(button => button.textContent)).toEqual(['对话', '知识库', '监督者', '运行记录'])
fireEvent.click(screen.getByRole('button', { name: '知识库' })) fireEvent.click(screen.getByRole('button', { name: '知识库' }))
const workspace = await screen.findByLabelText('知识工作区') const workspace = await screen.findByLabelText('知识工作区')
@@ -14271,7 +14271,7 @@ describe("App", () => {
nav.focus() nav.focus()
fireEvent.click(nav) fireEvent.click(nav)
} else { } else {
fireEvent.click(screen.getByRole('button', { name: '应用中心' })) fireEvent.click(screen.getByRole('button', { name: '应用' }))
if (entry === 'menu') fireEvent.click(screen.getByRole('menuitem', { name: '本机推理监控' })) if (entry === 'menu') fireEvent.click(screen.getByRole('menuitem', { name: '本机推理监控' }))
else { else {
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' })) fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
@@ -14290,7 +14290,7 @@ describe("App", () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument() expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.getByLabelText('知识工作区')).toBe(workspace) expect(screen.getByLabelText('知识工作区')).toBe(workspace)
expect(workspace.closest('.app-shell')).toHaveProperty('inert', false) expect(workspace.closest('.app-shell')).toHaveProperty('inert', false)
expect(entry === 'sidebar' ? nav : screen.getByRole('button', { name: '应用中心' })).toHaveFocus() expect(entry === 'sidebar' ? nav : screen.getByRole('button', { name: '应用' })).toHaveFocus()
}) })
it('saves Supervisor timeouts and concurrency through application settings and restores them on reopen', async () => { it('saves Supervisor timeouts and concurrency through application settings and restores them on reopen', async () => {
@@ -14342,7 +14342,7 @@ describe("App", () => {
render(<App />) render(<App />)
expect(await screen.findByRole('button', { name: '监督者' })).toBeInTheDocument() expect(await screen.findByRole('button', { name: '监督者' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '应用中心' })) fireEvent.click(screen.getByRole('button', { name: '应用' }))
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' })) fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
fireEvent.click(screen.getByRole('button', { name: '监督者 应用设置' })) fireEvent.click(screen.getByRole('button', { name: '监督者 应用设置' }))
const enable = screen.getByRole('switch', { name: '启用应用' }) const enable = screen.getByRole('switch', { name: '启用应用' })
@@ -14362,7 +14362,7 @@ describe("App", () => {
{ name: '监督者' }, { name: '监督者' },
), ),
).not.toBeInTheDocument() ).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '应用中心' })) fireEvent.click(screen.getByRole('button', { name: '应用' }))
expect(screen.queryByRole('menuitem', { name: '监督者' })).not.toBeInTheDocument() expect(screen.queryByRole('menuitem', { name: '监督者' })).not.toBeInTheDocument()
expect(api.heartbeats.remove).not.toHaveBeenCalled() expect(api.heartbeats.remove).not.toHaveBeenCalled()
expect(await api.heartbeats.list()).toEqual([plan]) expect(await api.heartbeats.list()).toEqual([plan])
@@ -14377,7 +14377,7 @@ describe("App", () => {
}) })
render(<App />) render(<App />)
const inferenceNav = await screen.findByRole('button', { name: '本机推理监控' }) const inferenceNav = await screen.findByRole('button', { name: '本机推理监控' })
fireEvent.click(screen.getByRole('button', { name: '应用中心' })) fireEvent.click(screen.getByRole('button', { name: '应用' }))
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' })) fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' })) fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' }))
const pin = screen.getByRole('switch', { name: '常驻左侧菜单' }) const pin = screen.getByRole('switch', { name: '常驻左侧菜单' })
@@ -14385,11 +14385,14 @@ describe("App", () => {
fireEvent.click(pin) fireEvent.click(pin)
await waitFor(() => expect(inferenceNav).not.toBeInTheDocument()) await waitFor(() => expect(inferenceNav).not.toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' })) fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' }))
const trigger = screen.getByRole('button', { name: '应用中心' }) const trigger = screen.getByRole('button', { name: '应用', expanded: false })
expect(trigger).toHaveFocus() expect(trigger).toHaveFocus()
expect(within(screen.getByRole('button', { name: '设置' })).getByText('设置')).toBeVisible()
fireEvent.click(trigger) fireEvent.click(trigger)
expect(trigger).toHaveAttribute('aria-expanded', 'true')
expect(screen.queryByRole('dialog')).not.toBeInTheDocument() expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('menuitem', { name: '本机推理监控' })) fireEvent.click(screen.getByRole('menuitem', { name: '本机推理监控' }))
expect(trigger).toHaveAttribute('aria-expanded', 'false')
const workspace = await screen.findByRole('region', { name: '本机推理服务' }) const workspace = await screen.findByRole('region', { name: '本机推理服务' })
expect(within(screen.getByRole('navigation', { name: '主导航' })).queryByRole('button', { name: '本机推理监控' })).not.toBeInTheDocument() expect(within(screen.getByRole('navigation', { name: '主导航' })).queryByRole('button', { name: '本机推理监控' })).not.toBeInTheDocument()
await act(async () => changed(await api.updates!.updateSettings({ localInferenceEnabled: false }))) await act(async () => changed(await api.updates!.updateSettings({ localInferenceEnabled: false })))
@@ -14403,14 +14406,14 @@ describe("App", () => {
expect((await api.updates!.getSettings()).applicationNavigation.pinned['local-inference']).toBe(false) expect((await api.updates!.getSettings()).applicationNavigation.pinned['local-inference']).toBe(false)
await act(async () => changed(await api.updates!.updateSettings({ localInferenceEnabled: false }))) await act(async () => changed(await api.updates!.updateSettings({ localInferenceEnabled: false })))
fireEvent.click(screen.getByRole('button', { name: '关闭本机推理监控' })) fireEvent.click(screen.getByRole('button', { name: '关闭本机推理监控' }))
fireEvent.click(screen.getByRole('button', { name: '应用中心' })) fireEvent.click(screen.getByRole('button', { name: '应用' }))
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' })) fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' })) fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' }))
await waitFor(() => expect(screen.getByRole('switch', { name: '启用应用' })).not.toBeDisabled()) await waitFor(() => expect(screen.getByRole('switch', { name: '启用应用' })).not.toBeDisabled())
fireEvent.click(screen.getByRole('switch', { name: '启用应用' })) fireEvent.click(screen.getByRole('switch', { name: '启用应用' }))
await waitFor(() => expect(screen.getByRole('switch', { name: '启用应用' })).toBeChecked()) await waitFor(() => expect(screen.getByRole('switch', { name: '启用应用' })).toBeChecked())
fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' })) fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' }))
fireEvent.click(screen.getByRole('button', { name: '应用中心' })) fireEvent.click(screen.getByRole('button', { name: '应用' }))
fireEvent.click(screen.getByRole('menuitem', { name: '本机推理监控' })) fireEvent.click(screen.getByRole('menuitem', { name: '本机推理监控' }))
expect(await screen.findByRole('region', { name: '本机推理服务' })).toBeVisible() expect(await screen.findByRole('region', { name: '本机推理服务' })).toBeVisible()
}) })
@@ -14418,7 +14421,7 @@ describe("App", () => {
it('keeps confirmed application preferences and locks edits until an unknown save is read back', async () => { it('keeps confirmed application preferences and locks edits until an unknown save is read back', async () => {
render(<App />) render(<App />)
await screen.findByRole('button', { name: '知识库' }) await screen.findByRole('button', { name: '知识库' })
fireEvent.click(screen.getByRole('button', { name: '应用中心' })) fireEvent.click(screen.getByRole('button', { name: '应用' }))
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' })) fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
const updates = api.updates! const updates = api.updates!
fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' })) fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' }))
+6 -3
View File
@@ -9362,10 +9362,12 @@ function App(): React.JSX.Element {
}} }}
> >
<Grid2X2 size={17} aria-hidden="true" /> <Grid2X2 size={17} aria-hidden="true" />
<span>{t("applications.title")}</span> <span>{t("applications.menuLabel")}</span>
<ChevronDown size={16} aria-hidden="true" />
</button> </button>
<div className="sidebar-footer__divider" aria-hidden="true" />
<button <button
className="icon-button" className="nav-item"
aria-label={t('navigation.settings')} aria-label={t('navigation.settings')}
title={t('navigation.settings')} title={t('navigation.settings')}
type="button" type="button"
@@ -9375,7 +9377,8 @@ function App(): React.JSX.Element {
} }
onPointerEnter={() => preloadWorkspaceRouteOnIntent("settings")} onPointerEnter={() => preloadWorkspaceRouteOnIntent("settings")}
> >
<Settings size={16} /> <Settings size={16} aria-hidden="true" />
<span>{t('navigation.settings')}</span>
</button> </button>
</div> </div>
</aside> </aside>
+2 -2
View File
@@ -1,5 +1,5 @@
import { useMemo, type RefObject } from 'react' import { useMemo, type RefObject } from 'react'
import { Settings } from 'lucide-react' import { LayoutGrid } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { defaultApplicationNavigation, type ApplicationSettings, type BuiltInApplicationId } from '../../shared/application-settings-contracts' import { defaultApplicationNavigation, type ApplicationSettings, type BuiltInApplicationId } from '../../shared/application-settings-contracts'
import { applicationDefinitions, isApplicationEnabled } from './ApplicationCenter' import { applicationDefinitions, isApplicationEnabled } from './ApplicationCenter'
@@ -36,7 +36,7 @@ export function ApplicationMenu({ anchorRef, settings, pending, error, onClose,
})} })}
<div role="separator" /> <div role="separator" />
<button type="button" role="menuitem" tabIndex={-1} onClick={() => { onClose(); anchorRef.current?.focus(); onManage() }}> <button type="button" role="menuitem" tabIndex={-1} onClick={() => { onClose(); anchorRef.current?.focus(); onManage() }}>
<Settings size={16} aria-hidden="true" /><span>{t('applications.manage')}</span> <LayoutGrid size={16} aria-hidden="true" /><span>{t('applications.manage')}</span>
</button> </button>
</AnchoredMenu> </AnchoredMenu>
} }
+13 -1
View File
@@ -205,5 +205,17 @@
gap: var(--space-2); gap: var(--space-2);
} }
.sidebar-footer--applications > .nav-item { .sidebar-footer--applications > .nav-item {
flex: 1; flex: 1 0 auto;
gap: var(--space-2);
padding-inline: var(--space-2);
justify-content: center;
white-space: nowrap;
}
.sidebar-footer--applications > .nav-item > span {
flex: 0 0 auto;
}
.sidebar-footer__divider {
flex: 0 0 1px;
height: var(--space-4);
background: var(--border-subtle);
} }
@@ -9,6 +9,7 @@ export const app = {
priority: { low: 'Low priority', medium: 'Medium priority', high: 'High priority' } priority: { low: 'Low priority', medium: 'Medium priority', high: 'High priority' }
}, },
applications: { applications: {
menuLabel: 'Apps',
alwaysShown: 'Always shown', optional: 'Optional', alwaysShown: 'Always shown', optional: 'Optional',
manage: 'Manage Apps', manage: 'Manage Apps',
title: 'Application Center', description: 'Open and reorder built-in applications, and manage optional apps and pins. Knowledge is always shown; Supervisor can be enabled or disabled.', title: 'Application Center', description: 'Open and reorder built-in applications, and manage optional apps and pins. Knowledge is always shown; Supervisor can be enabled or disabled.',
@@ -6,6 +6,7 @@ export const app = {
priority: { low: '低优先级', medium: '中优先级', high: '高优先级' } priority: { low: '低优先级', medium: '中优先级', high: '高优先级' }
}, },
applications: { applications: {
menuLabel: '应用',
alwaysShown: '始终显示', optional: '可选', alwaysShown: '始终显示', optional: '可选',
manage: '管理应用', manage: '管理应用',
title: '应用中心', description: '打开和排列内置应用,管理可选应用的启用与常驻。知识库始终显示,监督者可启用或关闭。', title: '应用中心', description: '打开和排列内置应用,管理可选应用的启用与常驻。知识库始终显示,监督者可启用或关闭。',