diff --git a/client/electron/src/components/CalendarView.tsx b/client/electron/src/components/CalendarView.tsx index 20d0d81..783ca2f 100644 --- a/client/electron/src/components/CalendarView.tsx +++ b/client/electron/src/components/CalendarView.tsx @@ -150,9 +150,39 @@ export default function CalendarView() { * 那是移动端最容易犯的手势错误);同时要求时间 < 600ms,避免"慢慢拖"也翻页。 */ const touch = useRef<{ x: number; y: number; t: number } | null>(null); + + /** + * 触点是否落在**可横向滚动**的区域里(周视图就是:`overflow-auto` + `min-w-[36rem]`)。 + * + * 用户(2026-09-14):「横向滚动条会与切换视图的手势冲突,我觉得周视图需要卡严条件」。 + * + * 冲突是真的:周视图在窄屏下必须横向滚,而我又给整页加了左右滑动翻页 ⇒ + * 同一次横滑既想滚又想翻,结果两边都不好用。规则定死:**手势从可横向滚动的 + * 区域里开始,就归滚动条,不翻页**。想要翻页就从别处(例如上方的标题栏)滑。 + */ + const startsInHorizontalScroller = (target: EventTarget | null, root: EventTarget | null) => { + let el = target as HTMLElement | null; + while (el && el !== root) { + const cs = getComputedStyle(el); + if ( + (cs.overflowX === 'auto' || cs.overflowX === 'scroll') && + el.scrollWidth > el.clientWidth + 4 + ) { + return true; + } + el = el.parentElement; + } + return false; + }; + const onTouchStart = (e: React.TouchEvent) => { const t = e.touches[0]; if (!t) return; + if (startsInHorizontalScroller(e.target, e.currentTarget)) { + // 卡严条件:这一次手势完全不参与翻页判断 + touch.current = null; + return; + } touch.current = { x: t.clientX, y: t.clientY, t: Date.now() }; }; const onTouchEnd = (e: React.TouchEvent) => { diff --git a/client/electron/src/index.css b/client/electron/src/index.css index 3c89e82..1170bf1 100644 --- a/client/electron/src/index.css +++ b/client/electron/src/index.css @@ -1354,3 +1354,31 @@ html[data-bg='on'] .glass-card:hover { flex: 0 0 auto; width: 320px; } + + +/* + * ★ 滚动容器的边缘淡出(2026-09-14 用户:「内容项上下滑动会直接被切断」)。 + * + * 滚动条本身是"一刀切":卡片滚到边缘就被硬生生截断。给滚动容器加一层 + * 上下渐隐的遮罩,切断处变成渐隐 —— 切得有交代,观感也不再像 bug。 + * + * 只作用在**纵向**滚动容器(`.overflow-y-auto`):周视图那种**横向**滚动 + * 有自己的横向滚动条,加纵向遮罩会跟它打架(用户刚提过手势冲突那件事)。 + * 上下各 12px 与列表内边距(10px)接近,静止时几乎看不出,滚动时才起作用。 + */ +.overflow-y-auto { + -webkit-mask-image: linear-gradient( + to bottom, + transparent 0, + #000 12px, + #000 calc(100% - 12px), + transparent 100% + ); + mask-image: linear-gradient( + to bottom, + transparent 0, + #000 12px, + #000 calc(100% - 12px), + transparent 100% + ); +} diff --git a/client/electron/test/harmony-appearance.test.mjs b/client/electron/test/harmony-appearance.test.mjs index 009739c..9c52f93 100644 --- a/client/electron/test/harmony-appearance.test.mjs +++ b/client/electron/test/harmony-appearance.test.mjs @@ -423,3 +423,37 @@ test('判据自检:预设清单少一档必须判红', () => { const trimmed = webIds.slice(0, -1); assert.notDeepEqual(trimmed, W.PRESET_IDS, '自检:裁掉一档后必须与实现不一致(否则这条判据没有分辨力)'); }); + +test('★ 背景画出来了还不够:每个页面要**让出**页面底,否则壁纸全被盖住', () => { + /* + * 这一条是"渲染"这句话的另一半。只把壁纸铺在最底层、而每个页面自己又刷一层 + * **不透明**的系统页面底,壁纸就等于没画(用户看到的仍然是纯色页面)。 + * WebUI 侧的原话:「页面底 → 完全透明,让出背景;不改 27 个组件的 class, + * 逐个加 class 必然漏(漏掉的那块就是一张不透明卡片浮在背景上)」。 + * + * 所以判据钉的是"没有一处页面底还在用不透明的系统页面底"—— + * 漏掉任何一个页面,就是那一页看不到壁纸。 + */ + const main = read('pages/MainPage.ets'); + const opaqueRoots = [...main.matchAll(/\.backgroundColor\(Theme\.pageBg\)/g)].length; + assert.equal(opaqueRoots, 0, + `还有 ${opaqueRoots} 处页面底用不透明的 Theme.pageBg —— 那几页看不到壁纸`); + const yielded = [...main.matchAll(/\.backgroundColor\(this\.bgActive \? Color\.Transparent : Theme\.pageBg\)/g)].length; + assert.ok(yielded >= 5, `要让出页面底的页面至少 5 个(通信/联系人 + 三个 pane),实际 ${yielded} 处`); + + // 每个页面都要**收到**这个开关:漏传 = 该页恒为不透明(等于没有让出) + for (const comp of ['CommPage', 'ContactsTab']) { + assert.match(main, new RegExp(`${comp}\\(\\{ bgActive: this\\.bgActive \\}\\)`), `主界面要把 bgActive 传给 ${comp}`); + } + for (const pane of ['InboxTab', 'SentTab', 'PermissionTab']) { + assert.match(main, new RegExp(`${pane}\\(\\{ bgActive: this\\.bgActive \\}\\)`), `通信页要把 bgActive 传给 ${pane}`); + } + // 开关必须由**背景计划**驱动(不是写死的 true/false) + assert.match(main, /this\.bgActive = this\.bgPlan\.kind !== 'none';/, 'bgActive 要由背景计划决定'); + // 每个组件都要声明这个 @Prop(漏一个就编译不过,但判据先钉住意图) + for (const comp of ['CommPage', 'ContactsTab', 'InboxTab', 'SentTab', 'PermissionTab']) { + const at = main.indexOf(`struct ${comp} {`); + const head = main.slice(at, at + 400); + assert.match(head, /@Prop bgActive: boolean = false;/, `${comp} 要声明 @Prop bgActive`); + } +}); diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index 458f3c1..59ed1fc 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -53,6 +53,8 @@ const INBOX_PAGE_SIZE: number = 50; @Component struct InboxTab { + /** 背景是否开启:开着就让出页面底(WebUI 的做法是页面底完全透明) */ + @Prop bgActive: boolean = false; @State mails: MailSummary[] = []; /** 按会话折叠后的列表(单封的组平铺渲染) */ @State groups: SessionGroup[] = []; @@ -409,7 +411,7 @@ struct InboxTab { .backgroundColor(Theme.surface) } .width('100%').height('100%') - .backgroundColor(Theme.pageBg) + .backgroundColor(this.bgActive ? Color.Transparent : Theme.pageBg) } /* @@ -576,6 +578,8 @@ struct InboxTab { @Component struct SentTab { + /** 背景是否开启:开着就让出页面底(WebUI 的做法是页面底完全透明) */ + @Prop bgActive: boolean = false; @State groups: SessionGroup[] = []; @State expandedKeys: string[] = []; @State loading: boolean = false; @@ -778,7 +782,7 @@ struct SentTab { } } .width('100%').height('100%') - .backgroundColor(Theme.pageBg) + .backgroundColor(this.bgActive ? Color.Transparent : Theme.pageBg) } } @@ -797,6 +801,8 @@ struct SentTab { @Component struct PermissionTab { + /** 背景是否开启:开着就让出页面底(WebUI 的做法是页面底完全透明) */ + @Prop bgActive: boolean = false; @State requests: PermissionRequest[] = []; @State loading: boolean = false; @State error: string = ''; @@ -1008,7 +1014,7 @@ struct PermissionTab { } } .width('100%').height('100%') - .backgroundColor(Theme.pageBg) + .backgroundColor(this.bgActive ? Color.Transparent : Theme.pageBg) } } @@ -1032,6 +1038,8 @@ struct PermissionTab { @Component struct CommPage { + /** 背景开启时,本页与其三个 pane 的页面底都要让出(否则壁纸全被盖住) */ + @Prop bgActive: boolean = false; @State commTab: string = 'inbox'; @State unreadCount: number = 0; @State pendingCount: number = 0; @@ -1165,11 +1173,11 @@ struct CommPage { this.CommTabBar() if (this.commTab === 'sent') { - SentTab() + SentTab({ bgActive: this.bgActive }) } else if (this.commTab === 'permissions') { - PermissionTab() + PermissionTab({ bgActive: this.bgActive }) } else { - InboxTab() + InboxTab({ bgActive: this.bgActive }) } } .width('100%').height('100%') @@ -1188,12 +1196,13 @@ struct CommPage { .onClick(() => { this.openCompose(); }) } .width('100%').height('100%') - .backgroundColor(Theme.pageBg) + .backgroundColor(this.bgActive ? Color.Transparent : Theme.pageBg) } } @Component struct ContactsTab { + @Prop bgActive: boolean = false; @State contacts: Contact[] = []; @State loading: boolean = false; @State error: string = ''; @@ -1298,7 +1307,7 @@ struct ContactsTab { } } .width('100%').height('100%') - .backgroundColor(Theme.pageBg) + .backgroundColor(this.bgActive ? Color.Transparent : Theme.pageBg) } /** @@ -1459,6 +1468,15 @@ struct MainPage { * 现在补上:预设档画渐变、图片档画图 + 压暗。 */ @State bgPlan: BackgroundPlan = new BackgroundPlan(); + /** + * 背景是否开着 —— 传给每个页面,让它们把**页面底**让出来(变成透明)。 + * + * 这是"背景画出来了"这句话的另一半:只把壁纸铺在最底层、而每个页面自己又刷一层 + * 系统页面底(`Theme.pageBg` 是不透明的),壁纸就**全被盖住**,等于没画。 + * WebUI 侧对这件事的原话:「页面底 → 完全透明,让出背景;不改 27 个组件的 class, + * 逐个加 class 必然漏(漏掉的那块就是一张不透明卡片浮在背景上)」。 + */ + @State bgActive: boolean = false; @State wallpaperImage: image.PixelMap | null = null; private gridSettings: RenderingContextSettings = new RenderingContextSettings(true); private gridCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.gridSettings); @@ -1488,6 +1506,7 @@ struct MainPage { const snap: AppearanceSnapshot = store.current(); this.wallpaperImage = store.wallpaper; this.bgPlan = resolveBackground(snap.bgKind, snap.bgPresetId, scrimOpacity(snap.bgDim), store.wallpaper !== null); + this.bgActive = this.bgPlan.kind !== 'none'; } /** 一层渐变的色标:`['色', 位置]` 成对(页面才拼,纯逻辑里只存两个数组) */ @@ -1619,12 +1638,12 @@ struct MainPage { this.WallpaperLayer() Tabs({ barPosition: BarPosition.End }) { TabContent() { - CommPage() + CommPage({ bgActive: this.bgActive }) } .tabBar(this.TabBarBuilder('通信', '✉️', 0)) TabContent() { - ContactsTab() + ContactsTab({ bgActive: this.bgActive }) } .tabBar(this.TabBarBuilder('联系人', '👤', 1)) } diff --git a/docs/HARMONY-ALIGN-PLAN.md b/docs/HARMONY-ALIGN-PLAN.md index 37609b7..bdba758 100644 --- a/docs/HARMONY-ALIGN-PLAN.md +++ b/docs/HARMONY-ALIGN-PLAN.md @@ -158,7 +158,7 @@ WebUI 侧 `npm test` 在 **HEAD 上就是红的**(`test/background.test.mjs` | P2a ✅ | 信息架构:「通信」一项,内部页签 收件箱/发件箱/授权(未读红、待决策橙徽标) | **点页签 → 断言落到哪个 pane**(不是断言页签个数)—— 页签状态机判据已落地(见 §7.15) | | P2b ✅ | 发件箱页:`GET /me/mail/sent`,复用列表项 | 判据:接口路径、行上主角是收件人、空态有说明(主句与 WebUI 逐字一致) | | P3 ⚠️ 主体完成 | 授权页:`GET /permission/pending` + `POST /permission/decide` | 未决口径与 WebUI 一致(无 `permission_result`)✅;拒绝可填备注且备注送出 ✅;`expired` 当场说清"这次批准不会恢复原调用" ✅。**未验**:真机上点同意/拒绝后状态是否"立刻变"(判据只钉到"决策后重新拉列表"这一层) | -| P4 ✅(壁纸上传除外) | 主题/壁纸(`/me/appearance`) | 换账号外观跟随 ✅(缓存键带账号);服务端无记录时以本地为准 ✅(§7.16)。**未做**:壁纸**上传**入口(需要 picker)。**未验**:真机渲染 | +| P4 ✅(P4c 上传除外) | 主题/壁纸(`/me/appearance`) | 换账号外观跟随 ✅(缓存键带账号);服务端无记录时以本地为准 ✅(§7.16);**预设 6 档都能画出来** ✅、图片壁纸渲染 ✅(§7.17 —— 这一版补的,第一版只有数据没有画面)。**未做**:P4c 上传入口。**未验**:真机观感与深色档预设 | | P5 ⬜ 未做 | 悬浮玻璃导航(取代系统 TabBar) | 模糊只由壁纸层负责;列表项每项一张卡;命中区 ≥44vp。现状:底栏仍是系统 `Tabs`,只有自绘的 tabBar builder 带了 `backgroundBlurStyle`(§7.10) | | P6 ⬜ 未做 | 日历(`/calendar/events`,含 ics 导入导出) | 手势阈值与 WebUI 一致(水平 ≥40px、≥1.5× 垂直、<600ms)。**有意排序**:入口与内容一起上,不留空页签(§7.15) | @@ -667,6 +667,78 @@ WebUI 也接好了;鸿蒙这边此前**完全没有接** —— 主题与壁 发现方式是判据去 SD​K 的枚举文件里读数比对,而不是凭印象。映射也因此搬进了纯逻辑 (`colorModeValue`),从"某处有个 setColorMode 调用"变成"可判据的行为"。 -**未验 / 未做**:壁纸在真机上的渲染效果(需要真机或模拟器); -**壁纸上传(选图 → `POST /me/appearance/image`)还没接** —— 需要文件选择器(picker), -这一期的 API 与命名都已就位,但入口没做,所以**不要**把它当成"已完成"。 +**⚠️ 更正一处我说得比证据强的地方**:上一版这里写的是"壁纸在真机上的**渲染**效果未验", +听着像"已经画出来了、只是没在真机上看过"。实际情况是:**P4 第一版没有任何东西去画它** —— +`AppearanceStore` 取回了 `PixelMap`、算好了快照,但没有组件把它渲染出来, +也就是说那一版里"壁纸"只有数据没有画面。取回像素这件事是真的(提交信息没写错), +但"渲染未验"这个说法把"没做"说成了"没验"。这条更正记在这里,免得后来人以为是回归。 + +### 7.17 P4b:预设档的画法(pi 指出的**信息对等**缺口) + +pi 的原话:「WebUI 的背景有**预设渐变**,服务端存的是 preset 名 + 参数, +鸿蒙拿到 preset 名画得出来吗?如果只支持 `image` 与 `none`,那'换账号后外观跟随' +对预设档就是**不成立**的 —— 用户设了预设,在鸿蒙看到的是没有背景。 +这是一个信息对等缺口,不是入口缺口,而且它比上传入口更容易被忽略。」 + +他说对了,而且当时比这更糟(见上面那条更正)。现在: + +- `model/Wallpaper.ts`(纯逻辑,判据直接跑):预设清单(id / 中文标签 / 归一化)、 + 色板、六个预设各由哪些层叠出来、`resolveBackground()` 决定画什么; +- 页面用**系统原语**画:`radialGradient` / `linearGradient`; + **网格档**(CSS 的 `repeating-linear-gradient`)系统没有对应原语 → 用系统 `Canvas` 画线 + (线色/间隔照抄 CSS:gray-200 / 0.55 / 28),理由写在模块里; +- 图片档:`Image(pixelMap)` + 系统遮罩色按服务端浓度压暗; +- `image` 档但图没取回来 → **什么都不画**(画一块空白会被当成"壁纸坏了")。 + +**判据**(`harmony-appearance` 11 → 17 条):预设 id/顺序/标签与 WebUI `PRESETS` 逐字一致; +**每个预设色值与 CSS 调色板变量逐个对照**(这类"看起来差不多"的色值最容易悄悄分叉); +色板反向检查(登记了没用的 → 红);透明必须用关键字而不是 8 位色值; +三档的 resolve 行为;页面真的画了(三种原语 + 图片 + 压暗); +以及**模糊归属的互斥形式**(见下)。 + +**判据抓到的真 bug**:我把 `--c-blue-200`(191 219 254 = `#BFDBFE`)写成了 `#BFDCFE` +(两位字母顺序反了)—— 这正是"照 CSS 读出来比"才拦得住的一类错。 +另外判据自己也有两处切片毛病(用 `indexOf('build() {')` 两头夹会跨到别的成员上),已改按行截。 + +### 7.18 pi 撤回的那条口径:模糊归属改成**互斥形式** + +pi 撤回了他原来那句"模糊只由壁纸层负责",并说明了它的来源:那是 **WebUI 的架构结论** +——它的壁纸图层自带 `filter: blur()`,浮在它上面的面再 `backdrop-filter` 就是把同一张 +糊过的底**糊第二遍**(更脏、更掉帧),所以那条规则在 WebUI 侧是空的; +而同一条 CSS 里它的**底部导航 `.narrow-nav` 是有 `backdrop-filter` 的**, +因为那一条背后是**会滚动的内容**,模糊在那里有遮蔽意义。 + +所以正确的形式是两条性质,而不是"归谁": + +1. **同一张底只许被模糊一次**; +2. **模糊应出现在"背后是可变内容"的层**。 + +套到鸿蒙:壁纸是整幅图、栏不吃壁纸,用户的模糊偏好被映射成**材质档位**, +于是栏上的系统材质就是唯一一次模糊,壁纸层不再糊 —— 满足"只一次",也更符合"用系统方案"。 +判据按互斥形式写:**壁纸层不许出现任何模糊/材质**,**导航条必须有系统材质**; +将来 P5 真做悬浮玻璃条(浮在滚动内容上)时,按"背后是可变内容"这条放行第二处, +并在 `GLASS_REGISTRY` 里登记 + 说明它背后确实是滚动内容(不是又一层壁纸)。 + +**语义转换要记清**(pi 要求写进文档,否则以后有人拿"`bg_blur=8px` 与档位对不上"当 bug 报): +WebUI 的"壁纸模糊度(px)"在鸿蒙变成了"**材质档次**"——**不是同一个物理量**; +前者是给 CSS 图层用的半径,后者是系统材质的档位(Thin/Regular/Thick)。 +映射在 `model/Appearance.ts` 的 `blurStyleFor()`,判据与 SDK 的 `BlurStyle` 成员比对。 + +### 7.19 两条跨端约定(pi 2026-09-14 复核后确认) + +- **`Theme.` 的成员名不改**(pi 三条理由:判据钉的是"值来自系统 + 品牌色仍手写", + 改名零收益;名字一致本身就是这个跨端词表的价值,`Theme.surface` ↔ WebUI `surface` + 是同一件事;149 处搬运的风险与收益不成比例)。**边界**:将来某处真需要"系统里更具体的面" + (例如 `ohos_id_color_dialog_bg`),**那时新增一个成员**,而不是把已有名字改一遍。 +- **可选数值字段的"缺省值"本身就是契约的一部分**:WebUI 用 `dflt`、鸿蒙用类字段默认值 —— + 两处默认值不一致就会**静默分叉**(P4 里真踩过:`bg_dim` 缺失时一边给 12、一边给 0)。 + 跨端对齐可选数值字段时,先对齐默认值,再对齐取值。 +- **页签键/顺序**:`CommTab`/`TABS` 与 `WorkCard` 字段集是同一类跨端耦合 —— pi 改 `uiStore.ts` + 或 `CommTabs.tsx` 前会先看鸿蒙这条判据,两边同时改;**不让我单方面红**。 + +**未做(P4c)**:壁纸**上传**入口(选图 → `POST /me/appearance/image`)。pi 定为 P4c(算 P4 范围, +不阻塞别的阶段),照 WebUI 踩过的三条做:**先压缩再上传**(手机直出照片 4–8MB)、 +**失败必须给原因**(别静默失败)、**上传成功后仍以服务端为权威**(`saved` 那套规则对图片同样适用)。 + +**未验**:预设渐变与图片壁纸在真机上的实际观感(尤其是**深色模式**下预设的表现 —— +色板现在取的是 CSS 的浅色档,深色档要不要另给一套,等真机看过再定)。