28 KiB
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.cppsrc/qEmbyApp/views/admin/manageview.cppsrc/qEmbyApp/quick/quickpagehost.{h,cpp}
2. 当前导航模型(Observed facts)
2.1 两层页面切换
MainWindow持有QStackedWidget m_viewStack,其中长期存在一个LoginView和一个HomeView。LoginView::loginCompleted调用MainWindow::navigateToHome();HomeView::logoutRequested调用MainWindow::navigateToLogin()。- 登录/主页之间使用淡入动画;配置
UiAnimations命中的现有分支会直接切换。 navigateToLogin()在切到登录页后调用AuthService::logout()。退出应用也会调用该方法。HomeView内部使用SlidingStackedWidget m_contentSwitcher和QStack<RouteInfo> m_navStack管业务页面。DashboardView与FavoritesView在HomeView::setupUi()中创建并长期保留;当前没有给这两个 QWidget 设置routeType/routeId/routeTitle/isDynamic。
2.2 当前 RouteInfo 与动态属性
RouteInfo 当前字段为:
QPointer<QWidget> 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。没有通用的滚动位置序列化/恢复契约。PlayerViewoverrideprepareForStackLeave();Manage 的 show/hide 连接/断开 WebSocket;离开 Manage 后HomeView还会刷新侧栏媒体库。
2.5 设置与管理子导航
- 设置是一个全局
SettingsViewroute,内部 5 个索引依次是general、appearance、library、player、about。页面惰性实例化;当前外层 route 不记录选中索引或滚动状态。 - 管理是一个全局
ManageViewroute,内部 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 作为声明式页面注册、实例化和视觉切换层:
QML 页面/侧栏/标题栏
│ push/replace/back/home
▼
NavigationController (C++:校验、权限、规范栈、会话清理)
│ currentRoute/transition request
▼
Router.qml (QML:route→Component、Loader/StackView、过渡、UI state 回写)
│
├─ QML Page + C++ ViewModel
└─ 迁移期 LegacyWidgetRouteHost
约束:
- 页面不得自行维护第二份全局历史,也不得直接实例化另一个顶级页面。
- C++ Controller 不持有 QML
Item*作为历史真相;历史必须只靠可验证 route 数据恢复。 - QML Router 不做认证、管理员权限或 deep-link 参数校验;这些属于 Controller。
- 业务加载/错误属于页面 ViewModel;“未知 route、参数非法、权限拒绝、无法实例化”属于导航错误。
- 迁移期间 Widgets 和 QML 入口都调用同一 Controller,旧
HomeView栈仅作为尚未迁移页面的适配实现。
4. Route shape(Proposed decision)
C++/QML 边界使用 QVariantMap,内部 C++ 应使用强类型 Route 后再导出,避免核心逻辑依赖无类型 map。
{
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)
以下是语义契约,不强制具体头文件拼写:
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的受控clearHistoryoption 实现,但普通页面不能传此 option。 updateUiState:只合并 allowlist 字段,节流写入当前栈项;不产生页面切换。- 所有失败操作都必须原子化:返回 false、栈不变、发
navigationRejected。
Needs confirmation: 当前“点收藏清栈”的行为是否继续保留。建议保留以匹配 resetToView(m_favoritesView),并将 favorites 与 home 都视为会话根页面。
5.1 局部返回与离开钩子
为兼容 BaseView::handleBackNavigation() 和 prepareForStackLeave(),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)
- 维护静态 route name →
Component注册表,不根据任意 URL 拼 QML 文件路径。 - 监听
currentRoute并实例化对应组件;把params、一次性 payload、Controller/ViewModel 注入页面。 - 根据
cachePolicy执行:singleton:会话内唯一实例,离开时停用但不销毁;lru:最多 12 个活页面实例,与现有上限一致;被淘汰后保留 route/uiState,返回时重建;destroyOnPop:离开即销毁,Player 默认使用。
- 页面
visible/active 变化必须传给 ViewModel,以替代QuickPageHost::pageShown/pageHidden,停止轮询、WebSocket 或过期请求。 - Fresh push 从顶部开始;back 恢复保存的滚动位置;home/favorites reset 后目标滚到顶部。滚动恢复要在模型/布局可用后 clamp 到合法范围。
- Player 使用 immersive presentation 并禁用普通滑页动画;其余页面由统一 transition policy 决定,reduce-motion 时无动画。
- 实例创建或 Component error 时报告 Controller,并展示
navigationError或安全回退;禁止黑屏或静默回 home。
7. 深链与参数(Proposed decision)
第一阶段先支持内部 deep link(规范 route map),外部 URI 注册推迟到纯 QML 壳稳定后。建议 URI 形态:
qemby://detail/<itemId>
qemby://library/<libraryId>
qemby://season/<seriesId>/<seasonId>
qemby://settings/<section>
qemby://manage/<section>
qemby://search?q=<percent-encoded-query>
处理规则:
- 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)
登录成功
- Controller 建立新的 session generation。
- 清除任何上一个会话的页面实例、历史和 uiState。
replace(home),不允许 back 回 login。- 若存在合法 pending deep link,再
push目标。
退出登录
- 先让 Player/当前页执行 leave hook,并停止会话级任务。
- 原子清空业务栈、LRU cache、一次性 payload、pending deep link、滚动状态和所有 session singleton。
- 断开 Manage WebSocket/页面轮询。
- 调用认证服务 logout,然后
replace(login);不允许 back 回任何业务页。
切换服务器/用户
按“退出旧 session + 建立新 session”处理,不能复用旧 server 的 route 页面或滚动状态。搜索历史本身当前按 server id 隔离,这不代表页面栈可跨 server 复用。
当前 Widgets 行为在侧栏 logout 时会先 reset Dashboard,但 MainWindow::navigateToLogin() 本身不直接拥有 HomeView 清栈 API。目标契约必须把清栈变成 Controller 的会话不变量,而不是依赖退出入口恰好来自侧栏。
9. 设置与管理页(Proposed decision)
- 顶层保持一个
settings和一个manageroute,不把每个 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. 待评审决定汇总
- Favorites 是否继续作为会话根并在进入时清空历史(建议:是,兼容现状)。
- Settings/Manage section 切换是否写入全局 back 历史(建议:否)。
- 管理合集 section 的稳定名使用
collections还是lists(建议:collections)。 - 首期是否包含 OS 外部 deep link 和跨服务器解析(建议:不包含,只做内部 route map)。
- pending deep link 的过期策略(建议:只存一个、仅当前登录流程有效,取消登录即清除)。
- 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覆盖QQuickItemfinalbottom/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 输出。