# QML 导航契约与兼容迁移规格 状态:Draft(Phase 0/1 兼容基础已实现,尚未形成已接受 ADR) 更新时间:2026-07-15 范围:应用登录/主页切换、`HomeView` 业务页面栈、设置/管理子页面导航,以及迁移到 QML 时的最小 C++/QML 边界。 非目标:本规格不决定播放器渲染技术、不迁移任何页面、不替换平台窗口/托盘代码,也不规定业务 ViewModel 的数据接口。 ## 1. 证据口径 本文严格区分: - **Observed fact(已观察事实)**:直接来自当前源码。 - **Proposed decision(建议决策)**:为 QML 迁移定义的目标契约,只有在评审接受后才是实现约束。 - **Needs confirmation(待确认)**:产品或架构选择,当前源码不足以给出唯一答案。 主要证据文件: - `src/qEmbyApp/mainwindow.{h,cpp}` - `src/qEmbyApp/views/user/homeview.{h,cpp}` - `src/qEmbyApp/views/baseview.{h,cpp}` - `src/qEmbyApp/views/settings/settingsview.cpp` - `src/qEmbyApp/views/admin/manageview.cpp` - `src/qEmbyApp/quick/quickpagehost.{h,cpp}` ## 2. 当前导航模型(Observed facts) ### 2.1 两层页面切换 1. `MainWindow` 持有 `QStackedWidget m_viewStack`,其中长期存在一个 `LoginView` 和一个 `HomeView`。 2. `LoginView::loginCompleted` 调用 `MainWindow::navigateToHome()`;`HomeView::logoutRequested` 调用 `MainWindow::navigateToLogin()`。 3. 登录/主页之间使用淡入动画;配置 `UiAnimations` 命中的现有分支会直接切换。 4. `navigateToLogin()` 在切到登录页后调用 `AuthService::logout()`。退出应用也会调用该方法。 5. `HomeView` 内部使用 `SlidingStackedWidget m_contentSwitcher` 和 `QStack m_navStack` 管业务页面。 6. `DashboardView` 与 `FavoritesView` 在 `HomeView::setupUi()` 中创建并长期保留;当前没有给这两个 QWidget 设置 `routeType/routeId/routeTitle/isDynamic`。 ### 2.2 当前 RouteInfo 与动态属性 `RouteInfo` 当前字段为: ```cpp QPointer widget; bool isDynamic; QString routeType; QString routeId; QString routeTitle; QString routeExtraId; ``` `pushView()` 在切换前从当前 QWidget 的同名动态属性复制元数据到历史栈。页面实例可能被删除,因此返回逻辑也依赖这些字符串重建页面。 | create/静态实例 | 实际 QWidget | `isDynamic` | `routeType` | `routeId` | `routeTitle` | `routeExtraId` | 初始化事实 | |---|---|---:|---|---|---|---|---| | `m_dashboardView` | `DashboardView` | 未设置/false | 未设置 | 未设置 | 未设置 | 未设置 | 构造时创建,`goHome()` 复用;已在首页时再次调用会刷新数据 | | `m_favoritesView` | `FavoritesView` | 未设置/false | 未设置 | 未设置 | 未设置 | 未设置 | 构造时创建,`goFav()` 复用;已在收藏时再次调用会刷新数据 | | `createDetailView(itemId,itemName,seedItem)` | `DetailView` | true | `DetailView` | `itemId` | `itemName` | 未设置 | `loadItem(itemId, seedItem)`;历史重建时没有 `seedItem` | | `createCategoryView(categoryId,title)` | `CategoryView` | true | `CategoryView` | `categoryId` | `title` | 未设置 | 异步排队调用 `loadCategory(categoryId,title)`;收藏分类入口给 id 加 `Favorite_` 前缀 | | `createLibraryView(libraryId,title)` | `LibraryView` | true | `LibraryView` | `libraryId` | `title` | 未设置 | `loadLibrary(libraryId,title)` | | `createPersonView(personId,personName)` | `LibraryView` | true | `PersonView` | `personId` | `personName` | 未设置 | 复用 `LibraryView`,调用 `loadPerson(personId,personName)` | | `createSearchView(query)` | `SearchView` | true | `SearchView` | `query` | 未设置 | 未设置 | `performSearch(query)` | | `createFilteredView(filterType,filterValue)` | `LibraryView` | true | `FilteredView` | `filterType + ":" + filterValue` | `filterValue` | `filterType` | 复用 `LibraryView`,调用 `loadFiltered(filterType,filterValue)` | | `createSeasonView(seriesId,seasonId,seasonName)` | `SeasonView` | true | `SeasonView` | `seasonId` | `seasonName` | `seriesId` | `loadSeason(seriesId,seasonId,seasonName)` | | `createPlayerView(mediaId,title,streamUrl,ticks,extraData)` | `PlayerView` | true | `PlayerView` | `mediaId` | `title` | 未设置 | 等切页动画结束后 `playMedia(...)`;进入/退出播放器采用瞬切 | | `createSettingsView()` | `SettingsView` | true | `SettingsView` | `settings_global` | 翻译后的 Settings | 未设置 | 侧栏仅避免连续打开当前 `SettingsView` | | `createManageView()` | `ManageView` | true | `ManageView` | `manage_global` | 翻译后的 Server Management | 未设置 | 侧栏仅避免连续打开当前 `ManageView` | 注意:当前 `navigateBack()` 能按元数据重建 Detail、Library、Category、Search、Settings、Person、Season、Manage、Filtered;没有 `PlayerView` 重建分支。找不到实例且无法重建时回退到 Dashboard。 ### 2.3 create 方法和导航信号的事实映射 `BaseView` 暴露以下导航信号: | 现有信号 | 参数 | `HomeView` 中的实际目标 | |---|---|---| | `navigateToDetail` | `itemId,itemName,seedItem` | `pushView(createDetailView(...))` | | `navigateToFolder` | `folderId,folderName` | `pushView(createLibraryView(...))` | | `navigateToPerson` | `personId,personName` | `pushView(createPersonView(...))` | | `triggerSearch` | `query` | trim、记录服务器维度搜索历史、`pushView(createSearchView(query))` | | `navigateToFilteredView` | `filterType,filterValue` | `pushView(createFilteredView(...))` | | `navigateBack` | 无 | `HomeView::navigateBack()`(只在部分页面显式连接) | | `navigateToCategory` | `categoryId,title` | Dashboard/Favorites 的专用连接进入 Category;Favorites 会加 `Favorite_` 前缀 | | `navigateToPlayer` | `mediaId,title,streamUrl,ticks,extraData` | `launchPlayer()` → `PlaybackManager::startPlayback()`;嵌入播放请求最终 `pushView(createPlayerView(...))` | | `navigateToSeason` | `seriesId,seasonId,seasonName` | `pushView(createSeasonView(...))` | 页面连接并不完全一致:Detail 额外连接过滤和搜索;Season 只连接详情、播放和返回;Search/Category/Library 连接其各自支持的详情/文件夹/人物/播放/季入口。目标导航控制器不应依赖每个页面重复手写这些连接。 ### 2.4 push/back/home 的现有生命周期 - `pushView(view)`:把当前页面压栈;把目标加入 switcher;排队调用目标的 `scrollToTop()`;非 Player 向左滑入,Player 瞬切。 - 活跃历史中最多保留 12 个仍有 QWidget 指针的动态页面。超过限制的旧动态页面从 switcher 移除并 `deleteLater()`,但历史元数据仍保留以便重建。 - `navigateBack()`:先让当前 `BaseView::handleBackNavigation()` 尝试消费返回;未消费时调用 `prepareForStackLeave()`,弹出历史;必要时重建;完成切换后销毁离开的动态页。 - `resetToView(Dashboard/Favorites)`:清空整个历史栈、调用当前页 `prepareForStackLeave()`、滚动目标到顶并销毁相关动态页面。 - `goHome()/goFav()` 在已经位于目标静态页面时刷新,而不是产生新历史。 - `MainWindow` 返回按钮顺序是:业务栈 back → 回 Dashboard → 2 秒内再次返回触发退出登录提示/动作。 - `BaseView::scrollToTop()` 默认为空;当前检索到 Dashboard 和 Detail 有 override。没有通用的滚动位置序列化/恢复契约。 - `PlayerView` override `prepareForStackLeave()`;Manage 的 show/hide 连接/断开 WebSocket;离开 Manage 后 `HomeView` 还会刷新侧栏媒体库。 ### 2.5 设置与管理子导航 - 设置是一个全局 `SettingsView` route,内部 5 个索引依次是 `general`、`appearance`、`library`、`player`、`about`。页面惰性实例化;当前外层 route 不记录选中索引或滚动状态。 - 管理是一个全局 `ManageView` route,内部 7 个索引依次是 `dashboard`、`libraries`、`collections/lists`、`transcoding`、`users`、`tasks`、`logs`。当前外层 route 不记录选中索引或滚动状态。 - 设置五个页面主体已由 `QuickPageHost` 加载 QML;管理仅 Dashboard 主体由该 host 加载。两者的左侧导航和父级页面栈仍是 Widgets。 - `QuickPageHost` 通过独立 `QQuickWidget` 加载 URL、注入 context property/image provider,并把 QWidget show/hide 转成 `pageShown/pageHidden`。 ## 3. 目标职责边界(Proposed decision) 采用一个 C++ `NavigationController` 作为**唯一规范导航状态所有者**,QML `Router.qml` 作为声明式页面注册、实例化和视觉切换层: ```text QML 页面/侧栏/标题栏 │ push/replace/back/home ▼ NavigationController (C++:校验、权限、规范栈、会话清理) │ currentRoute/transition request ▼ Router.qml (QML:route→Component、Loader/StackView、过渡、UI state 回写) │ ├─ QML Page + C++ ViewModel └─ 迁移期 LegacyWidgetRouteHost ``` 约束: 1. 页面不得自行维护第二份全局历史,也不得直接实例化另一个顶级页面。 2. C++ Controller 不持有 QML `Item*` 作为历史真相;历史必须只靠可验证 route 数据恢复。 3. QML Router 不做认证、管理员权限或 deep-link 参数校验;这些属于 Controller。 4. 业务加载/错误属于页面 ViewModel;“未知 route、参数非法、权限拒绝、无法实例化”属于导航错误。 5. 迁移期间 Widgets 和 QML 入口都调用同一 Controller,旧 `HomeView` 栈仅作为尚未迁移页面的适配实现。 ## 4. Route shape(Proposed decision) C++/QML 边界使用 `QVariantMap`,内部 C++ 应使用强类型 `Route` 后再导出,避免核心逻辑依赖无类型 map。 ```qml { name: "detail", // 稳定、非翻译的 route 名 key: "detail:item-123", // Controller 规范化生成的实例标识 title: "Movie title", // 展示提示;不得作为重建必需参数 params: { itemId: "item-123" }, presentation: "page", // page | immersive cachePolicy: "lru", // singleton | lru | destroyOnPop uiState: { scrollY: 0 }, // 可丢失的页面表现状态 version: 1 } ``` 规则: - `name + params` 是恢复页面的规范输入;`title` 只用于即时展示。 - `key` 由 Controller 根据 route 注册表生成,调用方不能伪造。 - `params` 只允许 JSON/QVariant 可安全复制的标量、list、map;不得把 `QObject*`、QWidget 指针、原始 `MediaItem` 或带凭据的流 URL写入历史。 - `uiState` 可更新、可淘汰,不参与 route 身份;第一阶段至少支持 `scrollY` 和嵌套页面 `section`。 - 播放的 `streamUrl/extraData` 目前可能包含瞬态或复杂数据。它们保留在 PlaybackManager 的启动上下文,不进入可持久化 deep link;Player route 历史只保存 `mediaId/title/startPositionTicks`,且默认 `destroyOnPop`。 - `seedItem` 是详情加载优化,不是恢复必需数据;可作为一次性 Controller payload 传递,但不能成为 route schema 的必填项。 ### 4.1 规范 route 注册表 | `name` | 必填 `params` | 可选 `params` | 兼容来源 | 默认缓存 | |---|---|---|---|---| | `login` | 无 | 无 | MainWindow LoginView | singleton(应用级) | | `home` | 无 | 无 | DashboardView | singleton(会话级) | | `favorites` | 无 | 无 | FavoritesView | singleton(会话级) | | `detail` | `itemId` | `title` | DetailView | lru | | `category` | `categoryId` | `title`, `favorite` | CategoryView;兼容期解析 `Favorite_` 前缀 | lru | | `library` | `libraryId` | `title` | LibraryView | lru | | `person` | `personId` | `title` | PersonView/LibraryView | lru | | `search` | `query` | 无 | SearchView | lru | | `filtered` | `filterType`,`filterValue` | `title` | FilteredView | lru | | `season` | `seriesId`,`seasonId` | `title` | SeasonView | lru | | `player` | `mediaId` | `title`,`startPositionTicks` | PlayerView | destroyOnPop | | `settings` | 无 | `section` | SettingsView | lru | | `manage` | 无 | `section` | ManageView | lru;admin guard | | `navigationError` | `code` | `message`,`failedRoute` | 当前无等价页 | destroyOnPop | Settings `section` allowlist:`general|appearance|library|player|about`。 Manage `section` allowlist:`dashboard|libraries|collections|transcoding|users|tasks|logs`。 **Needs confirmation:** `collections` 是否作为面向用户的稳定 deep-link 名,还是沿用界面文字 `lists`;建议内部稳定名使用 `collections`。 ## 5. NavigationController 最小契约(Proposed decision) 以下是语义契约,不强制具体头文件拼写: ```cpp class NavigationController : public QObject { Q_OBJECT Q_PROPERTY(QVariantMap currentRoute READ currentRoute NOTIFY currentRouteChanged) Q_PROPERTY(bool canGoBack READ canGoBack NOTIFY stackChanged) Q_PROPERTY(int depth READ depth NOTIFY stackChanged) public: Q_INVOKABLE bool push(const QVariantMap &request); Q_INVOKABLE bool replace(const QVariantMap &request); Q_INVOKABLE bool back(); Q_INVOKABLE void home(); Q_INVOKABLE void updateUiState(const QVariantMap &patch); // 仅认证/会话协调层调用,不暴露给任意内容页。 void enterAuthenticatedSession(); void leaveAuthenticatedSession(LogoutReason reason); signals: void currentRouteChanged(); void stackChanged(); void navigationRejected(QString code, QString message, QVariantMap request); void sessionRoutesCleared(); }; ``` 操作语义: - `push`:校验并规范化 request;保存当前 `uiState`;压入新 route。新页面 `scrollY=0`。默认允许相同 route 重复入栈。 - `replace`:校验成功后用新 route 替换栈顶,不保留被替换项;适用于登录完成、参数规范化和不可返回的重定向。 - `back`:先请求当前页面处理局部返回(关闭 overlay、退出子模式);未消费才弹全局栈。目标恢复已保存 `uiState`。 - `home`:清空业务历史并切到 `home`;已经在 `home` 时发出 `refreshRequested(key)`,对齐现有刷新语义。 - 收藏入口提供等价的 `resetTo({name:"favorites"})` 内部 API;它和 `home()` 一样清业务栈。若不希望扩大公开 API,可由 `replace` 的受控 `clearHistory` option 实现,但普通页面不能传此 option。 - `updateUiState`:只合并 allowlist 字段,节流写入当前栈项;不产生页面切换。 - 所有失败操作都必须原子化:返回 false、栈不变、发 `navigationRejected`。 **Needs confirmation:** 当前“点收藏清栈”的行为是否继续保留。建议保留以匹配 `resetToView(m_favoritesView)`,并将 `favorites` 与 `home` 都视为会话根页面。 ### 5.1 局部返回与离开钩子 为兼容 `BaseView::handleBackNavigation()` 和 `prepareForStackLeave()`,QML 页面统一可选实现: ```qml function handleBackRequest() { return false } function prepareForRouteLeave(reason) { } function captureRouteUiState() { return { scrollY: flickable.contentY } } function restoreRouteUiState(state) { ... } ``` Router 在 Controller `back/home/replace/sessionClear` 提交前调用前两个钩子。`handleBackRequest()` 返回 true 时,不调用 Controller `back()`。异步清理不能无限阻塞导航;耗时业务取消应由 ViewModel 的 active/generation/cancellation 机制完成。 ## 6. QML Router 最小职责(Proposed decision) 1. 维护静态 route name → `Component` 注册表,不根据任意 URL 拼 QML 文件路径。 2. 监听 `currentRoute` 并实例化对应组件;把 `params`、一次性 payload、Controller/ViewModel 注入页面。 3. 根据 `cachePolicy` 执行: - `singleton`:会话内唯一实例,离开时停用但不销毁; - `lru`:最多 12 个**活页面实例**,与现有上限一致;被淘汰后保留 route/uiState,返回时重建; - `destroyOnPop`:离开即销毁,Player 默认使用。 4. 页面 `visible`/active 变化必须传给 ViewModel,以替代 `QuickPageHost::pageShown/pageHidden`,停止轮询、WebSocket 或过期请求。 5. Fresh push 从顶部开始;back 恢复保存的滚动位置;home/favorites reset 后目标滚到顶部。滚动恢复要在模型/布局可用后 clamp 到合法范围。 6. Player 使用 immersive presentation 并禁用普通滑页动画;其余页面由统一 transition policy 决定,reduce-motion 时无动画。 7. 实例创建或 Component error 时报告 Controller,并展示 `navigationError` 或安全回退;禁止黑屏或静默回 home。 ## 7. 深链与参数(Proposed decision) 第一阶段先支持内部 deep link(规范 route map),外部 URI 注册推迟到纯 QML 壳稳定后。建议 URI 形态: ```text qemby://detail/ qemby://library/ qemby://season// qemby://settings/
qemby://manage/
qemby://search?q= ``` 处理规则: - Controller 维护 route/参数 allowlist、长度限制和 percent-decoding;未知字段忽略还是拒绝必须逐 route 固定,建议拒绝必填字段冲突、忽略未知可选字段并记录日志。 - 未登录时收到业务 deep link:保存**一个经过校验的 pending route**,切到 login;登录成功后 replace 为 home,再 push pending route。 - `manage` 在未登录、非管理员或服务器不支持时拒绝,不实例化管理页。 - server-scoped item deep link 必须绑定当前 server;跨服务器解析/选择流程当前无事实依据。 - 不允许在 URI、route 或日志中携带 access token、密码、完整 stream URL。 **Needs confirmation:** 是否支持 OS 外部 deep link、跨服务器 deep link,以及登录后 pending link 的过期时间。建议首期只保证应用内 route map,外部 URI 单独立项。 ## 8. 登录、退出与服务器切换(Proposed decision) ### 登录成功 1. Controller 建立新的 session generation。 2. 清除任何上一个会话的页面实例、历史和 uiState。 3. `replace(home)`,不允许 back 回 login。 4. 若存在合法 pending deep link,再 `push` 目标。 ### 退出登录 1. 先让 Player/当前页执行 leave hook,并停止会话级任务。 2. 原子清空业务栈、LRU cache、一次性 payload、pending deep link、滚动状态和所有 session singleton。 3. 断开 Manage WebSocket/页面轮询。 4. 调用认证服务 logout,然后 `replace(login)`;不允许 back 回任何业务页。 ### 切换服务器/用户 按“退出旧 session + 建立新 session”处理,不能复用旧 server 的 route 页面或滚动状态。搜索历史本身当前按 server id 隔离,这不代表页面栈可跨 server 复用。 当前 Widgets 行为在侧栏 logout 时会先 reset Dashboard,但 `MainWindow::navigateToLogin()` 本身不直接拥有 `HomeView` 清栈 API。目标契约必须把清栈变成 Controller 的会话不变量,而不是依赖退出入口恰好来自侧栏。 ## 9. 设置与管理页(Proposed decision) - 顶层保持一个 `settings` 和一个 `manage` route,不把每个 section 都变成全局历史项。 - `params.section` 支持侧栏/深链直达;同一页面内切 section 使用 `replace` 更新当前 route 参数,因此 back 返回离开 Settings/Manage 前的业务页面,而不是逐 tab 返回。 - section 自己保存独立 `scrollY`,建议 `uiState.sections[section].scrollY`;LRU 重建后恢复所选 section 和滚动。 - Manage route 必须由 Controller 做 admin guard;页面仍应处理服务端 401/403,因为登录后权限可能变化。 - 离开 Manage(包括 back/home/logout/server switch)必须停 WebSocket;进入时才连接。迁移期继续触发当前“离开管理后刷新侧栏媒体库”的兼容动作。 - 设置/管理内部 QML 页面不应各自创建 `QQuickWidget`。壳迁移后在同一 QML engine 中用 Component/Loader 承载;`QuickPageHost` 只保留给尚未迁移的 Widgets 壳阶段。 **Needs confirmation:** 切换设置/管理 section 是否应该进入浏览器式 back 历史。建议不进入,以保持当前全局栈行为。 ## 10. 错误处理(Proposed decision) | 错误 | Controller/Router 行为 | 用户可恢复动作 | |---|---|---| | 未知 route name | 拒绝、栈不变、日志 + `navigationRejected(unknownRoute)` | 保持当前页 | | 缺失/非法参数 | 拒绝、栈不变,不启动 ViewModel 请求 | 修正入口;外部 link 显示不可打开提示 | | 未认证 | 保存合法 pending route,replace login | 登录或取消 | | 非管理员访问 manage | 拒绝;不得短暂显示管理内容 | 返回当前页/提示权限不足 | | QML Component 加载失败 | 标记 `componentLoadFailed`,展示 navigationError;保留可 back/home 的壳 | 重试、back、home | | 页面数据请求失败 | route 保持不变,由页面 ErrorState/ViewModel 处理 | 重试、back | | LRU 页面已销毁 | 由 route params 重建并恢复可用 uiState | 无需用户干预 | | 历史数据版本不兼容 | 丢弃该项并继续向前找安全 route;记录原因 | 最终回 home/login | | leave hook/动画期间重复导航 | Controller 串行化或合并;不能出现双 pop/重复实例 | 操作完成后状态一致 | 不得使用“任何错误都静默回 Dashboard”的策略;现有 fallback 只作为兼容期最后保险。 ## 11. 分阶段兼容方案(Proposed decision) ### Phase 0:固定契约与测试夹具 - 新增强类型 Route/注册表和 Controller 单元测试,不改变实际导航入口。 - 建立 legacy 属性 ↔ route 的双向转换;记录不完整历史项但仍走现有 HomeView。 - 为 login/home/favorites 补稳定 route 名(可先只在适配器中表达,不要求立刻改 QWidget 属性)。 ### Phase 1:Controller 旁路接管 - 所有 Widgets 信号先转换成 route request,再由 legacy adapter 调用现有 `create*View/pushView/resetToView`。 - 对比 Controller 栈与 `m_navStack`,Debug 下发现漂移立即记录。 - 会话退出、服务器切换首先统一走 Controller clear-session 流程。 ### Phase 2:单引擎 QML Router + 已迁移页面 - 在现有 Widgets MainWindow/HomeView 内放置**一个**根 Quick host,Router 接管 Login、Search、Settings 主体、Manage Dashboard 等已迁移页面。 - 未迁移 route 通过 `LegacyWidgetRouteHost`/明确的边界回到 Widgets;不要为每个 QML 子组件新增独立 QQuickWidget。 - Controller 成为唯一历史;停止让 QML 路径同时写 `m_navStack`。 ### Phase 3:QML Home shell - 迁移侧栏、全局搜索、标题导航和内容 StackView;保留 C++ 原生窗口 agent、托盘及必要平台能力。 - Dashboard/Favorites/Category/Library/Detail/Season 逐个切 route factory;每个入口保留 feature flag 回退一个发布周期。 - 设置/管理 section 和滚动状态切到本文契约。 ### Phase 4:移除 legacy 业务栈 - 最后迁移 Player/长尾页面或明确其原生 host 边界。 - 删除 `HomeView::create*View()`、`RouteInfo`、重复 signal wiring 和 `QuickPageHost` 多实例路径前,必须完成全链路验收。 - 平台窗口、托盘、文件选择器等 C++ 能力无需为了“纯 QML”被删除。 ## 12. 验收标准 ### 契约与自动测试 - 每个注册 route 的合法/缺参/错参用例都有 Controller 测试;失败时栈严格不变。 - push → push → back、replace、home/favorites reset、空栈 back 均有确定结果。 - LRU 超过 12 个实例后,历史 route 仍能重建;Player 不被重建为带失效 stream context 的页面。 - uiState 不参与 key;fresh push 到顶,back 恢复滚动,超出新 contentHeight 时正确 clamp。 - logout、切 server/user 后 depth、cache、pending payload、scroll state 均不含旧 session 数据。 - 未登录 deep link、非法 deep link、非管理员 manage deep link 有测试。 ### 兼容行为 - Dashboard、Favorites、Detail、Category、Library、Person、Search、Filtered、Season、Settings、Manage、Player 的所有现有入口都有 route 映射。 - `BaseView` 九类导航信号在兼容适配器中无遗漏;同一入口不会同时 push 两个栈。 - 返回优先关闭当前页面局部状态,再 pop;离开 Player 执行 stop/report 语义,离开 Manage 停 WebSocket。 - 标题栏 back/home/favorites、侧栏入口、全局搜索、登录成功、侧栏退出、双击返回退出都到达同一 Controller API。 - Settings/Manage 直达 section、返回到来源页、重建后恢复 section/滚动均可用。 ### 运行与视觉 - `qEmbyApp_qmllint` 与 Debug 构建通过,无新增 runtime QML error/warning。 - 登录 → Home → Library → Detail → Season/Player → back → Settings → Manage → logout 完整链路重复 10 次无崩溃、无幽灵页面、无跨账号数据。 - reduce-motion 与普通动画两种配置下快速连续 back/push 不产生双 pop。 - Emby/Jellyfin、管理员/普通用户分别冒烟;窗口缩放、键盘 Back/Escape、鼠标侧键、托盘恢复后导航状态一致。 - QML Component 故障和网络错误分别验证:前者进入导航错误 UI,后者留在当前业务 route 的错误态。 ## 13. 待评审决定汇总 1. Favorites 是否继续作为会话根并在进入时清空历史(建议:是,兼容现状)。 2. Settings/Manage section 切换是否写入全局 back 历史(建议:否)。 3. 管理合集 section 的稳定名使用 `collections` 还是 `lists`(建议:`collections`)。 4. 首期是否包含 OS 外部 deep link 和跨服务器解析(建议:不包含,只做内部 route map)。 5. pending deep link 的过期策略(建议:只存一个、仅当前登录流程有效,取消登录即清除)。 6. QML 壳过渡期采用何种 Legacy QWidget 承载技术,需要在 Player/原生复杂页原型后单独决策;本规格只要求单一规范栈,未把具体承载方案当作事实。 ## 14. 实施记录 2026-07-15 完成 Phase 0/1 的首个兼容批次: - 新增 `NavigationRoute` 与 `NavigationController`,支持规范 key、route 参数校验、认证/管理员门禁、push/replace/back、Home/Favorites reset、会话清理和 UI state 白名单。 - `HomeView` 将现有 legacy 动态属性统一转换为 route request;控制器校验成功后才操作 Widgets 页面栈。旧 `m_navStack` 暂时保留用于页面实例和动画兼容。 - `MainWindow` 的登录/退出切换已通知控制器建立或清理认证会话。 - 采用建议默认值:Favorites 是会话根;Settings/Manage section 不写全局历史;管理合集稳定名为 `collections`;当前不实现 OS/跨服务器 deep link。 - 新增 `qEmbyNavigationTest`,覆盖原子拒绝、栈操作、权限、UI state 与 logout 清理。下一批实现单引擎 `Router.qml`,届时控制器才从“旁路规范状态”升级为页面实例切换的直接驱动者。 2026-07-15 完成 Phase 2 首批 Home/Favorites: - 新增应用级 `QuickRouterHost` 与 `Router.qml`;首批只静态注册 Home/Favorites,其他 route 不解析未注入的页面类型,也不会在 legacy Widgets 滑入期间闪现恢复页。 - 新增 `HomeQuickRouterView`,在同一 engine 中注入 Dashboard/Favorites 两个会话单例 ViewModel,并按规范 route 显式切换 active 生命周期。 - 播放、收藏、详情/媒体库/人物/分类导航、通用菜单及 Dashboard 管理媒体库专用菜单继续复用 `BaseView`/C++ action bridge,避免页面迁移造成功能回退。 - Home/Favorites 单引擎路径默认开启;设置环境变量 `QEMBY_USE_LEGACY_HOME_PAGES=1` 可回退旧双 `QuickPageHost`,用于一个发布周期内的兼容验证。 - 首批无服务器 offscreen 冒烟和 legacy fallback 冒烟均可稳定运行至测试超时,未出现 QML runtime error。期间发现并修复 `AdminTranscodingPage.DoubleField` 覆盖 `QQuickItem` final `bottom/top` 属性的问题。 2026-07-15 完成 Phase 2 第二批 Search: - `Router.qml` 增加 Search 静态组件;Search 与 Home/Favorites 共享根 engine、图片 provider 和 action bridge。 - `HomeView::triggerSearch()` 改为 push 规范 search route;同一 Router widget 内切换也会记录历史,因此 Home → Search → Back 可以恢复 Home,Search → legacy Detail → Back 可以恢复原搜索。 - `SearchViewModel` 增加 active/generation 取消语义;inactive 时只记录待搜索 query,重新激活后按需恢复,避免隐藏页面回写陈旧结果。 - 导航契约测试新增 search key 和返回用例;默认 Router 与 legacy fallback 的无服务器 offscreen 冒烟均无 runtime 输出。