diff --git a/client/electron/test/harmony-apibase.test.mjs b/client/electron/test/harmony-apibase.test.mjs index 77793d8..5f9aa58 100644 --- a/client/electron/test/harmony-apibase.test.mjs +++ b/client/electron/test/harmony-apibase.test.mjs @@ -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(它走的同一个拼接逻辑)'); diff --git a/client/harmony/entry/src/main/ets/common/Motion.ets b/client/harmony/entry/src/main/ets/common/Motion.ets index 8a34848..e61447f 100644 --- a/client/harmony/entry/src/main/ets/common/Motion.ets +++ b/client/harmony/entry/src/main/ets/common/Motion.ets @@ -26,6 +26,8 @@ */ import { accessibility } from '@kit.AccessibilityKit'; import { Theme } from './Theme'; +import { componentSnapshot } from '@kit.ArkUI'; +import { image } from '@kit.ImageKit'; export class Motion { private static cached: boolean | undefined = undefined; @@ -87,6 +89,88 @@ export class Motion { * * @param mutate 要让哪个状态发生变化(写在**闭包内**是硬要求) */ + /** + * **页面间转场**(路由 push/pop 时)的入场/退场。 + * + * ── 为什么是这两个值 ── + * + * 照 WebUI 的 `rise-in` 关键帧(`index.css:1298-1307`): + * + * @keyframes rise-in { + * from { opacity: 0; transform: translateY(4px); } + * to { opacity: 1; transform: none; } + * } + * + * ⇒ **淡入 + 上浮 4vp**,时长取 `Theme.durRise`(200)。 + * 与 `.transition(Theme.paneRiseIn())` 是**同一个观感**(那个也是照这个抄的), + * 差别只在于触发时机:`paneRiseIn` 靠挂载/卸载,这里靠**路由切换**。 + * + * ★ 为什么两边都用同一套数值:用户看到的都是"一块内容浮上来", + * 两种触发机制不该给出两种观感。 + * + * ── 用法(写在 `@Entry` 组件的成员函数位置,不是 build 里)── + * + * pageTransition() { + * PageTransitionEnter({ duration: Motion.dur(Theme.durRise), curve: Theme.easeRise }) + * .opacity(0) + * .translate({ y: Theme.riseInOffset }) + * PageTransitionExit({ duration: Motion.dur(Theme.durRise), curve: Theme.easeRise }) + * .opacity(0) + * } + * + * ★ 退场只做淡出、**不做位移**:两端都做位移会让"退出"和"进入"看起来 + * 互相推挤,而 WebUI 的退场也只是消失(它靠挂载/卸载,没有退场位移)。 + */ + static pageEnter(): PageTransitionOptions { + const o: PageTransitionOptions = { + duration: Motion.dur(Theme.durRise), + curve: Theme.easeRise + }; + return o; + } + + /** + * **主题切换时的交叉淡出**(用户:「深色浅色主题切换也加对应的动画」)。 + * + * ── 为什么需要专门的办法 ── + * + * WebUI 那边切主题是**免费**的:CSS transition 作用在 `background-color` 上, + * 而主题靠 CSS 变量换值 ⇒ 变量一变,每个带 transition 的元素**各自补间** + * (`index.css:1169` 给 button/a/input/textarea/select 都挂了)。 + * + * ArkUI 没有这个机制:主题由 `app.setColorMode()` 换,而语义色 + * (`$r('sys.color.*')`)是**系统资源**,换的瞬间所有元素同时跳变 —— + * 没有任何东西可以补间(资源不是数值)。实测观感是"闪一下"。 + * + * ── 做法:把旧主题画成一张位图,盖在上面淡出 ── + * + * ① 切换**前**用 `getComponentSnapshot().get(id)` 把整页抓成 `PixelMap`; + * ② 切主题(状态跳变,底层已经是新主题); + * ③ 把位图放在最上层、`opacity` 从 1 补间到 0 ⇒ 观感是"旧界面淡出、新界面透出"。 + * + * ★ 为什么不用 `animateTo` 直接改主题:它只能补间**数值属性**, + * 而这里要变的是系统资源引用 —— `animateTo` 对它无能为力(不是曲线问题)。 + * + * ★ 为什么不用"两层都截屏":新主题那一层不需要截图, + * 它就是真实界面(截屏反而会把它变成静态图,动画期间不能再滚动/响应)。 + * + * 返回 `PixelMap` 供调用方放进 `Image`;失败时返回 `undefined`, + * 调用方直接切主题(退化成原来的瞬切,不因为动画失败而卡住功能)。 + * + * @param ui 调用方的 UIContext(静态方法里拿不到 `this.getUIContext()`) + * @param rootId 要抓图的组件 id(用 `.id()` 声明) + */ + static async captureForThemeFade(ui: UIContext, rootId: string): Promise { + try { + /* `waitUntilRenderFinished: true` —— 不等渲染完成会抓到上一帧的半成品 */ + const opt: componentSnapshot.SnapshotOptions = { waitUntilRenderFinished: true }; + return await ui.getComponentSnapshot().get(rootId, opt); + } catch (e) { + /* 抓不到就不做动画(功能优先于观感)—— 不能因为动画失败而切不了主题 */ + return undefined; + } + } + static morph(ui: UIContext, mutate: () => void): void { /* * ★ 必须收 `UIContext` 并走 `ui.animateTo` —— diff --git a/client/harmony/entry/src/main/ets/common/Theme.ets b/client/harmony/entry/src/main/ets/common/Theme.ets index 77d65b8..c30049c 100644 --- a/client/harmony/entry/src/main/ets/common/Theme.ets +++ b/client/harmony/entry/src/main/ets/common/Theme.ets @@ -878,6 +878,17 @@ export class Theme { * 就得先把它拆开 —— 拆的时候一定会漏掉某处调用。 */ static readonly durMorph: number = 220; + /** + * **主题切换**交叉淡出的时长。 + * + * 取值理由:主题切换是**整屏**变化,比单个组件的入场要慢一点才不刺眼 + * (260ms 落在"能看清是个过渡"与"不让人觉得卡"之间)。 + * + * ★ 与 `durRise`(200) / `durMorph`(220) 分开:三者是三种不同的动效。 + * 本仓为此已经写过一次教训 —— **时长令牌合并后,想单独调一个就得先拆开, + * 而拆的时候一定会漏掉某处调用点**。 + */ + static readonly durThemeFade: number = 260; /** 弹层入场 —— WebUI `.animate-menu-in` 实测 **140ms** */ static readonly durMenu: number = 140; /** 日历翻月 —— WebUI `.cal-slide-next/prev` 实测 **200ms** */ diff --git a/client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets b/client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets index a92bb7f..e8696d3 100644 --- a/client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets +++ b/client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets @@ -26,6 +26,7 @@ import { ApiClient, ApiError } from '../api/ApiClient'; import { Theme } from '../common/Theme'; import { AppHeader, PressEffectModifier } from '../common/Surface'; +import { Motion } from '../common/Motion'; import { AdminApi, MeApi } from '../api/AdminApi'; import { AdminUser, @@ -278,6 +279,33 @@ struct AdminUsersPage { } } + /** + * **页面间转场**(用户:「加页面转场,因为是桌面应用了」)。 + * + * 照 WebUI `rise-in` 的关键帧(`index.css:1298-1307`): + * from { opacity: 0; transform: translateY(4px) } + * to { opacity: 1; transform: none } + * ⇒ 淡入 + 上浮 `Theme.riseInOffset`(4vp),时长 `Theme.durRise`(200)。 + * + * ★ 官方文档(`ts-page-transition-animation`)说明: + * 「当路由(router)进行切换时,可以通过在 `pageTransition` 函数中 + * 自定义页面入场和页面退场的转场动效」,且 + * 「为了实现更好的转场效果,**推荐使用 Navigation 组件和模态转场**」。 + * + * ⇒ 所以:**走 `router` 的本页用 `pageTransition`**; + * 而应用内的 Navigation 跳转(邮件详情 / 写信,都走 `pushPath`) + * 另有 `NavDestination` 的机制 —— 两套不混用。 + * + * ★ 退场只做**淡出、不做位移**:两端都位移会看起来互相推挤。 + */ + pageTransition() { + PageTransitionEnter({ duration: Motion.dur(Theme.durRise), curve: Theme.easeRise }) + .opacity(0) + .translate({ y: Theme.riseInOffset }) + PageTransitionExit({ duration: Motion.dur(Theme.durRise), curve: Theme.easeRise }) + .opacity(0) + } + build() { Column() { this.Header() diff --git a/client/harmony/entry/src/main/ets/pages/ComposePage.ets b/client/harmony/entry/src/main/ets/pages/ComposePage.ets index 786e725..20b9553 100644 --- a/client/harmony/entry/src/main/ets/pages/ComposePage.ets +++ b/client/harmony/entry/src/main/ets/pages/ComposePage.ets @@ -799,6 +799,33 @@ export struct ComposeView { struct ComposePage { @StorageLink(KEY_WINDOW_INSETS) windowInsets: Insets = new Insets(); + /** + * **页面间转场**(用户:「加页面转场,因为是桌面应用了」)。 + * + * 照 WebUI `rise-in` 的关键帧(`index.css:1298-1307`): + * from { opacity: 0; transform: translateY(4px) } + * to { opacity: 1; transform: none } + * ⇒ 淡入 + 上浮 `Theme.riseInOffset`(4vp),时长 `Theme.durRise`(200)。 + * + * ★ 官方文档(`ts-page-transition-animation`)说明: + * 「当路由(router)进行切换时,可以通过在 `pageTransition` 函数中 + * 自定义页面入场和页面退场的转场动效」,且 + * 「为了实现更好的转场效果,**推荐使用 Navigation 组件和模态转场**」。 + * + * ⇒ 所以:**走 `router` 的本页用 `pageTransition`**; + * 而应用内的 Navigation 跳转(邮件详情 / 写信,都走 `pushPath`) + * 另有 `NavDestination` 的机制 —— 两套不混用。 + * + * ★ 退场只做**淡出、不做位移**:两端都位移会看起来互相推挤。 + */ + pageTransition() { + PageTransitionEnter({ duration: Motion.dur(Theme.durRise), curve: Theme.easeRise }) + .opacity(0) + .translate({ y: Theme.riseInOffset }) + PageTransitionExit({ duration: Motion.dur(Theme.durRise), curve: Theme.easeRise }) + .opacity(0) + } + build() { Column() { ComposeView() diff --git a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets index 81c17d1..bf00e98 100644 --- a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets @@ -1562,6 +1562,33 @@ struct MailDetailPage { */ @StorageLink(KEY_WINDOW_INSETS) windowInsets: Insets = new Insets(); + /** + * **页面间转场**(用户:「加页面转场,因为是桌面应用了」)。 + * + * 照 WebUI `rise-in` 的关键帧(`index.css:1298-1307`): + * from { opacity: 0; transform: translateY(4px) } + * to { opacity: 1; transform: none } + * ⇒ 淡入 + 上浮 `Theme.riseInOffset`(4vp),时长 `Theme.durRise`(200)。 + * + * ★ 官方文档(`ts-page-transition-animation`)说明: + * 「当路由(router)进行切换时,可以通过在 `pageTransition` 函数中 + * 自定义页面入场和页面退场的转场动效」,且 + * 「为了实现更好的转场效果,**推荐使用 Navigation 组件和模态转场**」。 + * + * ⇒ 所以:**走 `router` 的本页用 `pageTransition`**; + * 而应用内的 Navigation 跳转(邮件详情 / 写信,都走 `pushPath`) + * 另有 `NavDestination` 的机制 —— 两套不混用。 + * + * ★ 退场只做**淡出、不做位移**:两端都位移会看起来互相推挤。 + */ + pageTransition() { + PageTransitionEnter({ duration: Motion.dur(Theme.durRise), curve: Theme.easeRise }) + .opacity(0) + .translate({ y: Theme.riseInOffset }) + PageTransitionExit({ duration: Motion.dur(Theme.durRise), curve: Theme.easeRise }) + .opacity(0) + } + build() { Column() { MailDetailView() diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index aae0335..2185a39 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -109,6 +109,14 @@ const MAIL_DETAIL_ROUTE: string = 'mail-detail'; */ const COMPOSE_ROUTE: string = 'compose'; +/** + * 主题交叉淡出要抓图的那个节点的 id(见 `MainPage` 根 Stack 的 `.id()`)。 + * + * 常量而不是字面量:`.id()` 与 `getComponentSnapshot().get(id)` 两处拼错**不报错**, + * 只表现为“抓不到图 ⇒ 动画静默不发生”(与 `AppStorage` 键名同一个坑)。 + */ +const THEME_FADE_ROOT_ID: string = 'theme-fade-root'; + /** WebUI 邮件列表统一使用 MM/DD HH:mm,避免把 ISO 原文塞进窄列表。 */ function compactMailTime(iso: string): string { const value: Date = new Date(iso); @@ -3165,6 +3173,19 @@ struct MainPage { private lastColorMode: ConfigurationConstant.ColorMode | undefined = undefined; @State wallpaperImage: image.PixelMap | null = null; + /** + * 主题切换时盖在最上层的**旧主题位图**(淡出后置空)。 + * + * 见 `toggleTheme` 与 `Motion.captureForThemeFade` 的注释。 + * `null` = 没有正在进行的主题淡出。 + * + * ★ 必须在动画结束后清空:留着就是一张**整屏大小**的 PixelMap 常驻内存, + * 而且它盖在最上层、会吃掉所有触摸事件(界面看着正常但点不动)。 + */ + @State themeFadeImage: image.PixelMap | null = null; + /** 淡出层的不透明度(1 → 0),用显式状态驱动 `animateTo`。 */ + @State themeFadeOpacity: number = 0; + /** * 壁纸淡入进度(0 → 1)。 * @@ -3222,12 +3243,77 @@ struct MainPage { * 不是直接 `setColorMode`:直接切系统色彩模式只改本机、换设备不跟着走, * 而外观是账号级偏好。 */ + /** + * 主题快捷切换(侧栏按钮 / 设置页)—— **带交叉淡出**。 + * + * ★★ 2026-09-21 加(用户:「深色浅色主题切换也加对应的动画」)。 + * + * ── 为什么不能只用 `animateTo` ── + * + * 主题走 `app.setColorMode()`,而界面颜色是**系统资源**(`$r('sys.color.*')`)。 + * `animateTo` 只能补间**数值属性** —— "把某个资源换成另一个资源"没有中间值 + * ⇒ 实测是整屏同时跳变(闪一下)。 + * + * WebUI 那边不存在这个问题:CSS transition 挂在 `background-color` 上, + * 主题换的是 CSS **变量**,于是每个元素各自补间(`index.css:1169`)。 + * ArkUI 没有对应机制 ⇒ 只能自己造一个"旧屏淡出"。 + * + * ── 做法 ── + * + * ① 切主题**之前**把当前界面抓成 `PixelMap`; + * ② 立刻切主题(底层已经是新主题); + * ③ 把那张位图盖在最上层、`opacity` 1 → 0 补间 ⇒ "旧界面淡出、新界面透出"。 + * + * ★ 抓图失败(或动画被系统关掉)时**直接切主题**: + * 功能优先于观感 —— 不能因为动画失败而切不了主题。 + */ private toggleTheme(): void { const ctx = this.getUIContext().getHostContext(); if (ctx === undefined) { return; } const next: string = this.isDarkNow ? 'light' : 'dark'; + const ui: UIContext = this.getUIContext(); + /* 动画被系统关掉时不做交叉淡出(与 `Motion.dur` 折 0 同一口径) */ + if (Motion.reduced()) { + this.applyThemeNow(ctx, next); + return; + } + Motion.captureForThemeFade(ui, THEME_FADE_ROOT_ID).then((bmp: image.PixelMap | undefined) => { + if (bmp === undefined) { + /* 抓不到就不做动画(退化成原来的瞬切) */ + this.applyThemeNow(ctx, next); + return; + } + this.themeFadeImage = bmp; + this.themeFadeOpacity = 1; + /* 先切主题(底层变新),再让旧屏淡出 */ + this.applyThemeNow(ctx, next); + ui.animateTo({ + duration: Motion.dur(Theme.durThemeFade), + curve: Theme.easeOutSoft + }, () => { + this.themeFadeOpacity = 0; + }); + /* + * 动画结束后**必须清掉位图**(理由见 `themeFadeImage` 的注释)。 + * 用 `setTimeout` 而不是完成回调:`animateTo` 没有可用的完成钩子, + * 而本仓既有口径就是"时长到了就收"。+40ms 余量避免在最后一帧就抽掉。 + */ + setTimeout(() => { + this.themeFadeImage = null; + this.themeFadeOpacity = 0; + }, Motion.dur(Theme.durThemeFade) + 40); + }); + } + + /** + * 真正的主题落地(**不含动画**)—— `toggleTheme` 的两条路径共用。 + * + * 抽出来是为了让"有动画"与"没动画(抓图失败 / 系统关了动画)" + * 两条路走**同一份**状态变更逻辑。两份拷贝必然漂移。 + */ + private applyThemeNow(ctx: Context, next: string): void { const store: AppearanceStore = AppearanceStore.getInstance(); const snap: AppearanceSnapshot = store.current(); snap.theme = next; @@ -4179,6 +4265,25 @@ struct MainPage { } .width('100%') .layoutWeight(1) + + /* + * ── 主题切换的**旧主题位图**(Stack 的最后一个子元素 ⇒ 最上层)── + * + * 见 `toggleTheme` 的注释:ArkUI 没有 CSS 那种"变量变了各自补间"的机制 + * (主题走 `app.setColorMode()`,颜色是**系统资源**、没有中间值可补间) + * —— 所以把旧界面画成一张图盖在上面淡出。 + * + * ★ `hitTestBehavior(None)`:动画期间用户可能正好在点某个按钮, + * 不该被这层静态图吃掉(它在动画结束就会被置 `null`)。 + */ + if (this.themeFadeImage !== null) { + Image(this.themeFadeImage) + .width('100%') + .height('100%') + .objectFit(ImageFit.Cover) + .opacity(this.themeFadeOpacity) + .hitTestBehavior(HitTestMode.None) + } } .width('100%') .height('100%') @@ -4190,6 +4295,12 @@ struct MainPage { this.isWide = (newValue.width as number) >= 768; this.recomputeNavReserve(); }) + /* + * ★★ 2026-09-21 加 id:**主题切换交叉淡出**用它抓整页快照 + * (`getComponentSnapshot().get(id)`,见 `Motion.captureForThemeFade`)。 + * 挂在根 `Stack` 上 ⇒ 抓到的是"整个界面",包括壁纸层。 + */ + .id(THEME_FADE_ROOT_ID) /* * ★★ 2in1 快捷键(用户 2026-09-21:「接下来做一下 2in1 上的快捷键, * 比如快捷键打开发信页面」)。