跨端: 页面转场 + 主题切换交叉淡出(用户:「加页面转场,因为是桌面应用了」+「深浅色切换也加动画」)

## ① 页面间转场(`pageTransition`)

用户要的是"因为它是桌面应用了"—— 桌面端的页面切换有转场是基本预期。

官方文档(`ts-page-transition-animation`)给了两条关键信息:
· 「当路由(**router**)进行切换时,可以通过在 `pageTransition` 函数中自定义
  页面入场和页面退场的转场动效」;
· 「为了实现更好的转场效果,**推荐使用 Navigation 组件和模态转场**」。

⇒ 所以**两套机制各归各位**,不混用:
  · 走 `router` 的独立页(管理页 / 邮件详情独立页 / 写信独立页)→ `pageTransition`;
  · 应用内 `Navigation` 跳转(邮件详情 / 写信,都走 `pushPath`)→ 已有
    `geometryTransition`(上一轮加的共享元素转场)。

数值照 WebUI `rise-in` 的关键帧(`index.css:1298-1307`)逐字对齐:
    from { opacity: 0; transform: translateY(4px) }
    to   { opacity: 1; transform: none }
⇒ 淡入 + 上浮 `Theme.riseInOffset`(4vp),时长 `Theme.durRise`(200)。

★ 退场**只淡出、不做位移**:两端都位移会看起来互相推挤。

## ② 主题切换的交叉淡出

这是**平台机制差异**,不是"懒得做":

· WebUI 切主题是**免费**的 —— CSS transition 挂在 `background-color` 上,
  主题换的是 CSS **变量**,于是每个元素各自补间(`index.css:1169` 给
  button/a/input/textarea/select 都挂了);
· ArkUI 的主题走 `app.setColorMode()`,而界面颜色是**系统资源**
  (`$r('sys.color.*')`)。`animateTo` 只能补间**数值属性** ——
  "把一个资源换成另一个资源"没有中间值 ⇒ 实测整屏同时跳变(闪一下)。

⇒ 自己造一个"旧屏淡出":
  ① 切主题**之前**用 `getComponentSnapshot().get(id)` 抓当前界面;
  ② 立刻切主题(底层已是新主题);
  ③ 把位图盖在最上层、`opacity` 1 → 0 补间 ⇒ "旧界面淡出、新界面透出"。

新增:
· `Motion.captureForThemeFade(ui, rootId)` —— 抓图,失败返回 `undefined`;
· `Theme.durThemeFade = 260`(整屏变化比单组件入场慢一点才不刺眼);
· `MainPage` 根 Stack 加 `.id(THEME_FADE_ROOT_ID)`(**常量**,两处拼错不报错、
  只表现为"动画静默不发生")+ `themeFadeImage`/`themeFadeOpacity` 两个状态
  + 最上层那层 `Image`;
· `toggleTheme` 拆成"带动画"与 `applyThemeNow`(**不含动画**)两条路,
  共用同一份状态变更 —— 抓图失败 / 系统关掉动画时直接切主题
  (**功能优先于观感**:不能因为动画失败而切不了主题)。

★ 两个必须做的收尾(都不是可选):
  · 动画结束后**清空 `themeFadeImage`** —— 留着是一张**整屏大小**的 PixelMap
    常驻内存,而且它盖在最上层会**吃掉所有触摸事件**(界面看着正常但点不动);
  · 那层加 `hitTestBehavior(HitTestMode.None)` —— 动画期间用户可能正好在点按钮。

★ 默认跟随系统:**本来就已经是**(`Appearance.theme` 初值 `'system'`,
  `AppearanceStore` 与 `Models` 两处都是),本轮未改,只是确认了。

## 验证状态(如实)

✓ 编译通过;设备上装包后进程存活、无新 jscrash
✗ **动画本体没有在设备上看到**:
  · 页面转场与主题淡出都是 200-260ms,而 `snapshot_display` 往返 1.5-3s
    ⇒ 探针比被测对象慢一个数量级(同 `harmony-morph-unverified-middleframes`);
  · 主题淡出还额外需要"已登录 + 找到主题开关",而我在清 preferences 之后
    登录流程没能用 `uitest uiInput` 走通(`inputText` 追加而非替换,
    把地址栏填成了 `https://exm/api/v1https://mail.jianfgit.`)。

