跨端: 左右滑动翻页(P6 第 3 步)+ 还 gesture-semantics 债 + 修跑不起来的判据基建
★ 这一轮从用户一句「滑动手势呢?」开始。查下去发现它不是"顺手加个手势",
而是 `docs/DEBTS.json` 里挂着的一笔债 —— `gesture-semantics` 的原话是:
「P6 第 3 步:鸿蒙侧出现滑动手势代码时**立即建**判据
(此前建 = 只有一端存在的假判据)」
也就是**先有手势、再钉语义**。WebUI 2026-09-14 就有滑动翻页(用户当时
亲口提的),鸿蒙一直没有 ⇒ 之前建判据会是空真(∀x∈∅)。
## 一、手势本体(两端语义逐项对齐,数值各自定)
按 `HARMONY-ALIGN-PLAN.md:118-128` 显式选的 **(b) 口径**:
「手势的物理量本来就不该强求同值……该对齐的是**语义层**」。
· 判定逻辑放**纯逻辑层** `model/Calendar.ts` 的 `judgeSwipe`(可被 node 直跑,
写在 .ets 里就跑不了判据,语义没法被单测钉住)。四道门:位移 / 纵向优先 /
快滑窗口 / 方向。
· 阈值**不引用** WebUI 的 40 / 1.5 / 600(那是把巧合当契约),
各自定为 56vp / 1.4× / 700ms,并在注释里写出取值依据。
· 接线在 `CalendarPage.ets`:`PanGesture({direction: Horizontal})` +
`onActionStart`(记时 —— `GestureEvent` **没有时间戳字段**,我查了 SDK
的 gesture.d.ts 确认)+ `onActionEnd`(读 offsetX/offsetY)。
· ★ 挂在**网格列**上而不是整页:右栏(日程/编辑器)里有输入框与可滚内容,
整页挂会让「在表单里横划一下」变成翻月。
· ★ 翻页复用 `shiftRange()`(与 ‹ › 按钮**同一个来源**)—— WebUI 的注释
专门交代过:各写一套的话,阈值、边界、三档行为迟早分叉。
手势回调里**不准**直接改 year/month(锚点是唯一真相,年月只能由
`shiftRange → syncYearMonthFrom` 派生)。
**有意差异(记录在案,不是漏做)**:WebUI 在周/日档会额外检查「触点是否落在
可横向滚动的区域里」,是则让给滚动条(用户 2026-09-14 报过这个冲突)。
鸿蒙周档是「一行 7 格按 layoutWeight 等分」、**不横滚** ⇒ 该条件不适用。
哪天加了横滚必须同时补上它。
## 二、判据(8 条语义契约 + 行为层)
新增 `cross-client-gesture.test.mjs`:两端都真有手势 / 方向映射逐项相同 /
纵向优先 / 快滑窗口 / 复用同一翻页函数 / 无边界回弹 / 有意差异被记录 / 自检。
**只比语义、不比数值**,并反向断言鸿蒙的阈值常量不得直接取 WebUI 的那三个数。
`harmony-logic.test.mjs` 加行为判据:真跑 `judgeSwipe`,把四道门各自验一遍
(只钉字符串的话,一个 return 写漏了照样全绿)。
## 三、顺手修掉的三处**判据基建**缺陷(不修就没法验证上面这些)
1. `run-all.mjs` 只认 `# pass N`,而 node v24 打的是 `ℹ pass N`
⇒ **21 个文件被记成"没自报条数"**、套件在 HEAD 就恒红(memory 里记过这条,
修法也记过,今天终于落进代码:4 个正则加 `(?:#|ℹ)`)。修完 `unreported` 27 → 0。
代价是暴露出一批此前被"没自报"掩盖的真实问题(下面 4~6 条)。
2. `harmony-nav` 的设备判据 `navItemsOf`:宽屏过滤条件从「左边缘靠左 1/6」
改成「**整个盒子在侧栏轨道内**」。旧条件把**日历网格的格子**
(实测 `[229,511][366,794]`)也当成导航项,一屏数出 11~12 个。
这是设备实测抓出来的 —— 我第一版还以为是"底部簇混进来了",
打印真实数据才发现是隔壁页面的格子。
3. `harmony-logic` 两处:从 `NAV_ITEMS` / `NAV_SIDEBAR_ITEMS` **各自的数组**里取
label。原先把全文件 `label:` 一网打尽 ⇒ 得到 7 个(4+3 混在一起),
任何一边改对了它都会红。
## 四、被判据拦住后的正经修法(每条都按判据自己给的方向改,不改判据迁就代码)
· `cross-client-theme` A2 拦住我:新增的 `navActiveBg`/`navBrandFg`/`badgePlain`
未登记;又拦住我:`sseColorOf` 里四个裸色值。→ 抽成 `Theme.sseConnected` 等
四个令牌(取值对齐 WebUI 的 Tailwind 类)+ 登记 + 在 Theme.ets 的表里写理由。
· 同一条判据的"死令牌"检出:`Theme.durBase` 声明了却从没人读。
**查 WebUI 才发现壁纸淡入是真有的动效**(`index.css:742-752` 的
`.app-backdrop` 从 opacity:0 → 1,180ms)⇒ 补上而不是删令牌
(删掉等于把差异抹平、还说成"清理")。reset 在 `animateTo` **外**,
与 `calPaneIn` 同一条纪律。
· 玻璃登记:`NavItemBuilder` → `SidebarItem`(重构后按最近的 @Builder 命名),
登记同步跟上。
· `harmony-admin` 的退出判据:退出逻辑抽成 `api/Logout.ets` 的 `performLogout()`
(两个入口——「我的」页与侧栏底簇——必须做同一件事,尤其"先注销推送 token"
那一步)。判据相应改成**追到实际执行处**(两半都断:按钮调了 + 函数真清了全部),
并写明"别再退回直接匹配 SETTINGS_PAGE 的写法"(那会随重构假红,
下一个人只会去改判据而不看行为)。
· `harmony-widescreen` 的连接点色值:色值搬进 Theme 后,判据改成断
「令牌定义对了 + 侧栏真的用了它」两半(只断任一半都有假绿形态)。
· `align-refs`:`CalendarView.tsx` 变了,按判据要求**读一遍差异**再更新登记
(差异只有农历小字的灰阶档位 gray-300→gray-400/500,**骨架未变**)。
· `criteria-hygiene`:`harmony-contacts` 自造了一个 `code()`、`harmony-widescreen`
裸用 `readFileSync` ⇒ 都改用 `lib/read.mjs` 的共享入口。
途中撞出一个**判据自己的 bug**:`code()` 的块注释正则
`/\*[\s\S]*?\*\//` 会把注释里出现的 `/*`(如 `/」**` 这种中文夹星号)
当成块注释起点,一路吃到几十行后的 `*/`,把中间的 import 全吞掉 ——
于是 hygiene 判据假红"没 import"。改掉那处写法后正常。
## 五、验证
判据面:`run-all.mjs` → `files=32 ran=32 checks=497 pass=497 fail=0
skip=0 red=0 broken=0 unreported=0`。
其中新/改判据:gesture 8、nav 18、widescreen 7、logic 30、admin 27、
cross-client-theme 15、criteria-hygiene 6。
构建:`hvigorw assembleHap` 成功;前端 `npm run build` + 重新打 AppImage/deb
(`build-stamp` 7/7、`packaging` 5/5)。
**设备实测(HATriple 三折叠 3184×2232,hdc 连 127.0.0.1:5555)**:
· 农历在格子里真的显示(1=二十 / 7=廿六 / 19=**初九** / 11=八月),与 WebUI 一致;
这条同时验证了**服务端农历路由已部署**(之前线上是 404)。
· 日历左右两栏:编辑器出现在**右栏**、左栏月份仍可见(单栏模式下编辑器会整页盖掉它)。
· 侧栏 3 项 + 底部一簇;徽标回到图标右上角。
**仍未验(如实标注)**:滑动翻页的**手感**(阈值 56vp/1.4×/700ms 是否合适)
只能真人滑过才知道;我只验了判定逻辑与接线形态。动画同理 ——
机制已验证(`animateTo` 驱动 + reset 在窗口外),但"看起来顺不顺"未做取样验证。
docs:`DEBTS.json` 销掉 `gesture-semantics`(并记结算说明)、
`HARMONY-ALIGN-PLAN.md` P6 从「✅(滑动翻页除外)」改为 ✅。
This commit is contained in:
239
client/electron/test/cross-client-gesture.test.mjs
Normal file
239
client/electron/test/cross-client-gesture.test.mjs
Normal file
@ -0,0 +1,239 @@
|
||||
/**
|
||||
* 左右滑动翻页的**语义契约**:两端逐项相同,数值各自定。
|
||||
*
|
||||
* 这条判据是 `docs/DEBTS.json` 的 `gesture-semantics` 那条债的还款 ——
|
||||
* 它的原文是:
|
||||
*
|
||||
* 「P6 第 3 步:鸿蒙侧出现滑动手势代码时立即建
|
||||
* (此前建 = 只有一端存在的假判据)」
|
||||
*
|
||||
* 也就是**先有手势、再钉语义**。2026-09-19 鸿蒙侧真的加了手势
|
||||
* (`CalendarPage.ets` 的 `PanGesture` + `model/Calendar.ts` 的 `judgeSwipe`),
|
||||
* 所以现在建。
|
||||
*
|
||||
* ★ 为什么钉语义而不是钉数值
|
||||
*
|
||||
* `docs/HARMONY-ALIGN-PLAN.md:118-128` 显式选了 (b) 口径,理由是原话:
|
||||
* 「手势的物理量本来就不该强求同值。40px 的位移阈值、600ms 的"快滑"判据,
|
||||
* 在触摸屏与鼠标上、在手机与大屏上,人体工学的合理值天然不同;
|
||||
* 强行同值会得到一个两边都不舒服的数。该对齐的是**语义层**。」
|
||||
*
|
||||
* 所以本文件**从两端源码里各自读一遍语义**(不是读常量名、不是读注释),
|
||||
* 再逐项比对:
|
||||
*
|
||||
* | 语义 | WebUI 侧证据 | 鸿蒙侧证据 |
|
||||
* |------------|-------------------------------------------|-------------------------|
|
||||
* | 左滑=下一段 | `shift(dx < 0 ? 1 : -1)` | `dx < 0 ? 1 : -1` |
|
||||
* | 右滑=上一段 | 同上 | 同上 |
|
||||
* | 纵向优先 | `Math.abs(dx) < Math.abs(dy) * 1.5` | `Math.abs(dx) < Math.abs(dy) * X` |
|
||||
* | 慢拖不翻页 | `Date.now() - start.t < 600` | `ms > Y` 时 return |
|
||||
* | 无边界回弹 | `addMonths` 无钳制(可任意前后) | 同(`addMonths`) |
|
||||
* | 手势复用按钮逻辑 | 手势与按钮都调 `shift()` | 手势与按钮都调 `shiftRange()` |
|
||||
*
|
||||
* 数值(`40` / `1.5` / `600`)**只做形状校验**(是数字、是正数),
|
||||
* 断言它们**相等**才是错的 —— 那会把 (b) 口径推翻。
|
||||
*/
|
||||
import { code, prose } from './lib/read.mjs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..', '..');
|
||||
const HARMONY_ETS = join(ROOT, 'client/harmony/entry/src/main/ets');
|
||||
|
||||
/** WebUI 的手势实现在日历组件里 */
|
||||
const webCal = code(join(ROOT, 'client/electron/src/components/CalendarView.tsx'));
|
||||
/** 鸿蒙的判定逻辑在纯逻辑层(可被 node 直接执行,另有行为判据跑它) */
|
||||
const harmonyLogic = code(join(HARMONY_ETS, 'model/Calendar.ts'));
|
||||
/** 鸿蒙的接线(手势挂在哪个容器上) */
|
||||
const harmonyPage = code(join(HARMONY_ETS, 'pages/CalendarPage.ets'));
|
||||
|
||||
/* ────────────────────────── ① 两端都真的存在手势 ────────────────────────── */
|
||||
|
||||
test('两端都有左右滑动翻页的代码(不是只有一端)', () => {
|
||||
/*
|
||||
* 这是本文件存在的前提。`docs/HARMONY-ALIGN-PLAN.md:136-140` 记过:
|
||||
* 鸿蒙侧当时**没有日历页、也没有任何手势代码**,此时"两端逐项相同"
|
||||
* 只有一端存在 ⇒ 任何断言都是空真(`∀x∈∅`)。所以第一条就把这个前提钉住。
|
||||
*/
|
||||
assert.match(webCal, /onTouchStart/,
|
||||
'WebUI 侧要有触摸起点(`CalendarView.tsx` 的 onTouchStart)');
|
||||
assert.match(webCal, /onTouchEnd/,
|
||||
'WebUI 侧要有触摸终点(判定位移与时长的地方)');
|
||||
|
||||
assert.match(harmonyPage, /PanGesture\s*\(/,
|
||||
'鸿蒙侧要有手势(`PanGesture`)—— 没有它本判据是空真,正是那条债警告的事');
|
||||
assert.match(harmonyLogic, /export function judgeSwipe\s*\(/,
|
||||
'鸿蒙侧的判定要落在**纯逻辑层**(`model/Calendar.ts` 的 judgeSwipe)—— ' +
|
||||
'写在 .ets 里就跑不了 node 判据,语义没法被单测钉住');
|
||||
});
|
||||
|
||||
/* ────────────────────── ② 语义逐项相同(不比数值) ────────────────────── */
|
||||
|
||||
test('★ 语义:左滑 = 下一段,右滑 = 上一段(两端同)', () => {
|
||||
/*
|
||||
* 这是最容易写反、也最容易被"看起来在动"掩盖的一条:
|
||||
* 方向反了功能完全正常,只是与另一端的体感相反 —— 用户跨端用会觉得很怪。
|
||||
*
|
||||
* 两端的写法都必须是 `dx < 0 ? +1 : -1` 这个**同一个形状**:
|
||||
* 左滑(dx 为负)= 前进(+1);右滑(dx 为正)= 后退(-1)。
|
||||
*/
|
||||
const webDir = webCal.match(/shift\(dx\s*<\s*0\s*\?\s*(\d+)\s*:\s*(-?\d+)\)/);
|
||||
assert.ok(webDir,
|
||||
'WebUI 的方向映射要能读出来(形如 `shift(dx < 0 ? 1 : -1)`)');
|
||||
assert.equal(webDir[1], '1', 'WebUI:左滑(dx<0)应映射为 +1(下一段)');
|
||||
assert.equal(webDir[2], '-1', 'WebUI:右滑应映射为 -1(上一段)');
|
||||
|
||||
const hDir = harmonyLogic.match(/delta\s*=\s*dx\s*<\s*0\s*\?\s*(\d+)\s*:\s*(-?\d+)/);
|
||||
assert.ok(hDir,
|
||||
'鸿蒙的方向映射要能读出来(形如 `v.delta = dx < 0 ? 1 : -1`)');
|
||||
assert.equal(hDir[1], webDir[1],
|
||||
'★ 左滑在两端的映射必须相同(这里**该**比数值,因为它是语义不是物理量)');
|
||||
assert.equal(hDir[2], webDir[2],
|
||||
'★ 右滑同理');
|
||||
});
|
||||
|
||||
test('★ 语义:纵向优先 —— 横向位移必须明显大于纵向,两端都有这道门', () => {
|
||||
/*
|
||||
* 移动端最容易犯的手势错误:用户在纵向列表上滚,手指天然带一点横向偏移,
|
||||
* 不卡这条就会"滚着滚着翻页了"。
|
||||
*
|
||||
* 两端都要有这道门,且形状必须是「横向 < 纵向 * 倍数 ⇒ 放弃」。
|
||||
* 倍数是多少**不比**((b) 口径),但必须是个正数。
|
||||
*/
|
||||
const webAxis = webCal.match(/Math\.abs\(dx\)\s*<\s*Math\.abs\(dy\)\s*\*\s*([\d.]+)/);
|
||||
assert.ok(webAxis, 'WebUI 要有"横向 vs 纵向"这道门');
|
||||
assert.ok(Number(webAxis[1]) > 1,
|
||||
`WebUI 的倍数应 > 1(实测 ${webAxis[1]})—— 等于 1 时斜滑就翻页,判据等于没有`);
|
||||
|
||||
const hAxis = harmonyLogic.match(/Math\.abs\(dx\)\s*<\s*Math\.abs\(dy\)\s*\*\s*([A-Z_]+|[\d.]+)/);
|
||||
assert.ok(hAxis, '鸿蒙要有"横向 vs 纵向"这道门(漏了它 = 纵向滚动会误翻页)');
|
||||
|
||||
/* 数值不同是**允许的**((b)),但取值来源要说清是常量而不是裸字面量 */
|
||||
assert.match(harmonyLogic, /export const SWIPE_AXIS_RATIO:\s*number\s*=\s*[\d.]+/,
|
||||
'鸿蒙的倍数要走具名常量(裸字面量会让"这个数为什么是它"无从追溯)');
|
||||
});
|
||||
|
||||
test('★ 语义:慢拖不翻页("快滑"是感知档,不是同一个毫秒数)', () => {
|
||||
/*
|
||||
* WebUI:`const fast = Date.now() - start.t < 600` + `!fast → return`。
|
||||
* 鸿蒙:`if (ms > SWIPE_MAX_DURATION_MS) return v;`。
|
||||
*
|
||||
* 两端都要有这道门;**毫秒数不比**。
|
||||
*/
|
||||
const webFast = webCal.match(/Date\.now\(\)\s*-\s*start\.t\s*<\s*(\d+)/);
|
||||
assert.ok(webFast, 'WebUI 要有"快滑"时间窗(否则慢慢拖也会翻页)');
|
||||
assert.ok(Number(webFast[1]) > 0, 'WebUI 的时间窗应为正数');
|
||||
|
||||
const hFast = harmonyLogic.match(/ms\s*>\s*SWIPE_MAX_DURATION_MS/);
|
||||
assert.ok(hFast, '鸿蒙要有"快滑"时间窗这道门');
|
||||
assert.match(harmonyLogic, /export const SWIPE_MAX_DURATION_MS:\s*number\s*=\s*\d+/,
|
||||
'鸿蒙的时间窗要走具名常量');
|
||||
|
||||
/*
|
||||
* ★ 反向断言:鸿蒙**不得**引用 WebUI 的那三个数。
|
||||
* 这正是 (b) 口径的落点 —— 一旦有人"抄过去省事",两端就被锁在一起,
|
||||
* 以后为大屏调阈值会同时改坏手机端。计划文档明写「鸿蒙**不得引用** WebUI 的三个数」。
|
||||
*/
|
||||
for (const forbidden of ['40', '600', '1.5']) {
|
||||
const asStandalone = new RegExp(`SWIPE_[A-Z_]*:\\s*number\\s*=\\s*${forbidden.replace('.', '\\.')}\\b`);
|
||||
assert.ok(!asStandalone.test(harmonyLogic),
|
||||
`★ 鸿蒙的阈值常量不得直接取 WebUI 的值 ${forbidden} —— ` +
|
||||
'(b) 口径要求数值各自定(见 HARMONY-ALIGN-PLAN.md:118-128)');
|
||||
}
|
||||
});
|
||||
|
||||
test('★ 语义:手势与翻页按钮复用**同一个**翻页函数(两端同)', () => {
|
||||
/*
|
||||
* WebUI 的原话:「复用 shift() —— 手势与「上一月/下一月」按钮必须是同一套翻页逻辑,
|
||||
* 各写一遍的话阈值、边界、"周/日/月"三种刻度的行为迟早分叉。」
|
||||
*
|
||||
* 鸿蒙对应的函数是 `shiftRange()`(`shiftMonth` 只是它的旧名包装)。
|
||||
* 断言方式:页面里出现的手势判定结果必须交给 shiftRange,
|
||||
* 而不是自己算日期/自己改 year/month。
|
||||
*/
|
||||
assert.match(harmonyPage, /if\s*\(v\.turned\)\s*\{[\s\S]{0,60}this\.shiftRange\(v\.delta\)/,
|
||||
'★ 手势判定通过后必须调 `shiftRange(v.delta)` —— 不能自己改 year/month,' +
|
||||
'那会出现"手势翻 1 天、按钮翻 7 天"的分叉');
|
||||
|
||||
/* 手势那一支里不得直接写 year/month(那是 shiftRange 的职责) */
|
||||
const gestureBlocks = harmonyPage.match(/\.onActionEnd\([\s\S]{0,400}?\}\)/g) ?? [];
|
||||
assert.ok(gestureBlocks.length >= 1, '要能找到手势的 onActionEnd 回调');
|
||||
for (const b of gestureBlocks) {
|
||||
assert.ok(!/this\.(year|month)\s*=/.test(b),
|
||||
'★ 手势回调里不得直接改 `year`/`month` —— 锚点是唯一真相,' +
|
||||
'年月只能由 `shiftRange` → `syncYearMonthFrom` 派生');
|
||||
}
|
||||
});
|
||||
|
||||
test('★ 语义:翻页无边界回弹(两端一致)', () => {
|
||||
/*
|
||||
* 语义表里那一行是「翻页边界是否回弹」。两端的答案都是"没有边界":
|
||||
* `addMonths(year, month, delta)` 直接把 month 折进 year(`total % 12`),
|
||||
* 不钳制范围 ⇒ 可以一直往前/往后翻,不存在"到头了"的状态,
|
||||
* 因此**没有回弹动画可看**(不是"忘了做回弹")。
|
||||
*
|
||||
* 这条判据防的是"哪天有人加了钳制":加了钳制就得同时定义回弹语义,
|
||||
* 而那是一个两端都要改的契约变更,不能只改一边。
|
||||
*/
|
||||
const webAddMonths = webCal.includes('addMonths');
|
||||
assert.ok(webAddMonths, 'WebUI 翻页走 addMonths(无钳制)');
|
||||
/*
|
||||
* 锚点要短:`addMonths` 只有两行,中间隔不了多少字符。
|
||||
* (第一版写 `[\s\S]{0,140}?total\s*%\s*12` 反而没匹上 —— 我把它想长了。)
|
||||
*/
|
||||
assert.match(harmonyLogic, /export function addMonths\([\s\S]*?total % 12/,
|
||||
'★ 鸿蒙的 addMonths 必须仍是取模折年(有边界就不对称了);' +
|
||||
'若哪天要加钳制,回弹语义必须两端一起定');
|
||||
});
|
||||
|
||||
/* ─────────────────────── ③ 有意差异被记录(不是漏做) ─────────────────────── */
|
||||
|
||||
test('有意差异:周档的"横滚冲突让位"在鸿蒙侧不存在 —— 但必须被记录', () => {
|
||||
/*
|
||||
* WebUI 在**周/日档**额外检查「触点是否落在可横向滚动的区域里」,
|
||||
* 是则把手势让给滚动条。用户 2026-09-14 亲口报过这个冲突:
|
||||
* 「横向滚动条会与切换视图的手势冲突,我觉得周视图需要卡严条件」。
|
||||
*
|
||||
* 鸿蒙侧**没有这个冲突**:周档是"一行 7 格、按 layoutWeight 等分",不横滚。
|
||||
* 所以这一条**不适用**,不是漏做。
|
||||
*
|
||||
* 判据两半(缺一不可):
|
||||
* ① 鸿蒙的周档确实不横滚(否则就该补卡严条件,而它没补 = 真漏);
|
||||
* ② 这件事在代码里被**记录下来**(下一个人看到 WebUI 有、鸿蒙没有时,
|
||||
* 要能查到这是有意的,而不是当成待办)。
|
||||
*/
|
||||
const strictWeb = /startsInHorizontalScroller/.test(webCal);
|
||||
assert.ok(strictWeb, 'WebUI 侧应有"落在横滚区域就让位"的检查');
|
||||
|
||||
assert.ok(!/Scroll\s*\(\s*\)\s*\{[\s\S]{0,200}?calScale\s*===\s*'week'/.test(harmonyPage),
|
||||
'鸿蒙周档目前不横滚(若改成横滚,必须同时补上这条卡严条件)');
|
||||
|
||||
/*
|
||||
* ★ 读**注释**要用 `prose`(不剥注释),不能用 `code`:
|
||||
* 这条断言判的正是「理由有没有被写下来」,而理由天然在注释里。
|
||||
* 我第一版用了 code(),它把注释剥掉了 ⇒ 永远红。
|
||||
*/
|
||||
const harmonyLogicProse = prose(join(HARMONY_ETS, 'model/Calendar.ts'));
|
||||
assert.match(harmonyLogicProse, /有意\*\*未对齐\*\*的一条|有意未对齐/,
|
||||
'★ 鸿蒙侧要把"这条为什么不适用"写进代码注释 —— ' +
|
||||
'两端行为不同的地方不留记录,下一个人只会当成漏做(§7.12 的同一纪律)');
|
||||
});
|
||||
|
||||
/* ─────────────────────────── ④ 判据自检 ─────────────────────────── */
|
||||
|
||||
test('★ 判据自检:方向写反必须判红', () => {
|
||||
/*
|
||||
* 没有自检的判据是"看起来在跑"的判据。这里模拟"有人把左滑写成上一段",
|
||||
* 确认 ② 那条能红 —— 若不能红,说明它锚错了地方(读的是别的字符串)。
|
||||
*/
|
||||
const mutated = harmonyLogic.replace(/delta\s*=\s*dx\s*<\s*0\s*\?\s*1\s*:\s*-1/,
|
||||
'delta = dx < 0 ? -1 : 1');
|
||||
assert.notEqual(mutated, harmonyLogic, '变异要有实际效果(锚点必须命中)');
|
||||
/* 变异后**不能**再匹配上「左滑=+1」那个形状 —— 这才是那条断言真正的咬法 */
|
||||
const stillOk = /delta\s*=\s*dx\s*<\s*0\s*\?\s*1\s*:\s*-1/.test(mutated);
|
||||
assert.equal(stillOk, false,
|
||||
'★ 方向写反时,②「左滑=下一段」那条必须能判红');
|
||||
});
|
||||
@ -220,7 +220,34 @@ const SELF_OWNED_COLORS = [
|
||||
// 业务语义色:系统没有对应物(同意 / 拒绝 / 警示)
|
||||
'approve', 'danger', 'approveBg', 'approveFg', 'dangerBg', 'warnBg', 'warnFg',
|
||||
// 权限档位与预算档位的胶囊配色(档位是产品语义,系统不认识"plan/workspace/full")
|
||||
'chipNeutralBg', 'chipNeutralFg', 'chipSpentBg', 'chipSpentFg', 'chipWarnBg', 'chipWarnFg'
|
||||
'chipNeutralBg', 'chipNeutralFg', 'chipSpentBg', 'chipSpentFg', 'chipWarnBg', 'chipWarnFg',
|
||||
/*
|
||||
* ★★ 2026-09-19 补登记(对应用户「底栏数字为什么显示在图标下面?」与
|
||||
* 用户「你写的app和webui大面积不符」那两轮改动)。
|
||||
*
|
||||
* 三个值都是**跨端身份色**,系统语义色里没有对应物:
|
||||
* · `navActiveBg` #DBEAFE —— WebUI `index.css:1595-1606` 的 `--nav-active-bg`,
|
||||
* 侧栏选中项的**浅蓝底块**(宽屏侧栏的选中线索就是它,不是文字变色)。
|
||||
* · `navBrandFg` #475569 —— WebUI 的 `--nav-fg-muted`,侧栏未选中项的图标色。
|
||||
* · `badgePlain` #475569 —— WebUI `bg-chrome-600`(`index.css:92`),
|
||||
* "plain" 档徽标底色(联系人数)。**它必须是石板灰而不是红**:
|
||||
* 三档被压成两档时,`'plain'` 会走 `danger`,看起来像"有未读"。
|
||||
* `chrome-600` 刻意不透明(`index.css:1097` 有注释:15px 小控件叠透明度
|
||||
* 会让数字掉到 4.46:1,低于 WCAG AA)。
|
||||
*
|
||||
* 三者的取值理由都已写在 `Theme.ets` 各自的注释里(本判据的要求)。
|
||||
*/
|
||||
'navActiveBg', 'navBrandFg', 'badgePlain',
|
||||
/*
|
||||
* SSE 连接指示器的四个状态色(`WideSidebar.ets` 的 `sseColorOf`)。
|
||||
* 逐档对齐 WebUI 的 Tailwind 类(`ConnectionIndicator.tsx:22-25`):
|
||||
* connected `bg-green-500` / connecting `bg-yellow-400` /
|
||||
* reconnecting `bg-orange-400` / disconnected `bg-red-400`。
|
||||
*
|
||||
* 为什么不复用 `warnFg`/`danger`:那两个是**文字色**,而状态点是 8px 实心圆,
|
||||
* 用文字色会发脏、深色底上不够跳。WebUI 也是分开的两套(`text-*` vs `bg-*`)。
|
||||
*/
|
||||
'sseConnecting', 'sseConnected', 'sseReconnecting', 'sseDisconnected'
|
||||
];
|
||||
|
||||
test('A2|Theme.ets 里"自己写的色"必须**登记过**:新写死一个色不该默默通过', () => {
|
||||
@ -307,7 +334,7 @@ const GLASS_REGISTRY = [
|
||||
// 文件(相对 ets 根) + 组件/Builder 名:为什么这里可以有一层系统材质
|
||||
{ file: 'pages/MainPage.ets', provider: 'NavBar', why: 'P5 悬浮玻璃条:浮在**会滚动的内容**之上(pi 给的放行条件②),壁纸层整个不吃材质' },
|
||||
// ★ 2026-09-16:宽屏 app-shell 复刻 WebUI —— 面板走系统材质(半透明由 BlurStyle 给,不手写 alpha)
|
||||
{ file: 'pages/WideSidebar.ets', provider: 'NavItemBuilder', why: '宽屏侧栏(判据按最近的 @Builder 命名):浮在壁纸之上、背后是会滚动的导航内容(WebUI Sidebar 同形状),bgActive 才开' }
|
||||
{ file: 'pages/WideSidebar.ets', provider: 'SidebarItem', why: '宽屏侧栏(判据按最近的 @Builder 命名):浮在壁纸之上、背后是会滚动的导航内容(WebUI Sidebar 同形状),bgActive 才开。★ 2026-09-19 改名:原先登记的是 NavItemBuilder,但那是我上一版编造的“复刻”(参见 Theme.ets 里那段更正)—— 重写后按 WebUI 拆成导航轨 + 底部一簇,@Builder 相应改名,登记同步跟上(判据就是为此存在的)' }
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -45,6 +45,8 @@ const ADMIN_PAGE = join(ETS, 'pages/AdminUsersPage.ets');
|
||||
const SETTINGS_PAGE = join(ETS, 'pages/SettingsPage.ets');
|
||||
const ADMIN_API = join(ETS, 'api/AdminApi.ets');
|
||||
const ACCOUNT_MGR = join(ETS, 'api/AccountManager.ets');
|
||||
/** 退出登录的唯一实现(两个入口共用)—— 2026-09-19 抽出来后才有的文件 */
|
||||
const LOGOUT_MOD = join(ETS, 'api/Logout.ets');
|
||||
const PAGES_JSON = join(ROOT, 'client/harmony/entry/src/main/resources/base/profile/main_pages.json');
|
||||
|
||||
// 真跑纯逻辑(有 `@ohos` 依赖的文件不能这样跑;AdminUsers.ts 刻意零 import)
|
||||
@ -420,10 +422,31 @@ test('★ 退出登录必须清**全部**账号(否则被 LoginPage 快速登
|
||||
* 这是鸿蒙特有的路径(WebUI 没有"本机多账号库自动恢复",
|
||||
* 所以它的 `logout()` 只 `set({phase:'anonymous'})` 就够)。
|
||||
*/
|
||||
/*
|
||||
* ★★ 2026-09-19 修:退出逻辑被抽成了 `api/Logout.ets` 的 `performLogout()`。
|
||||
*
|
||||
* 为什么要抽(不是为了好看):退出有**两个入口** —— 「我的」页的按钮、
|
||||
* 宽屏侧栏底部那一簇的退出键。复制一份实现的话迟早分叉,而最容易分叉的
|
||||
* 恰恰是**最不能少的那一步**(注销推送 token —— 少了它,退出后服务器还会
|
||||
* 往一台"已退出"的设备推通知)。所以两个入口都走同一个函数。
|
||||
*
|
||||
* 本判据相应改成**追到实际执行处**:断言「我的」页真的调了 performLogout,
|
||||
* 且 performLogout 里真的清了全部账号。**两半都要断** ——
|
||||
* 只断第一半会放过"performLogout 被改成空实现",
|
||||
* 只断第二半会放过"按钮没接上"(那种情况页面里照样有 performLogout 这几个字)。
|
||||
*
|
||||
* ⚠️ 别退回"直接匹配 SETTINGS_PAGE 里的 clearAll()"的写法:
|
||||
* 那会随重构假红(今天就是),于是下一个人只会去改判据、而不去看行为。
|
||||
*/
|
||||
const src = code(SETTINGS_PAGE);
|
||||
assert.match(src, /clearAll\(\)/,
|
||||
assert.match(src, /performLogout\(/,
|
||||
'★ 「我的」页的退出要调 performLogout()(与侧栏底部簇共用同一个实现)');
|
||||
|
||||
/* 第二半:那个函数真的清了**全部**账号,而不是只清当前 */
|
||||
const logoutSrc = code(LOGOUT_MOD);
|
||||
assert.match(logoutSrc, /clearAll\(\)/,
|
||||
'★ 退出要调 AccountManager.clearAll()(清全部账号)');
|
||||
assert.ok(!/clearActive\(\)/.test(src),
|
||||
assert.ok(!/clearActive\(\)/.test(logoutSrc),
|
||||
'不得只清"当前"账号 —— 库里的其余账号会让快速登录路径把退出撤销掉');
|
||||
|
||||
// 被调的那个方法真的存在,且真的清空
|
||||
|
||||
@ -30,17 +30,19 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { code, prose } from './lib/read.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
|
||||
const ETS = join(ROOT, 'client', 'harmony', 'entry', 'src', 'main', 'ets');
|
||||
|
||||
/** 去注释:判据要判**代码**,不是判我们自己在注释里写的那些话 */
|
||||
function code(p) {
|
||||
return readFileSync(p, 'utf8')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/^\s*\/\/.*$/gm, '');
|
||||
}
|
||||
/*
|
||||
* ★ 2026-09-19:本文件原先**自造**了一个 `code()`(自己写去注释正则)。
|
||||
* `criteria-hygiene` 判据拦的正是这个 —— 判据目录有一条"裸用 readFileSync"的
|
||||
* 禁令,理由不是洁癖:`code`/`prose` 的选择决定"判的是代码还是注释",
|
||||
* 各文件自造一份,就等于每个文件对同一件事有各自的说法。
|
||||
* 改用 `test/lib/read.mjs` 的共享入口。
|
||||
*/
|
||||
|
||||
|
||||
const MAIN = join(ETS, 'pages', 'MainPage.ets');
|
||||
const API = join(ETS, 'api', 'MailApi.ets');
|
||||
|
||||
@ -607,3 +607,67 @@ test('★ 判据自检:把状态机的默认页签改错必须判红', () => {
|
||||
const defaultMutated = prose(COMM_TS).replace("export const COMM_TAB_DEFAULT: string = 'inbox';", "export const COMM_TAB_DEFAULT: string = 'sent';");
|
||||
assert.ok(!/COMM_TAB_DEFAULT: string = 'inbox'/.test(defaultMutated), '自检:默认页签变异没生效');
|
||||
});
|
||||
|
||||
/* ─────────────────── 左右滑动翻页:行为(不是"代码里有") ─────────────────── */
|
||||
|
||||
test('★ 行为:滑动判定四道门各自真的在拦(跑 judgeSwipe,不看字符串)', async () => {
|
||||
/*
|
||||
* 上一条跨端判据钉的是"两端语义相同",这里钉的是"这道门真的会拦住"。
|
||||
* 两条都要有:只钉语义的话,一个 `return` 写漏了照样全绿。
|
||||
*/
|
||||
const CAL = await import(pathToFileURL(join(HARMONY_ETS, 'model/Calendar.ts')).href);
|
||||
const { judgeSwipe, SWIPE_MIN_DISTANCE, SWIPE_AXIS_RATIO, SWIPE_MAX_DURATION_MS } = CAL;
|
||||
|
||||
// ① 正常快滑:左滑 = 下一段
|
||||
const next = judgeSwipe(-200, 10, 120);
|
||||
assert.equal(next.turned, true, '横向位移足够大 + 够快 ⇒ 应判为翻页');
|
||||
assert.equal(next.delta, 1, '★ 左滑(dx<0)必须是 +1(下一段)');
|
||||
// 右滑 = 上一段
|
||||
const prev = judgeSwipe(200, 10, 120);
|
||||
assert.equal(prev.delta, -1, '★ 右滑(dx>0)必须是 -1(上一段)');
|
||||
|
||||
// ② 位移不够 —— 轻点/微抖不该翻页
|
||||
assert.equal(judgeSwipe(-(SWIPE_MIN_DISTANCE - 1), 0, 100).turned, false,
|
||||
'位移小于阈值 ⇒ 不翻页(否则点一下就会翻)');
|
||||
assert.equal(judgeSwipe(SWIPE_MIN_DISTANCE + 1, 0, 100).turned, true,
|
||||
'刚过阈值 ⇒ 应翻页(阈值不能写大到正常滑动都不触发)');
|
||||
|
||||
// ③ 纵向优先 —— 这是移动端最容易犯的错
|
||||
assert.equal(judgeSwipe(-100, 90, 100).turned, false,
|
||||
'横向只比纵向大一点 ⇒ 不翻页(用户在纵向滚动)');
|
||||
assert.equal(judgeSwipe(-100, 90 / SWIPE_AXIS_RATIO, 100).turned, true,
|
||||
'横向达到倍数要求 ⇒ 应翻页');
|
||||
/* 纯纵向必然不翻 */
|
||||
assert.equal(judgeSwipe(5, -300, 100).turned, false, '纯纵向滚动不得翻页');
|
||||
|
||||
// ④ 慢拖不翻页
|
||||
assert.equal(judgeSwipe(-300, 0, SWIPE_MAX_DURATION_MS + 1).turned, false,
|
||||
'超过时间窗 ⇒ 不翻页(用户在瞄准/阅读,不是在翻)');
|
||||
assert.equal(judgeSwipe(-300, 0, SWIPE_MAX_DURATION_MS).turned, true,
|
||||
'正好在时间窗内 ⇒ 应翻页');
|
||||
|
||||
// ⑤ 阈值必须是正数(0 会让所有手势都翻页)
|
||||
assert.ok(SWIPE_MIN_DISTANCE > 0 && SWIPE_AXIS_RATIO > 1 && SWIPE_MAX_DURATION_MS > 0,
|
||||
'三个阈值都要是正数;倍数还必须 > 1(=1 时斜滑就翻页)');
|
||||
|
||||
// ⑥ 不翻页时 delta 必须是 0(否则调用方会拿 0 去翻页)
|
||||
for (const v of [judgeSwipe(1, 0, 100), judgeSwipe(-100, 500, 100), judgeSwipe(-300, 0, 99999)]) {
|
||||
assert.equal(v.turned, false);
|
||||
assert.equal(v.delta, 0, '未翻页时 delta 应为 0(不是 -1/1)');
|
||||
}
|
||||
});
|
||||
|
||||
test('★ 行为:手势触发的翻页与按钮触发的翻页走同一个函数(步长不会分叉)', () => {
|
||||
/*
|
||||
* 这条是"结构"判据但断的是**行为等价性**:手势与 ‹ › 按钮都必须进 shiftRange。
|
||||
* 各写一套的话,周档"手势翻 1 天、按钮翻 7 天"这种分叉不会报错、只会静静地不对。
|
||||
*/
|
||||
const page = code(join(HARMONY_ETS, 'pages/CalendarPage.ets'));
|
||||
const gestureCalls = [...page.matchAll(/this\.shiftRange\(v\.delta\)/g)].length;
|
||||
assert.ok(gestureCalls >= 2,
|
||||
`★ 宽屏与窄屏两处手势都要调 shiftRange(实际 ${gestureCalls} 处)—— ` +
|
||||
'只挂一屏会让另一屏没有手势,而那是"看起来已经做了"的最隐蔽形式');
|
||||
/* 按钮那一支 */
|
||||
assert.match(page, /\.onClick\(\(\)\s*=>\s*this\.shiftMonth\(-1\)\)/, '上一页按钮');
|
||||
assert.match(page, /\.onClick\(\(\)\s*=>\s*this\.shiftMonth\(1\)\)/, '下一页按钮');
|
||||
});
|
||||
|
||||
@ -490,13 +490,29 @@ test('★ 窗格切换有真的过场动画(transition 挂在会换的那棵
|
||||
assert.match(mainCode, /\.translate\(\{[^}]*calPaneIn[^}]*\}\)/,
|
||||
'★ 日历容器要同时驱动位移(对齐 WebUI `rise-in` 的 translateY)');
|
||||
// reset 必须在 animateTo **外** —— 否则起点=终点,动画退化
|
||||
const resetIdx = mainCode.indexOf('this.calPaneIn = 0');
|
||||
const animIdx = mainCode.indexOf('getUIContext().animateTo(');
|
||||
assert.ok(resetIdx > 0, '★ 切到日历前要先把 `calPaneIn` 瞬回 0(否则看不到“从无到有”)');
|
||||
assert.ok(resetIdx < animIdx || animIdx < 0,
|
||||
'★ `calPaneIn = 0` 必须在 `animateTo` **之前**:写进回调里会与同帧的 1 相抵,' +
|
||||
'渲染层只看得见最终值 ⇒ 动画退化成一次瞬移(等于没修)');
|
||||
const intoAnim = /getUIContext\(\)\.animateTo\([\s\S]{0,300}?this\.calPaneIn = 1/.exec(mainCode);
|
||||
/*
|
||||
* ★★ 2026-09-19 修(加壁纸淡入时撞出来的):
|
||||
*
|
||||
* 原写法是「全文 indexOf('this.calPaneIn = 0') 与全文第一个 animateTo 比大小」。
|
||||
* 而 `MainPage.ets` 里 `animateTo` 不止一处 —— 新增壁纸淡入(`bgFadeIn`)
|
||||
* 之后,第一个 animateTo 出现在**日历那一大段之前** ⇒ `resetIdx < animIdx` 假红。
|
||||
*
|
||||
* 错法本身很典型:**拿全文下标去比两件相隔很远的事**。
|
||||
* 正确的锚法是:只看**日历那一处**的局部上下文 —— 从 `calPaneIn = 0`
|
||||
* 往后截一段,要求 `animateTo` 就出现在这段里且 `calPaneIn = 1` 在它之后。
|
||||
*/
|
||||
const calResetAt = mainCode.indexOf('this.calPaneIn = 0');
|
||||
assert.ok(calResetAt > 0, '★ 切到日历前要先把 `calPaneIn` 瞬回 0(否则看不到“从无到有”)');
|
||||
/* 截到 reset 之后的**同一段**(400 字符足够包住 animateTo + 赋值,又不会跨到别处) */
|
||||
const calLocal = mainCode.slice(calResetAt, calResetAt + 400);
|
||||
const localAnim = calLocal.indexOf('getUIContext().animateTo(');
|
||||
assert.ok(localAnim > 0,
|
||||
'★ `calPaneIn = 0` 之后必须紧跟 `animateTo`(同一段代码内)—— ' +
|
||||
'否则这个 reset 根本没被任何动画窗口包住,等于白 reset');
|
||||
assert.ok(!/calPaneIn = 1[\)\s]*;?\s*\}/.test(calLocal.slice(0, localAnim)),
|
||||
'★ `calPaneIn = 1` 不得出现在 animateTo **之前**;' +
|
||||
'reset 写进回调里会与同帧的 1 相抵,渲染层只看得见最终值 ⇒ 动画退化成一次瞬移(等于没修)');
|
||||
const intoAnim = /getUIContext\(\)\.animateTo\([\s\S]{0,300}?this\.calPaneIn = 1/.exec(calLocal);
|
||||
assert.ok(intoAnim,
|
||||
'★ 推到 1 要在 `animateTo` 窗口里(与其余窗格的 transition 同时长、同曲线)');
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
// ★★ 2026-09-19 重写本文件的头部说明(上面那一版是错的,与它判的东西矛盾):
|
||||
// 旧文写的是「60px 图标轨 / 通信·日历·联系人(中)/ 设置(底)/
|
||||
// 选中态只换颜色 + 品牌浅底,**不加背景块**」—— 那个描述是**编的**:
|
||||
// · `Sidebar.tsx:26-44` 是**三项**(通信/日历/**联系**)——「我的」不在导航轨里,
|
||||
// · `Sidebar.tsx` 第 26-44 行是**三项**(通信/日历/「**联系**」)——「我的」不在导航轨里,
|
||||
// 而是 **底部头像按钮**(`Sidebar.tsx:186`);
|
||||
// · `Sidebar.tsx:110` 有 `<span className="text-3xs">{short}</span>`(有文字);
|
||||
// · `index.css:1590` 的 `.nav-item[data-active='true']` **有底色块**,
|
||||
@ -13,9 +13,7 @@
|
||||
//
|
||||
// 鸿蒙侧原来**没有**宽屏布局(只有底部导航条),WideSidebar 补的就是"宽屏模式"这半:
|
||||
// ≥768vp 时 MainPage 挂侧栏、藏底部条(见 MainPage.build 的 onAreaChange)。
|
||||
|
||||
import { code, prose, stripComments } from './lib/read.mjs';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { test } from 'node:test';
|
||||
@ -28,8 +26,12 @@ const HARMONY_ETS = join(ROOT, 'client/harmony/entry/src/main/ets');
|
||||
const WEBUI_SRC = join(ROOT, 'client/electron/src');
|
||||
|
||||
const read = p => prose(join(HARMONY_ETS, p));
|
||||
/** 读 WebUI 的源文件(**不去注释**:注释里就有真实理由) */
|
||||
const readWeb = p => readFileSync(join(WEBUI_SRC, p), 'utf8');
|
||||
/*
|
||||
* 读 WebUI 的源文件(**不去注释**:注释里就有真实理由)。
|
||||
* ★ 用共享入口 `prose` 而不是裸 `readFileSync`:判据目录有一条 hygiene 判据
|
||||
* 专门拦裸用(理由是那些用法会把"读哪个文件、剥不剥注释"变成每个文件各说各话)。
|
||||
*/
|
||||
const readWeb = p => prose(join(WEBUI_SRC, p));
|
||||
|
||||
/** 取某个成员/方法正文(按行切到下一个成员声明) */
|
||||
function memberBody(src, signature) {
|
||||
@ -121,10 +123,31 @@ test('② 侧栏**三项**(通信/日历/联系)+ 底部一簇 —— 与底
|
||||
/* 头像右下角要带 SSE 连接状态点(WebUI 的 `<ConnectionIndicator/>`) */
|
||||
assert.match(sidebar, /sseColorOf\(/, '头像要带连接状态点(颜色由 SSE 状态决定)');
|
||||
const webInd = readWeb('components/ConnectionIndicator.tsx');
|
||||
for (const [status, hex] of [['connected', '#22C55E'], ['connecting', '#FACC15'], ['reconnecting', '#FB923C'], ['disconnected', '#F87171']]) {
|
||||
/*
|
||||
* ★★ 2026-09-19 修:色值已从 `WideSidebar.ets` 抽到 `Theme.ets` 的令牌
|
||||
* (`Theme.sseConnected` 等)—— 这是 `cross-client-theme` 那条
|
||||
* 「鸿蒙页面里不得出现任何裸色值」要求的方向。
|
||||
*
|
||||
* 所以本断言相应改成:这里要有**四个令牌名**与 WebUI 四档的**逐档对应**。
|
||||
* 分两半断(缺一不可):
|
||||
* ① 令牌在 `Theme.ets` 里定义,且取值就是 WebUI 对应的那个色(# 值真的一样);
|
||||
* ② 侧栏真的用了这些令牌(不是定义了不接)。
|
||||
* 只断①会得到“有令牌但没人用”的假绿(`navMaterial` 就当过孤儿);
|
||||
* 只断②会得到“接了但颜色抄错”的假绿。
|
||||
*/
|
||||
const themeSrc = read('common/Theme.ets');
|
||||
for (const [status, hex, token] of [
|
||||
['connected', '#22C55E', 'sseConnected'],
|
||||
['connecting', '#FACC15', 'sseConnecting'],
|
||||
['reconnecting', '#FB923C', 'sseReconnecting'],
|
||||
['disconnected', '#F87171', 'sseDisconnected']
|
||||
]) {
|
||||
/* WebUI 用的是 tailwind 类名,这里判的是"四档都有"这个结构,不是具体色值 */
|
||||
assert.ok(new RegExp(`${status}:`).test(webInd), `WebUI 的连接指示器要有 ${status} 档`);
|
||||
assert.ok(sidebar.includes(hex), `鸿蒙的连接点要有 ${status} 对应的色值 ${hex}`);
|
||||
assert.ok(new RegExp(`static readonly ${token}: string = '${hex}'`).test(themeSrc),
|
||||
`① Theme.ets 里要有 ${token} = ${hex}(与 WebUI 的 ${status} 档同值)`);
|
||||
assert.ok(sidebar.includes(`Theme.${token}`),
|
||||
`② 侧栏要真的用 Theme.${token} —— 定义了不接 = 那档颜色永远画不出来`);
|
||||
}
|
||||
/* 「我的」由**头像**进入(不是导航轨里的一项) */
|
||||
assert.match(sidebar, /this\.onSelect\(ME_PANE_INDEX\)/,
|
||||
@ -150,13 +173,12 @@ test('③ 侧栏选中态 = **浅蓝底块** + 变色(与底栏"只变色"是
|
||||
const code_ = stripComments(sidebar);
|
||||
|
||||
// 从 WebUI 的真实源码里取侧栏选中底色(不写死,改了两边一起动)
|
||||
const css = readFileSync(
|
||||
join(ROOT, 'client', 'electron', 'src', 'index.css'), 'utf8');
|
||||
const css = prose(join(ROOT, 'client', 'electron', 'src', 'index.css'));
|
||||
const m = css.match(/--nav-active-bg:\s*(\d+ \d+ \d+)/);
|
||||
assert.ok(m, 'WebUI index.css 要有 --nav-active-bg(侧栏选中底色)');
|
||||
const [r, g, b] = m[1].split(' ').map(Number);
|
||||
const hex = '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join('').toUpperCase();
|
||||
const themeSrc = readFileSync(join(HARMONY_ETS, 'common', 'Theme.ets'), 'utf8');
|
||||
const themeSrc = prose(join(HARMONY_ETS, 'common', 'Theme.ets'));
|
||||
assert.match(themeSrc, new RegExp(`navActiveBg: string = '${hex}'`, 'i'),
|
||||
`Theme.navActiveBg 要等于 WebUI 的 --nav-active-bg(${hex})`);
|
||||
|
||||
@ -357,7 +379,7 @@ test('⑦ 导航项徽标:取值/色调与 WebUI Sidebar 同口径(纯逻辑
|
||||
assert.ok(chromeM, 'WebUI index.css 要有 --c-chrome-600(中性徽标底色)');
|
||||
const [cr, cg, cb] = chromeM[1].split(' ').map(Number);
|
||||
const plainHex = '#' + [cr, cg, cb].map(v => v.toString(16).padStart(2, '0').toUpperCase()).join('');
|
||||
const themeSrc = readFileSync(join(HARMONY_ETS, 'common', 'Theme.ets'), 'utf8');
|
||||
const themeSrc = prose(join(HARMONY_ETS, 'common', 'Theme.ets'));
|
||||
assert.match(themeSrc, new RegExp(`badgePlain: string = '${plainHex}'`, 'i'),
|
||||
`Theme.badgePlain 要等于 WebUI 的 --c-chrome-600(${plainHex})`);
|
||||
|
||||
|
||||
@ -61,18 +61,24 @@ for (const must of ['配对/解析', 'allow-list', '变异验证', '剥掉注释
|
||||
|
||||
const SUITE = [
|
||||
['test/markdown-xss.test.mjs', [], 9],
|
||||
['test/narrow-layout.test.mjs', [], 64],
|
||||
['test/nav-merge.test.mjs', [], 8],
|
||||
['test/narrow-layout.test.mjs', [], 88],
|
||||
['test/nav-merge.test.mjs', [], 9],
|
||||
['test/animation-audit.test.mjs', [], 4], // 动画全量盘点:死动画/过宽作用域/弹层接线/reduced-motion
|
||||
// 深色模式:色板反转 + 玻璃 alpha 档 + 底必须是暗的(2026-09-17 那次
|
||||
// 「只有通信页深色正常」的回归锁 —— 38 条里后 8 条是这次新增)。
|
||||
['test/theme.test.mjs', [], 38],
|
||||
['test/background.test.mjs', [], 43],
|
||||
['test/background.test.mjs', [], 44],
|
||||
['test/cross-client-theme.test.mjs', [], 15],
|
||||
// 左右滑动翻页的**语义契约**(两端逐项相同、数值各自定)—— 这是
|
||||
// `docs/DEBTS.json` 的 `gesture-semantics` 那条债:它写着「鸿蒙侧出现滑动
|
||||
// 手势代码时**立即建**(此前建 = 只有一端存在的假判据)」。
|
||||
// 2026-09-19 鸿蒙侧加了手势 ⇒ 现在建。8 条:两端都真有手势 / 方向映射 /
|
||||
// 纵向优先 / 快滑窗口 / 复用同一翻页函数 / 无边界回弹 / 有意差异被记录 / 自检。
|
||||
['test/cross-client-gesture.test.mjs', [], 8],
|
||||
// 预设的**行为**判据:每一档都真的画得出来(能真跑,不需要设备 ⇒ 不进 static 欠账)。
|
||||
// 与 appearance-defaults 那条「清单 id/顺序相等」配对:值判据管清单,行为判据管渲染器。
|
||||
['test/harmony-presets.test.mjs', ['--experimental-strip-types', '--no-warnings'], 5],
|
||||
['test/harmony-logic.test.mjs', ['--experimental-strip-types', '--no-warnings'], 28],
|
||||
['test/harmony-presets.test.mjs', ['--experimental-strip-types', '--no-warnings'], 6],
|
||||
['test/harmony-logic.test.mjs', ['--experimental-strip-types', '--no-warnings'], 30],
|
||||
['test/harmony-system-api.test.mjs', [], 5],
|
||||
// P4 外观同步:跑 model/Appearance.ts(纯逻辑),所以也要 strip-types
|
||||
['test/harmony-appearance.test.mjs', ['--experimental-strip-types', '--no-warnings'], 25],
|
||||
@ -405,7 +411,7 @@ for (const [file, flags, expected] of SUITE) {
|
||||
* 自己就写过这条、但**没落进代码**):
|
||||
*
|
||||
* node:test 会打三行 —— `# tests N` / `# pass N` / `# fail N`,而 `# tests = # pass + # fail`。
|
||||
* 原来取的是 `/^# pass (\d+)/` ⇒ `checks` = Σ`# pass` = **399**,
|
||||
* 原来取的是 `/^# pass (\d+)/`(node v24 起前缀变成 `ℹ`,现已两种都认) ⇒ `checks` = Σ`# pass` = **399**,
|
||||
* 比真实总条数(400)**少了失败的那几条**;接着 `pass = totalTests - totalFail`
|
||||
* = 399 − 1 = **398**,**在一个从未包含失败的和上又减了一次失败**(真实通过数是 399)。
|
||||
* 实测(唯一有失败的文件 `build-stamp`:`# tests 7 / # pass 6 / # fail 1`):
|
||||
@ -418,8 +424,8 @@ for (const [file, flags, expected] of SUITE) {
|
||||
* 上一轮那条"虚高时恒等式照样成立"是同一个病 —— **恒等式两边同源就没有检验力**。
|
||||
*/
|
||||
const marker = /RESULT pass=(\d+) fail=(\d+)/.exec(out); // 自定义 check():pass/fail 自报
|
||||
const nodeTests = /^# tests (\d+)/m.exec(out); // node:test:总条数
|
||||
const nodeFail = /^# fail (\d+)/m.exec(out);
|
||||
const nodeTests = /^[#ℹ] tests (\d+)/m.exec(out); // node:test:总条数
|
||||
const nodeFail = /^[#ℹ] fail (\d+)/m.exec(out);
|
||||
/*
|
||||
* ★ `# skipped K`(node:test):**没跑**的条数,必须单独取一格(dsh 2026-09-18)。
|
||||
*
|
||||
@ -434,7 +440,7 @@ for (const [file, flags, expected] of SUITE) {
|
||||
* 读不出来的跳过等于没有跳过(同 CRITERIA.md 里"跳过若不可数就等于不显形")。
|
||||
* `check()` 风格的文件没有跳过机制 ⇒ 恒 0。
|
||||
*/
|
||||
const nodeSkip = /^# skipped (\d+)/m.exec(out);
|
||||
const nodeSkip = /^[#ℹ] skipped (\d+)/m.exec(out);
|
||||
/*
|
||||
* ★★ 跳过的**具名**与**原因**(pi 2026-09-18 的闸 (i) 前半)。
|
||||
*
|
||||
@ -455,7 +461,7 @@ for (const [file, flags, expected] of SUITE) {
|
||||
* 老版本 node 或输出被截断时,和才等于总条数;单取 `# pass` 正是上面那个 bug。
|
||||
* 两者都没有 ⇒ null("没自报条数",走 unreported 那条红)。
|
||||
*/
|
||||
const nodePass = /^# pass (\d+)/m.exec(out);
|
||||
const nodePass = /^[#ℹ] pass (\d+)/m.exec(out);
|
||||
const nodeTotal = nodeTests ? Number(nodeTests[1])
|
||||
: (nodePass ? Number(nodePass[1]) + (nodeFail ? Number(nodeFail[1]) : 0)
|
||||
+ (nodeSkip ? Number(nodeSkip[1]) : 0) : null);
|
||||
|
||||
@ -131,7 +131,7 @@ export class Theme {
|
||||
* 第 12 个**新加的手写色**(它不在名单里,于是 A/B/裸色值三条都碰不到它)。
|
||||
* 「枚举挡实例,类才挡漂移」—— 这次枚举的是**名字**,所以要有名单。
|
||||
*
|
||||
* 登记项(17 个,名字与判据里的 SELF_OWNED_COLORS 逐字一致 —— 逐个列出而不是缩写,
|
||||
* 登记项(24 个,名字与判据里的 SELF_OWNED_COLORS 逐字一致 —— 逐个列出而不是缩写,
|
||||
* 这样 grep 一个名字就能找到它的理由):
|
||||
*
|
||||
* 品牌(跨客户端身份,系统给不了):
|
||||
@ -157,6 +157,24 @@ export class Theme {
|
||||
* · chipWarnBg #FFEDD5 预算紧张底
|
||||
* · chipWarnFg #C2410C 预算紧张文字
|
||||
*
|
||||
* 导航选中/未选中的**跨端身份色**(2026-09-19 补,对应用户
|
||||
* 「底栏数字为什么显示在图标下面?」与「宽屏侧栏根本不像 WebUI」那两轮):
|
||||
* · navActiveBg #DBEAFE WebUI `--nav-active-bg`(`index.css:1595-1606`)
|
||||
* = 侧栏选中项的浅蓝底块;宽屏侧栏的选中线索就是它
|
||||
* · navBrandFg #475569 WebUI `--nav-fg-muted` = 侧栏未选中项图标色
|
||||
* · badgePlain #475569 WebUI `bg-chrome-600`(`index.css:92`)=
|
||||
* 'plain' 档徽标底色(联系人数)。★ 必须石板灰而非红:
|
||||
* 三档被压成两档时 `'plain'` 会走 danger,看着像"有未读"
|
||||
*
|
||||
* SSE 连接指示器的四个状态色(逐档对齐 WebUI 的 Tailwind 类,
|
||||
* `ConnectionIndicator.tsx:22-25`):
|
||||
* · sseConnected #22C55E bg-green-500 已连接
|
||||
* · sseConnecting #FACC15 bg-yellow-400 正在连接
|
||||
* · sseReconnecting #FB923C bg-orange-400 重连中
|
||||
* · sseDisconnected #F87171 bg-red-400 已断开
|
||||
* ★ 不复用 warnFg/danger:那两个是**文字色**,而状态点是 8px 实心圆,
|
||||
* 用文字色会发脏、深色底上不够跳(WebUI 也是 text-* 与 bg-* 两套)。
|
||||
*
|
||||
* 不在这里的手写色只有一种合法去处:`$r('sys.*')`(跟随系统/深色模式)。
|
||||
*/
|
||||
|
||||
@ -221,6 +239,30 @@ export class Theme {
|
||||
*/
|
||||
static readonly badgePlain: string = '#475569';
|
||||
|
||||
/*
|
||||
* 状态点的四个色(SSE 连接指示器)—— **逐档对齐 WebUI** 的 Tailwind 色值。
|
||||
*
|
||||
* 来源:`client/electron/src/components/ConnectionIndicator.tsx:22-25`
|
||||
* connecting `bg-yellow-400` = #FACC15
|
||||
* connected `bg-green-500` = #22C55E
|
||||
* reconnecting `bg-orange-400` = #FB923C
|
||||
* disconnected `bg-red-400` = #F87171
|
||||
*
|
||||
* ★ 为什么不复用现有的 `warnFg`/`danger`:
|
||||
* 那两个是**文字色**(`warnFg` #B45309 是深橙,给正文用的),
|
||||
* 而状态点是 8px 的实心圆 —— 用文字色会显得发脏、且在深色底上不够跳。
|
||||
* WebUI 也是分开的两套(文字走 `text-*`,这个点走 `bg-*`)。
|
||||
*
|
||||
* ★ 为什么必须进 Theme 而不是就地写字面量(`WideSidebar.ets` 原先就是就地写):
|
||||
* 判据 `cross-client-theme` 有一条「鸿蒙页面里不得出现任何裸色值」,
|
||||
* 理由写在 `read.mjs` 的头顶:「解释性注释与它解释的标识符同名」那类误报,
|
||||
* 以及更重要的 —— 裸色值散在页面里,改配色时**没有任何一处会提醒你漏改了**。
|
||||
*/
|
||||
static readonly sseConnecting: string = '#FACC15';
|
||||
static readonly sseConnected: string = '#22C55E';
|
||||
static readonly sseReconnecting: string = '#FB923C';
|
||||
static readonly sseDisconnected: string = '#F87171';
|
||||
|
||||
// ─────────────── 业务语义色:系统没有对应物,继续自己写 ───────────────
|
||||
|
||||
/** 语义色:同意 / 拒绝(对应 WebUI 的 approve/danger) */
|
||||
|
||||
@ -320,3 +320,92 @@ export function stepDaysOf(scale: CalendarScale): number {
|
||||
}
|
||||
return scale === 'day' ? 1 : 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* 左右滑动翻页(P6 第 3 步)—— 纯判定逻辑,放在这里以便单测
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* 用户 2026-09-14:「日历页面还不支持左右滑动手势」。
|
||||
* 延期到 2026-09-19 才做,延期本身是**有意的**:`docs/DEBTS.json` 的
|
||||
* `gesture-semantics` 写着「鸿蒙侧出现滑动手势代码时**立即建**判据
|
||||
* (此前建 = 只有一端存在的假判据)」—— 也就是先有手势、再钉语义,
|
||||
* 反过来会写出一条永远为真的判据。
|
||||
*
|
||||
* ★ 阈值按 (b) 口径:**数值各自定,语义必须一致**(`docs/HARMONY-ALIGN-PLAN.md:118-128`)。
|
||||
* 理由(那里的原话):40px / 600ms 这类人体工学量在触摸屏与鼠标上、
|
||||
* 手机与大屏上天然不同,强行同值会得到两边都不舒服的数。
|
||||
* 所以下面**不引用** WebUI 的 `40` / `1.5` / `600`,但**语义逐项对齐**:
|
||||
*
|
||||
* | 语义 | WebUI(`CalendarView.tsx:204-209`) | 鸿蒙(这里) |
|
||||
* |----------------|--------------------------------------------|---------------------|
|
||||
* | 左滑 | 下一段(`shift(1)`) | 同(`+1`) |
|
||||
* | 右滑 | 上一段(`shift(-1)`) | 同(`-1`) |
|
||||
* | 纵向优先 | 横向位移须 ≥ 纵向的 1.5 倍,否则不翻页 | 同语义,倍数另设 |
|
||||
* | 慢拖 | 超过 600ms 不算「快滑」,不翻页 | 同语义,毫秒另设 |
|
||||
* | 边界 | 无边界(`addMonths` 可任意前后)⇒ 不回弹 | 同(见 `addMonths`)|
|
||||
* | 翻页动作 | 复用 `shift()`(与按钮同一套) | 复用 `shiftRange()` |
|
||||
*
|
||||
* ★ 有意**未对齐**的一条(记录下来,免得下次被当成漏做):
|
||||
* WebUI 在**周/日档**会额外检查「触点是否落在可横向滚动的区域里」,
|
||||
* 是则把手势让给滚动条。鸿蒙侧**没有这个冲突** —— 周档是「一行 7 格、
|
||||
* 按 `layoutWeight` 等分」,不横滚(见 `CalendarPage.rows()`)。
|
||||
* 语义表里这一条因此**不适用**,不是"忘了写"。
|
||||
* ⇒ 哪天真给周档加了横滚,必须同时把这条卡严条件补上,
|
||||
* 否则同一次横滑会既想滚又想翻(WebUI 用户 2026-09-14 报过这个体感)。
|
||||
*/
|
||||
|
||||
/** 一次滑动被判定为「翻页」的结果;`delta` 直接喂给 `shiftRange` */
|
||||
export class SwipeVerdict {
|
||||
/** 是否算翻页 */
|
||||
turned: boolean = false;
|
||||
/** 翻页方向:+1 = 下一段(左滑),-1 = 上一段(右滑),0 = 不翻 */
|
||||
delta: number = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手势阈值。**数值是鸿蒙侧自己定的**((b) 口径),不是照抄 WebUI。
|
||||
*
|
||||
* 取值依据:触摸屏(而非鼠标)的横向意图需要比指针更明确的位移才成立,
|
||||
* 否则"点一下时手指轻微偏移"会被误判成翻页;同时"快滑"的窗口在触摸屏上
|
||||
* 比 WebUI(鼠标为主)稍宽松,因为触摸的起止事件本身有抖动延迟。
|
||||
*/
|
||||
export const SWIPE_MIN_DISTANCE: number = 56;
|
||||
/** 横向位移至少要达到纵向的这么多倍,才认为用户意图是"横"而不是"竖" */
|
||||
export const SWIPE_AXIS_RATIO: number = 1.4;
|
||||
/** 「快滑」的时间窗(毫秒);超过就算"慢慢拖",不翻页 */
|
||||
export const SWIPE_MAX_DURATION_MS: number = 700;
|
||||
|
||||
/**
|
||||
* 判定一次滑动 —— 纯函数,无 UI 依赖(判据直接跑它)。
|
||||
*
|
||||
* @param dx 横向位移(px/vp,用同一单位即可;符号跟屏幕坐标系:右为正)
|
||||
* @param dy 纵向位移(同上)
|
||||
* @param ms 手势持续毫秒
|
||||
*
|
||||
* 四道门,全部通过才翻页:
|
||||
* ① 横向位移足够大(`SWIPE_MIN_DISTANCE`)—— 防"轻点/微抖"被当成滑动;
|
||||
* ② 横向明显大于纵向(`SWIPE_AXIS_RATIO`)—— 这是移动端最容易犯的手势错误:
|
||||
* 用户在纵向列表上滚动,手指多少会带一点横向偏移,不卡这条就会"滚着滚着翻页了";
|
||||
* ③ 够快(`SWIPE_MAX_DURATION_MS`)—— 慢慢拖通常意味着用户在瞄准或阅读,
|
||||
* 不是在翻页(WebUI 同语义);
|
||||
* ④ 方向:左滑(dx < 0)= 下一段,右滑(dx > 0)= 上一段。
|
||||
*
|
||||
* 方向与翻页按钮同源:`delta` 直接交给 `shiftRange`,
|
||||
* 不在这里自己算日期(那会出现"手势翻 1 天、按钮翻 7 天"的分叉)。
|
||||
*/
|
||||
export function judgeSwipe(dx: number, dy: number, ms: number): SwipeVerdict {
|
||||
const v: SwipeVerdict = new SwipeVerdict();
|
||||
if (Math.abs(dx) < SWIPE_MIN_DISTANCE) {
|
||||
return v;
|
||||
}
|
||||
if (Math.abs(dx) < Math.abs(dy) * SWIPE_AXIS_RATIO) {
|
||||
return v;
|
||||
}
|
||||
if (ms > SWIPE_MAX_DURATION_MS) {
|
||||
return v;
|
||||
}
|
||||
v.turned = true;
|
||||
v.delta = dx < 0 ? 1 : -1;
|
||||
return v;
|
||||
}
|
||||
|
||||
@ -50,6 +50,8 @@ import {
|
||||
scaleLabel,
|
||||
SCALE_ORDER,
|
||||
CalendarScale,
|
||||
judgeSwipe,
|
||||
SwipeVerdict,
|
||||
weekdayLabels,
|
||||
weekdayNameOf,
|
||||
isoOfLocal,
|
||||
@ -589,6 +591,16 @@ export struct CalendarPage {
|
||||
*/
|
||||
@State slideForward: boolean = true;
|
||||
|
||||
/**
|
||||
* 一次滑动手势的起始时刻(毫秒)。
|
||||
*
|
||||
* 为何要自己记:`GestureEvent` **没有时间戳字段**(我查了 SDK 的
|
||||
* `gesture.d.ts` —— 它有 `offsetX/offsetY`、`velocityX/velocityY`、
|
||||
* `repeat`、`fingerList`,唯独没有时间),而"快滑才翻页"这条语义需要时长。
|
||||
* 所以用 `onActionStart` 记一个 `Date.now()`,`onActionEnd` 相减。
|
||||
*/
|
||||
private swipeStartMs: number = 0;
|
||||
|
||||
/**
|
||||
* 换一个"格子内容"的身份 —— `ForEach` 的键挂上它,翻页时才会重放入场过渡。
|
||||
*
|
||||
@ -1335,6 +1347,28 @@ export struct CalendarPage {
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.height('100%')
|
||||
/*
|
||||
* ★★ 2026-09-19 加左右滑动翻页(P6 第 3 步,用户:「滑动手势呢?」)。
|
||||
*
|
||||
* 挂在**网格列**上而不是整页:右栏(日程/编辑器)里也有可滚内容与
|
||||
* 输入框,整页挂会让"在表单里横向划一下"变成翻月。
|
||||
* 语义与方向复用 `shiftRange`(与 ‹ › 按钮**同一个来源**)——
|
||||
* WebUI 的注释专门交代过:手势与按钮各写一套的话,
|
||||
* 阈值、边界、三档行为迟早分叉。
|
||||
*/
|
||||
.gesture(
|
||||
PanGesture({ direction: PanDirection.Horizontal })
|
||||
.onActionStart(() => {
|
||||
this.swipeStartMs = Date.now();
|
||||
})
|
||||
.onActionEnd((e: GestureEvent) => {
|
||||
const v: SwipeVerdict = judgeSwipe(
|
||||
e.offsetX as number, e.offsetY as number, Date.now() - this.swipeStartMs);
|
||||
if (v.turned) {
|
||||
this.shiftRange(v.delta);
|
||||
}
|
||||
})
|
||||
)
|
||||
/* 右栏有左边框(WebUI `border-l border-gray-200`),两栏才有分界 */
|
||||
.border({ width: { right: 1 }, color: Theme.border })
|
||||
|
||||
@ -1365,7 +1399,24 @@ export struct CalendarPage {
|
||||
if (this.editorOpen) {
|
||||
this.Editor()
|
||||
} else {
|
||||
this.GridPane()
|
||||
/* 窄屏同样支持左右滑翻页(网格这一块挂手势,下方面程不受影响) */
|
||||
Column() {
|
||||
this.GridPane()
|
||||
}
|
||||
.width('100%')
|
||||
.gesture(
|
||||
PanGesture({ direction: PanDirection.Horizontal })
|
||||
.onActionStart(() => {
|
||||
this.swipeStartMs = Date.now();
|
||||
})
|
||||
.onActionEnd((e: GestureEvent) => {
|
||||
const v: SwipeVerdict = judgeSwipe(
|
||||
e.offsetX as number, e.offsetY as number, Date.now() - this.swipeStartMs);
|
||||
if (v.turned) {
|
||||
this.shiftRange(v.delta);
|
||||
}
|
||||
})
|
||||
)
|
||||
this.AgendaPane()
|
||||
}
|
||||
}
|
||||
|
||||
@ -2236,6 +2236,20 @@ struct MainPage {
|
||||
*/
|
||||
private lastColorMode: ConfigurationConstant.ColorMode | undefined = undefined;
|
||||
@State wallpaperImage: image.PixelMap | null = null;
|
||||
|
||||
/**
|
||||
* 壁纸淡入进度(0 → 1)。
|
||||
*
|
||||
* 对齐 WebUI `index.css:742-752` 的 `.app-backdrop`:
|
||||
* `opacity: 0;` → `html[data-bg='on'] .app-backdrop { opacity: 1 }`
|
||||
* 过渡时长用 `--dur-base: 180ms` + `--ease-out-soft`。
|
||||
*
|
||||
* ★ 这一条是**补的欠账**:`Theme.durBase` 早就声明了(注释写着"壁纸淡入"),
|
||||
* 但壁纸层从来没有真的读过它 —— 令牌成了孤儿,`cross-client-theme`
|
||||
* 那条"死令牌"判据把它揪了出来。查 WebUI 才发现淡入是**真有的动效**,
|
||||
* 于是补上而不是把令牌删掉(删掉等于把差异抹平、还说成"清理")。
|
||||
*/
|
||||
@State bgFadeIn: number = 0;
|
||||
private gridSettings: RenderingContextSettings = new RenderingContextSettings(true);
|
||||
private gridCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.gridSettings);
|
||||
|
||||
@ -2402,6 +2416,29 @@ struct MainPage {
|
||||
// 模糊强度是**服务端给的 px 原值**:计划只搬运它,映射成系统材质档在画的那一层做
|
||||
this.bgPlan = resolveBackground(snap.bgKind, snap.bgPresetId, scrimOpacity(snap.bgDim), store.wallpaper !== null, dark, snap.bgBlur);
|
||||
this.bgActive = this.bgPlan.kind !== 'none';
|
||||
/*
|
||||
* 壁纸淡入(对齐 WebUI `index.css:742-752`:`.app-backdrop` 从 `opacity:0`
|
||||
* 过渡到 `1`,时长 `--dur-base: 180ms`、曲线 `--ease-out-soft`)。
|
||||
*
|
||||
* ★ 顺序与"常驻窗格入场"同一条纪律(见 `calPaneIn` 那段):
|
||||
* reset 到 0 **必须**在 `animateTo` **外面**先做 —— 写进回调里会与同帧的
|
||||
* 1 相抵,渲染层只看得见最终值 ⇒ 动画退化成一次瞬移(等于没做)。
|
||||
*
|
||||
* ★ 只在**真的换成了有色/图片壁纸**时播:`none` 时没有可淡入的层,
|
||||
* 无脑播会是"看不见的空转",也会让"开了壁纸"这件事得不到视觉反馈。
|
||||
*/
|
||||
if (this.bgActive) {
|
||||
this.bgFadeIn = 0;
|
||||
this.getUIContext().animateTo({
|
||||
duration: Theme.durBase,
|
||||
curve: Theme.easeOutSoft
|
||||
}, () => {
|
||||
this.bgFadeIn = 1;
|
||||
});
|
||||
} else {
|
||||
/* 关掉壁纸:直接归零,不需要动画(淡出会让"关壁纸"这个动作显得迟钝) */
|
||||
this.bgFadeIn = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** 一层渐变的色标:`['色', 位置]` 成对(页面才拼,纯逻辑里只存两个数组) */
|
||||
@ -2479,6 +2516,7 @@ struct MainPage {
|
||||
.opacity(this.bgPlan.scrim)
|
||||
}
|
||||
.width('100%').height('100%')
|
||||
.opacity(this.bgFadeIn)
|
||||
} else if (this.bgPlan.kind === 'image' && this.wallpaperImage !== null) {
|
||||
Stack() {
|
||||
Image(this.wallpaperImage)
|
||||
@ -2514,6 +2552,7 @@ struct MainPage {
|
||||
.opacity(this.bgPlan.scrim)
|
||||
}
|
||||
.width('100%').height('100%')
|
||||
.opacity(this.bgFadeIn)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -116,21 +116,21 @@ export struct WideSidebar {
|
||||
* 状态 → 颜色。与 WebUI `ConnectionIndicator.tsx:26-31` 逐档对齐:
|
||||
* connecting 黄 / connected 绿 / reconnecting 橙 / disconnected 红。
|
||||
*
|
||||
* ★ 取近似色而不是新建一组常量:这四个是"状态点"专用的语义色,
|
||||
* 与正文/边框不是一族;Theme 里没有它们的位置就就地取,
|
||||
* 而不是把 `Theme.warnFg`(#B45309 深橙)拿来当"连接中"——那是文字色。
|
||||
* ★ 色值走 Theme 令牌(`Theme.sseConnected` 等),不在这里就地写字面量:
|
||||
* 裸色值散在页面里,改配色时没有任何一处会提醒你漏改了 ——
|
||||
* `cross-client-theme` 有一条判据专门拦这个(我第一版就是就地写的,被判红)。
|
||||
*/
|
||||
private sseColorOf(status: string): string {
|
||||
if (status === 'connected') {
|
||||
return '#22C55E';
|
||||
return Theme.sseConnected;
|
||||
}
|
||||
if (status === 'connecting') {
|
||||
return '#FACC15';
|
||||
return Theme.sseConnecting;
|
||||
}
|
||||
if (status === 'reconnecting') {
|
||||
return '#FB923C';
|
||||
return Theme.sseReconnecting;
|
||||
}
|
||||
return '#F87171';
|
||||
return Theme.sseDisconnected;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -10,10 +10,10 @@
|
||||
{
|
||||
"id": "calendar-view",
|
||||
"path": "client/electron/src/components/CalendarView.tsx",
|
||||
"blob": "31a62fffd75c076ca86dc065ec7d6eec47fce985",
|
||||
"atCommit": "bb8201f",
|
||||
"atCommitSubject": "feat(webui): 回复/转发动画真的会播了 + 写邮件改成「按钮长成整页」",
|
||||
"note": "P6 日历骨架的对齐目标。**2026-09-15 第二次重新核对**:本次差异只有根节点的动画类 rise-in → pane-rise(拆「挂载即播」与「窗口触发」两档时改的名,见 436de6e)——骨架结构、布局类、圆角来源全都未变;鸿蒙侧日历本来就没有动效实现(grep 无 animate/transition),所以仍不产生新的对齐义务。此前两笔:日历圆角修复、根节点加 rise-in。"
|
||||
"blob": "89aa237be83a074d8871d13cea17d3a02a7dce72",
|
||||
"atCommit": "7b8c78f",
|
||||
"atCommitSubject": "维护: 底本重算(4 个文件漂移 = **底本过期**,不是变异残留)—— 逐个核实后按本文件的协议记一行\"为什么\"",
|
||||
"note": "P6 日历骨架的对齐目标。**2026-09-19 第三次重新核对**:本次差异只有一处 —— 非本月日期的农历小字从 gray-300 改到 gray-400/500(gray-300 是分隔线档,当文字用只有 ~1.47:1)。**骨架未变**:仍是 gridPane(flex-1) + 400px 右栏两栏结构、工具条分组与顺序、手势与按钮共用 shift()。鸿蒙侧不受影响 —— 它的次要文字走 Theme.textMuted 这类语义令牌,不引用灰阶档位。此前两笔:动画类 rise-in → pane-rise(拆两档时改名)、日历圆角修复。"
|
||||
}
|
||||
],
|
||||
"radius": [
|
||||
@ -44,4 +44,4 @@
|
||||
"appId": "6917616450599975320",
|
||||
"note": "AGC 下发的配置(加密信封格式,非明文密钥)。**包名必须与 AppScope/app.json5 的 bundleName 完全一致** —— 不一致时 Push Kit 推不到设备,这不是风格问题而是功能性约束(pi 邮件 `1f9ff3b4` 实测:AGC 拒绝 `com.agentmail.harmony`,因为 `harmony` 是包名保留字;用户定 `com.jianf.agentmail`)。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,8 @@
|
||||
"没有一处能看全 —— 而『欠账不显形,就等于没有』;分散在多处的登记,审计时只会被找到一处就当全部。",
|
||||
"两端都读这个文件:Go 侧判据断言自己的条目与**实测**一致(不许留一份手写的数字),",
|
||||
"electron 套件把它打进 RESULT 行(那是常态可见的位置)。",
|
||||
"已结算(2026-09-14):calendar-today-recompute —— P6 第 1 步(日历页 pages/CalendarPage.ets)落地,today 走 `@Prop @Watch('onVisibleChanged') visible` 在 pane 变可见时重算(另有 aboutToAppear 覆盖重新挂载),判据 test/harmony-calendar.test.mjs 的「★ today 在 pane **变可见时**重算」。结算即从此清单移除,余额里不再计这一笔。"
|
||||
"已结算(2026-09-14):calendar-today-recompute —— P6 第 1 步(日历页 pages/CalendarPage.ets)落地,today 走 `@Prop @Watch('onVisibleChanged') visible` 在 pane 变可见时重算(另有 aboutToAppear 覆盖重新挂载),判据 test/harmony-calendar.test.mjs 的「★ today 在 pane **变可见时**重算」。结算即从此清单移除,余额里不再计这一笔。",
|
||||
"已结算(2026-09-19):gesture-semantics —— P6 第 3 步(左右滑动翻页)落地:鸿蒙侧 `CalendarPage.ets` 加了 `PanGesture`、判定逻辑在纯逻辑层 `model/Calendar.ts` 的 `judgeSwipe`。按该条自己的口径(「鸿蒙侧出现滑动手势代码时**立即建**」)同步建了语义契约判据 `test/cross-client-gesture.test.mjs`(8 条)—— 按 (b) 口径钉**语义**不钉数值:左滑=下一段/右滑=上一段/纵向优先/快滑窗口/手势与按钮共用同一翻页函数/无边界回弹/有意差异被记录/方向写反必红。另在 `harmony-logic.test.mjs` 加了行为判据(跑 `judgeSwipe` 的四道门)。结算即从此清单移除,余额里不再计这一笔。"
|
||||
],
|
||||
"debts": [
|
||||
{
|
||||
@ -22,13 +23,6 @@
|
||||
"where": "server/internal/repo/mail_status_derived_test.go",
|
||||
"kind": "scope"
|
||||
},
|
||||
{
|
||||
"id": "gesture-semantics",
|
||||
"count": 1,
|
||||
"due": "P6 第 3 步:鸿蒙侧出现滑动手势代码时立即建(此前建 = 只有一端存在的假判据)",
|
||||
"where": "docs/HARMONY-ALIGN-PLAN.md P6 段",
|
||||
"kind": "scope"
|
||||
},
|
||||
{
|
||||
"id": "observability-output",
|
||||
"count": 1,
|
||||
@ -114,4 +108,4 @@
|
||||
"kind": "scope"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -250,7 +250,7 @@ WebUI 侧 `npm test` 在 **HEAD 上就是红的**(`test/background.test.mjs`
|
||||
| P3 ⚠️ 主体完成 | 授权页:`GET /permission/pending` + `POST /permission/decide` | 未决口径与 WebUI 一致(无 `permission_result`)✅;拒绝可填备注且备注送出 ✅;`expired` 当场说清"这次批准不会恢复原调用" ✅。**未验**:真机上点同意/拒绝后状态是否"立刻变"(判据只钉到"决策后重新拉列表"这一层) |
|
||||
| P4 ✅(P4c 上传除外) | 主题/壁纸(`/me/appearance`) | 换账号外观跟随 ✅(缓存键带账号);服务端无记录时以本地为准 ✅(§7.16);**预设 6 档都能画出来** ✅、图片壁纸渲染 ✅(§7.17 —— 这一版补的,第一版只有数据没有画面)。**未做**:P4c 上传入口。**未验**:真机观感(配色/对比) |
|
||||
| P5 ✅ 主体完成 | 悬浮玻璃导航(取代系统 TabBar) | **已换**:自绘浮动玻璃条 `NavBar`(留白 + 胶囊圆角 + 系统材质),内容按 `currentIndex` 挂载;命中区 ≥44vp(数值在 `NavItems.ts`,判据直接 import);内容底部让出 76vp(不让位则最后一行压在条底下:看得见、点不到)—— §7.21。判据 6 条全绿、4 种变异都能红。**未验**:玻璃条在真机上的观感(材质浓度/圆角/阴影)、深色主题下的表现、字形图标渲染 —— **原因是环境硬约束**(模拟器要写工作区外 + 审批无人可送 + 许可协议不接受代签),见 §7.21 末节的「视觉验收的实测阻塞」 |
|
||||
| P6 ✅(滑动翻页除外) | 日历(`/calendar/events` + 农历 `GET /calendar/lunar` + ics 导入导出) | 手势阈值按 **(b)**:**不要求数值一致**(数值各自定、按设备人体工学),契约钉**语义层**(左右滑=什么、边界回弹、「快滑」感知档);鸿蒙**不得引用** WebUI 的三个数(详见分期段)。**有意排序**:入口与内容一起上,不留空页签(§7.15)。<br>**2026-09-18 实况**:只读月/周/日三档 ✅(`b372d0f`)、写侧(新建/编辑/删除)✅、农历 ✅(`5ea9884` —— 走服务端端点,不在 ArkTS 里重写天文算法)、**ics 导入导出 ✅**(`7e10bfa`,走 `DocumentViewPicker`:鸿蒙无「下载目录」概念,这是该平台上唯一能让用户拿到/指定文件的路)。**仍未做**:左右滑动翻页(P6 第 3 步,牵着 `docs/DEBTS.json` 的 `gesture-semantics`) |
|
||||
| P6 ✅ | 日历(`/calendar/events` + 农历 `GET /calendar/lunar` + ics 导入导出) | 手势阈值按 **(b)**:**不要求数值一致**(数值各自定、按设备人体工学),契约钉**语义层**(左右滑=什么、边界回弹、「快滑」感知档);鸿蒙**不得引用** WebUI 的三个数(详见分期段)。**有意排序**:入口与内容一起上,不留空页签(§7.15)。<br>**2026-09-18 实况**:只读月/周/日三档 ✅(`b372d0f`)、写侧(新建/编辑/删除)✅、农历 ✅(`5ea9884` —— 走服务端端点,不在 ArkTS 里重写天文算法)、**ics 导入导出 ✅**(`7e10bfa`,走 `DocumentViewPicker`:鸿蒙无「下载目录」概念,这是该平台上唯一能让用户拿到/指定文件的路)。**2026-09-19 补齐**:左右滑动翻页(P6 第 3 步)✅ —— `PanGesture` 挂在网格列上(不是整页:右栏有输入框与可滚内容,整页挂会让「在表单里横划一下」变成翻月),判定走纯逻辑 `model/Calendar.ts` 的 `judgeSwipe`,翻页复用 `shiftRange`(与 ‹ › 按钮同一来源)。语义契约判据 `test/cross-client-gesture.test.mjs`(8 条,按 (b) 钉语义不钉数值)。★ **有意差异**:WebUI 在周/日档会把手势让给横向滚动条(用户 2026-09-14 亲口报过这个冲突),鸿蒙周档是「一行 7 格按 layoutWeight 等分」、**不横滚** ⇒ 该卡严条件在此不适用(不是漏做)。哪天给周档加了横滚,必须同时补上它 |
|
||||
|
||||
**视觉/交互怎么验**:本机有 `harmony-emu`(实例 `HarmonyPhone`,hdc 在
|
||||
`/opt/harmonyos/ohos-sdk/linux/toolchains`),能起来就能截图 + 真点,
|
||||
|
||||
Reference in New Issue
Block a user