⇒ 这两条的**结构与接线**有判据/编译保障,但"动起来好不好看"只能由真机确认。
   我不声称已验 —— 与既有的 morph 那条同一个诚实口径。
This commit is contained in:
2026-09-21 17:36:40 +08:00
parent 1571eb2ec4
commit e29f38b151
7 changed files with 330 additions and 0 deletions

View File

@ -193,6 +193,48 @@ test('★ 默认地址自己就必须是"能通的那一个":https + 完整 /a
+ '公网用 https(域名解析到同一台机,内网走 https 也到得了)');
assert.equal(A.normalizeApiBase(value), value,
'★ 默认地址必须**已经**是归一化后的形态(少了 /api/v1 的默认值 = 开箱即 404)');
/*
* ★★ 2026-09-21 新增(用户:「为什么现在登陆页面默认填写我们的服务器地址?
* 不应该是 example 地址吗」)。
*
* 这条原先只钉"https + 带 /api/v1" —— 而 `https://mail.jianfgit.xyz/api/v1`
* **两条都满足** ⇒ 判据全绿,默认值是开发者自己的生产域名这件事**没人管**。
*
* 判据形状的问题:它验的是"格式对不对",而用户报的是"**值是谁的**"。
* 格式判据抓不到"语义事故"—— 这个值可以完全合法却仍然是错的。
*
* ⇒ 加一条**语义**判据:出厂默认不许是**任何人的真实服务器**。
* 判法是"它必须落在保留域名里"(RFC 2606:`example.com/net/org` 与 `.test/.invalid/.example`)。
* 这样既允许 `example.com`,又挡住把任何真实域名写成默认。
*
* ★ 为什么不留空串:见 `Config.ets` 那段注释 —— 字段必须填对,
* 留空用户不知道格式;而预填一个**看起来能用**的真域名更坏
* (用户会以为"直接登录就行")。
*/
const RESERVED = /^https:\/\/([a-z0-9-]+\.)*(example\.(com|net|org)|example|test|invalid)\/api\/v1$/;
assert.match(value, RESERVED,
`★ 出厂默认是 ${value} —— 那是**某一个真实服务器**的地址。`
+ '通用客户端把某个人的后端写成默认值,等于宣称"本产品只有一个后端";'
+ '且用户会以为"直接登录就行",而连的其实是别人的机器。'
+ '默认值必须是**保留域名**(RFC 2606 的 example.com/test/invalid),'
+ '与实际地址区分开。');
/*
* 提示文案也不许出现真实域名:用户报的正是"**填给我看的示例**也是那个域名"
* (校验失败时那 5 条 error 全在教用户填同一个生产域名)。
* 注释里的历史取证**不算**(那是"当时发生了什么"的记录)—— 所以剥注释再判。
*/
/* ★ 用本文件既有的 `read(rel)`(它已经拼好 ETS 前缀)——
第一版我写了 `code('model/ApiBase.ts')`,而 `code()` 是相对 **electron 包**解析的
⇒ `ENOENT .../client/electron/model/ApiBase.ts`。判据自己崩了。 */
const apiBaseCode = read('model/ApiBase.ts');
const leaked = [...apiBaseCode.matchAll(/https?:\/\/([a-z0-9.-]+)/gi)]
.map(m2 => m2[1])
.filter(h => !RESERVED.test('https://' + h + '/api/v1') && !/^(127\.|10\.|192\.168\.|localhost|\[::1\])/.test(h));
assert.deepEqual(leaked, [],
`★ 代码(非注释)里出现了真实域名:${[...new Set(leaked)].join(', ')}。`
+ '面向用户的**提示文案**必须用示例域名 —— 用户是照着它填的。'
+ '(`Config.ets` 里那句"原来是什么"属于历史取证,在注释里,不受本条约束。)');
const emu = /EMULATOR_HOST_BASE: string = '([^']+)'/.exec(cfg);
assert.ok(emu && A.normalizeApiBase(emu[1]) === emu[1],
'模拟器备用地址也要带 /api/v1(它走的同一个拼接逻辑)');