diff --git a/client/electron/test/harmony-nav.test.mjs b/client/electron/test/harmony-nav.test.mjs index 8392c0a..952cf08 100644 --- a/client/electron/test/harmony-nav.test.mjs +++ b/client/electron/test/harmony-nav.test.mjs @@ -1339,3 +1339,67 @@ test('★ 判据自检:设备忙的跳过有界 —— K 轮内礼貌、超了 rmSync(dir, { recursive: true, force: true }); } }); + +test('★ 登录/退出必须用同一个原语(`replaceUrl`)—— 只改对一半会以"怪现象"回来', () => { + /* + * ★★ 2026-09-19 修的真 bug(用户报「在主页返回为什么会直接回到登陆页」): + * + * `LoginPage` 用 **`pushUrl`** 去主界面 ⇒ 路由栈是 `[LoginPage, MainPage]` + * ⇒ 在主页按返回,弹掉 MainPage,**回到登录页**。 + * + * 而 `api/Logout.ets` 那一半**早就写对了**,注释也写了理由: + * 「④ replaceUrl 而不是 pushUrl:退出后不该还能"返回"到已登出的页」 + * + * 两件事是同一条不变式的两端: + * · 退出 ⇒ 不该能返回到已登出的页 ⇒ `replaceUrl` ✓(早就对) + * · 登录 ⇒ 不该能返回到已登录的登录页 ⇒ `replaceUrl`(原来错着) + * + * ⇒ 这条判据的形状是**成对检查**,不是逐处检查 —— + * 因为它要防的不是"某一处写错",而是"**只改对了一半**"。 + * 逐处判据在那个形状下必然漏(另一半当时全绿)。 + * + * ★ 为什么不判"必须有 replaceUrl"这么简单:登录要走快速路径(已有账号)、 + * doLogin、tryRestore 三条,逐个写死数量会在加第四条时假红。 + * 所以判"**不许有** pushUrl 到 MainPage" —— 那是唯一会破坏不变式的写法。 + */ + const login = code(join(HARMONY_ETS, 'pages/LoginPage.ets')); + const logout = code(join(HARMONY_ETS, 'api/Logout.ets')); + + /* ① 登录侧:不许 pushUrl 到主界面 */ + const badLogin = [...login.matchAll(/pushUrl\(\{[^}]*MainPage[^}]*\}/g)].map((m) => m[0]); + assert.deepEqual(badLogin, [], + '★ `LoginPage` 里不许用 `pushUrl` 去 `MainPage` —— 那会把主界面**压在登录页之上**,\n' + + ' 于是在主页按返回会**回到登录页**(用户 2026-09-19 报的就是这个)。\n' + + ' 登录是"到达"不是"进入下一层",要用 `replaceUrl`。\n' + + ' 违规处:\n ' + badLogin.join('\n ')); + + /* ② 登录侧:至少要有一次 replaceUrl 到主界面(否则上面那条可能被"全删掉"满足) */ + assert.match(login, /replaceUrl\(\{[^}]*MainPage[^}]*\}/, + '`LoginPage` 要有 `replaceUrl` 去 `MainPage`(上一条只是"不许 pushUrl",' + + '光删不写也能满足它 —— 这条补上"必须真的用对的那个")'); + + /* ③ 退出侧:同样的口径(早就对了,钉住别退化) */ + assert.match(logout, /replaceUrl\(\{[^}]*LoginPage[^}]*\}/, + '`api/Logout.ets` 要用 `replaceUrl` 回登录页 —— 退出后不该还能"返回"到已登出的页'); + assert.ok(!/pushUrl/.test(logout), + '`Logout` 里不该出现 `pushUrl`:与 LoginPage 是**同一不变式的两端**,' + + '两边都要 replaceUrl(一边对一边错就是本条要防的形状)'); + + /* + * ★★ 自检:**造一个违规样本喂给①那条正则**,确认它真的抓得到。 + * + * 为什么必须做这一步(本仓反复记录过):形状判据最常见的失效方式是 + * 正则写歪了 ⇒ **恒绿**。而恒绿的判据看起来和"代码是对的"一模一样。 + * 这里主动构造 `pushUrl({ url: 'pages/MainPage' })` —— + * 正是修复前真实存在的那一行 —— 要求正则命中它。 + */ + const violation = "this.getUIContext().getRouter().pushUrl({ url: 'pages/MainPage' });"; + const re = /pushUrl\(\{[^}]*MainPage[^}]*\}/g; + assert.ok(re.test(violation), + '★ 自检失败:①的正则抓不到修复前那行真实代码 —— 说明正则是歪的,' + + '那样本判据**恒绿**(看起来和"代码正确"一样)。样本:' + violation); + /* 反过来:正确写法不该被误报 */ + const ok = "this.getUIContext().getRouter().replaceUrl({ url: 'pages/MainPage' });"; + assert.ok(!/pushUrl\(\{[^}]*MainPage[^}]*\}/.test(ok), + '★ 自检失败:正确写法(replaceUrl)被误判为违规'); +}); diff --git a/client/electron/test/lib/harmony-device.mjs b/client/electron/test/lib/harmony-device.mjs index 866ed15..ca15561 100644 --- a/client/electron/test/lib/harmony-device.mjs +++ b/client/electron/test/lib/harmony-device.mjs @@ -204,8 +204,34 @@ export function findHdc() { } /** 在 hdc 上跑一条命令,带回 spawnSync 结果。 */ +/** + * 选定目标设备的连接键(`AGENTMAIL_HARMONY_TARGET`,如 `192.168.2.108:43679`)。 + * + * ★★ 为什么需要它(2026-09-19 装真平板时撞出来的): + * 原来所有助手都**不带 `-t`** ⇒ 一旦同时存在两个目标 + * (模拟器 `127.0.0.1:5555` + 真平板 `192.168.2.108:43679`), + * `hdc` 会挑一个 —— 于是设备判据可能在**另一台设备**上跑, + * 而它照样报绿("跑错设备"与"跑对"从输出上看不出来)。 + * + * 这与本仓那条"判据的边界要显式"是同一条:#一台设备#是环境事实, + * 不能靠"当前只有一台"这种偶然。 + * + * 用法:`AGENTMAIL_HARMONY_TARGET=192.168.2.108:43679 node run-all.mjs` + * 不设时行为与从前完全一致(多目标时由 hdc 自己挑)。 + */ +export function targetKey() { + const k = (process.env.AGENTMAIL_HARMONY_TARGET || '').trim(); + return k.length > 0 ? k : null; +} + function sh(hdc, args, timeout = 20000) { - return spawnSync(hdc, args, { encoding: 'utf8', timeout }); + /* + * 注入 `-t `:放在 `hdc` 之后、子命令之前(hdc 的参数序)。 + * 已经被调用方显式带 `-t` 时不重复注入。 + */ + const key = targetKey(); + const argv = key && !args.includes('-t') ? ['-t', key, ...args] : args; + return spawnSync(hdc, argv, { encoding: 'utf8', timeout }); } /** `hdc list targets` 的输出(trim 后)。null 表示 hdc 都没找到。 */ @@ -566,34 +592,66 @@ export function closeColor(a, b, tol = 12) { * * 坐标系与 `dumpLayout` 的 `bounds` 一致(屏幕像素,原点左上)。 */ -export function readPixels(pngPath, screenW = 3184, screenH = 2232) { +export function readPixels(pngPath, screenW, screenH) { if (!existsSync(pngPath)) return null; - const out = join(tmpdir(), `hm-rgb-${process.pid}-${Date.now()}.raw`); + + /* + * ★★ 尺寸必须**问出来**,不能猜(2026-09-19 装真平板时撞出来的)。 + * + * 我原来的实现是"从 raw 字节数 + 一个候选宽度表反推": + * + * const px = buf.length / 3; + * let w = screenW; // 默认 3184(模拟器) + * for (const cand of [3184, 2232, 2560, 1920, 1280, 1080]) ... + * + * 在模拟器(3184x2232,正好在表里)一直是对的, + * **装到真平板(2800x1840)就错了**: + * 候选表里没有 2800 => 退到 1280 => 算出 **1280x4025** + * (像素数恰好相同:5152000)=> 不报错,`at(x,y)` 全读到错位的行。 + * + * 危险之处是**它长得像成功**:句柄返回了、尺寸是个正数、 + * `at()` 也不越界 —— 只有拿 `file` 去看原图才发现对不上。 + * 这个错会一路带进结论("某个颜色不对"其实是读错了行)。 + * + * => 改用 `ffprobe` 问真实尺寸(一次调用,几毫秒), + * 并且**校验 raw 字节数是否等于 w*h*3** —— + * 不等就返回 null(宁可让判据说"读不到",也不要给它一张错位的图)。 + */ + let w = screenW || 0; + let h = screenH || 0; + if (!w || !h) { + const probe = spawnSync('ffprobe', [ + '-v', 'error', '-select_streams', 'v:0', + '-show_entries', 'stream=width,height', + '-of', 'csv=p=0', pngPath, + ], { encoding: 'utf8', timeout: 20000 }); + const out = (probe.stdout || '').trim(); // 形如 "2800,1840" + const m = /^(\d+),(\d+)$/.exec(out); + if (!m) return null; + w = Number(m[1]); + h = Number(m[2]); + } + if (!w || !h) return null; + + const raw = join(tmpdir(), `hm-rgb-${process.pid}-${Date.now()}.raw`); const r = spawnSync('ffmpeg', [ '-loglevel', 'error', '-y', '-i', pngPath, - '-f', 'rawvideo', '-pix_fmt', 'rgb24', out, + '-f', 'rawvideo', '-pix_fmt', 'rgb24', raw, ], { encoding: 'utf8', timeout: 60000 }); - if (r.error || !existsSync(out)) return null; + if (r.error || !existsSync(raw)) return null; + let buf; try { - buf = bytes(out); + buf = bytes(raw); } catch { return null; } finally { - try { unlinkSync(out); } catch { /* 清不掉就算了 */ } - } - /* 尺寸从数据长度反推 —— 这样调用方不必传对屏幕尺寸 */ - const px = buf.length / 3; - let w = screenW; - let h = Math.round(px / w); - if (h * w !== px) { - /* 退一步:按常见屏宽找整除(设备可能是折叠态/别的分辨率) */ - w = 0; - for (const cand of [3184, 2232, 2560, 1920, 1280, 1080]) { - if (px % cand === 0) { w = cand; h = px / cand; break; } - } - if (!w) return null; + try { unlinkSync(raw); } catch { /* 清不掉就算了 */ } } + + /* 字节数必须严丝合缝 —— 这是防"错位读到别的行"的那道闸 */ + if (buf.length !== w * h * 3) return null; + return { w, h, diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 4354d4f..07394a1 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -83,7 +83,7 @@ const SUITE = [ // P4 外观同步:跑 model/Appearance.ts(纯逻辑),所以也要 strip-types ['test/harmony-appearance.test.mjs', ['--experimental-strip-types', '--no-warnings'], 27], // P5 悬浮玻璃导航:点击配对 / index 决定挂载 / 命中区 ≥44vp / 悬浮与让位 - ['test/harmony-nav.test.mjs', ['--experimental-strip-types', '--no-warnings'], 18], + ['test/harmony-nav.test.mjs', ['--experimental-strip-types', '--no-warnings'], 19], // 上下黑边(用户 2026-09-17/18 报过两次)——钉的是一整套东西的两半: // `setWindowLayoutFullScreen(true)`(消黑边)+ `getWindowAvoidArea`(让开时钟/手势条)。 // 只做前半 ⇒ 页签被时钟盖住(上一次就是这样退回去的,黑边于是留了三天); diff --git a/client/harmony/entry/src/main/ets/pages/LoginPage.ets b/client/harmony/entry/src/main/ets/pages/LoginPage.ets index 524cadd..00ceaf7 100644 --- a/client/harmony/entry/src/main/ets/pages/LoginPage.ets +++ b/client/harmony/entry/src/main/ets/pages/LoginPage.ets @@ -19,6 +19,32 @@ import { AmIcon } from '../common/Icons'; @Entry @Component struct LoginPage { + /* + * ★★ 2026-09-19 修(用户报「在主页返回为什么会直接回到登陆页」): + * + * 本页先前用 **`pushUrl`** 去主界面,那是**把主界面压在登录页之上**: + * + * pushUrl ⇒ 路由栈变成 [LoginPage, MainPage] + * ⇒ 在主页按返回 = 弹出 MainPage ⇒ **回到登录页** + * + * 而登录是"到达",不是"进入下一层":用户在这个屏幕上完成的是 + * 「从无会话 → 有会话」的状态跃迁,不是一次可以返回的导航。 + * + * ★ 这个理由**签名就在同一个仓库里**(`api/Logout.ets`,退出登录那一半), + * 而且是反方向的同一件事: + * + * 「④ replaceUrl 而不是 pushUrl:退出后不该还能"返回"到已登出的页」 + * + * 两件事是同一条不变式的两端: + * · 退出 ⇒ 不该能返回到已登出的页 ⇒ `replaceUrl` ✓(早就对了) + * · 登录 ⇒ 不该能返回到已登录的登录页 ⇒ `replaceUrl`(本页原来错着) + * + * ⇒ 教训:**成对的操作要用同一个原语**。只把一半改对, + * 另一半会在很久以后以"用户报了一个奇怪的现象"的形式回来。 + * (解铉:登录/退出是同一件事的两个方向,搜 `replaceUrl` 应该同时看到两处。) + * + * 本页三处跳主界面(快速路径 / doLogin / tryRestore)**全部**用 `replaceUrl`。 + */ /* * 当前是否深色(`MainPage` 经 `AppStorage` 发布)—— 用来选品牌浅底的深浅变体。 * @@ -66,7 +92,7 @@ struct LoginPage { * 漏了这里,老用户(最需要推送的那批)恰好永远不补报。 */ this.reportPushToken(client); - this.getUIContext().getRouter().pushUrl({ url: 'pages/MainPage' }); + this.getUIContext().getRouter().replaceUrl({ url: 'pages/MainPage' }); return; } // 无账号,走正常登录流程 @@ -124,8 +150,8 @@ struct LoginPage { SseService.getInstance().connectForAccount(acct.id, acct.server, acct.token); } this.reportPushToken(c); - // ③ ★ 跳转 —— 原来缺的就是这一句 - await this.getUIContext().getRouter().pushUrl({ url: 'pages/MainPage' }); + /* ③ 跳转 —— 原来缺的就是这一句。用 replaceUrl,理由见 doLogin 上方那段。 */ + await this.getUIContext().getRouter().replaceUrl({ url: 'pages/MainPage' }); } catch (e) { const c2: ApiClient | null = this.client; if (c2 !== null) { @@ -213,8 +239,8 @@ struct LoginPage { } // ★ 登录成功 ⇒ 补报一次推送 token(见 reportPushToken 的说明:启动那次是在登录之前) this.reportPushToken(c); - // 跳转主界面(含 Tab 导航) - await this.getUIContext().getRouter().pushUrl({ url: 'pages/MainPage' }); + /* 跳转主界面(含 Tab 导航)—— replaceUrl,理由见 doLogin 上方那段 */ + await this.getUIContext().getRouter().replaceUrl({ url: 'pages/MainPage' }); } catch (e) { const ae = e as ApiError; const msg: string = ae.code === 0 ? ae.message : (ae.message.length > 0 ? ae.message : '登录失败'); diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index d61922f..1ba15aa 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -1808,11 +1808,29 @@ struct ContactsTab { } } /* - * ★ 确认态下不能再钉死 85 高 + `clip(true)`: - * 确认框(两行文案 + 两个按钮)比 85 高,会被裁掉而看不见按钮 —— - * 那正是"破坏性操作点不了也退不出"。高度交给内容自己定。 + * ★★ 2026-09-19 修(用户报「联系人界面存在严重问题」): + * + * 这里原来写 `.height(85)`,而**内容需要 95vp**: + * + * agent 名 14 + 3 + 主题 12 + 3 + 档位行 11 + 6 + * + 动作行 26 + padding(10+10) = **95vp** + * + * 加上 `.clip(true)` ⇒ 多出来的 **10vp(该平板 ≈ 21px)** + * 被硬切掉 —— 切掉的正好是**「写信 / 归档」那一行的下半截**。 + * + * 真机现场(HUAWEI MatePad Pro,密度 2.125): + * 卡片实测高 181px = 85vp(吻合), + * 截图里两个按钮只剩上半截,看起来像"渲染坏了"。 + * + * ★ 讽刺的是:**旁边那段注释早就写明了这个道理**, + * 只是它只管了「确认态」那一支 —— + * 「确认框比 85 高,会被裁掉而看不见按钮,高度交给内容自己定」。 + * 而普通态同样装不下,只是差得少(10vp)、不容易一眼看出来。 + * + * ⇒ 两个分支的不变式其实是同一条:**高度必须由内容决定**。 + * 不要把一个"按当时字号的估算值"钉成常量 —— + * 字号、行高、动作行的存在与否都会变,而常量不会跟着变。 */ - .height(this.pendingArchiveId === c.session_id ? undefined : 85) .backgroundColor(Theme.surface) .borderRadius(Theme.radiusCard) .clip(true) @@ -2162,11 +2180,22 @@ struct ContactsTab { } .width('100%').margin({ top: 6 }) } - .layoutWeight(1).height('100%') + /* + * ★★ 2026-09-19:把 `height('100%')` 拿掉。 + * + * 它与外层 ListItem 的硬高度是**一对**: + * ListItem `.height(85)` + 这里 `height('100%')` ⇒ 刚好 85。 + * 我只把 ListItem 那半去掉之后,这里就变成"填满整个列表"—— + * 实测 ListItem 高 **1563px**(一整屏就一张卡)。 + * + * ⇒ 两半必须一起改:**让内容决定高度**。 + * `layoutWeight(1)` 保留(横向撑满),纵向不再写 height。 + */ + .layoutWeight(1) .alignItems(HorizontalAlign.Start) .justifyContent(FlexAlign.SpaceBetween) } - .width('100%').height('100%') + .width('100%') .padding({ left: 16, right: 16, top: 10, bottom: 10 }) .alignItems(VerticalAlign.Center) } @@ -3071,7 +3100,30 @@ struct MainPage { * 不加 backgroundBlurStyle:卡片是"正文面不透"的那一层(Theme 注释原话), * 玻璃只留给浮层(NavBar),不是这里。 */ - .backgroundColor(this.isWide ? Theme.surface : (this.bgActive ? Color.Transparent : Theme.surface)) + /* + * ★★ 2026-09-19 修(用户报「界面完全不透明,不显示背景」): + * + * 这一行原来写的是: + * `.backgroundColor(this.isWide ? Theme.surface + * : (this.bgActive ? Color.Transparent : Theme.surface))` + * + * 宽屏那一支**无条件** `Theme.surface` —— `bgActive` 根本没参与判断。 + * 而平板(2800×1840,宽高比 1.52 > 1.2)**就是宽屏** + * ⇒ 整个内容列被一块不透明面盖死,壁纸只在底部那条缝里露出来。 + * + * 实测现场:真平板 HUAWEI MatePad Pro 上,截图里只有最下沿能看到 + * 一丝极光图案,其余全是不透明白底。 + * + * ★ 为什么写成这样(推测的成因,记下来免下次重蹈): + * 宽屏那支是从 WebUI `app-shell` 的"面板"几何抄来的 + * (padding/gap/radius —— 注释就在旁边),而 `app-shell > *` + * 在壁纸开启时是**半透玻璃**(`index.css:1070`),不是纯白。 + * 抄几何时把"面"也一起写成了实心 `surface`,漏了 `bgActive` 这一维。 + * + * ★ 修法与窄屏那一支**对齐**(窄屏本来就是对的): + * 壁纸开着就透明,让底下的 `WallpaperLayer()` 透上来。 + */ + .backgroundColor(this.bgActive ? Color.Transparent : Theme.surface) .borderRadius(this.isWide ? Theme.glassRadius : 0) .clip(this.isWide) /* @@ -3123,7 +3175,28 @@ struct MainPage { * 加在**内容层**而不是根 `Stack`:壁纸层是 `Stack` 的底层兄弟, * 根上加了 padding 会把壁纸一起缩进去,黑边只是换个地方出现。 */ - top: this.isWide ? Theme.paneGap : this.windowInsets.statusBar, + /* + * ★★ 2026-09-19 修(用户报「顶部还是被状态条挡住了」): + * + * 原来写的是:`top: this.isWide ? Theme.paneGap : this.windowInsets.statusBar` + * + * 宽屏那一支只留 `paneGap`(10vp),**把状态栏避让整个丢掉了**。 + * 平板(2800×1840,宽高比 1.52)**就是宽屏** ⇒ 顶栏被顶进状态栏里。 + * + * 实测现场(HUAWEI MatePad Pro):状态栏占 y=0..83px, + * 而我们的 tab 文案(收件箱/发件箱/授权)渲染在 y=83 —— + * 截图里 `收件箱 20` 与系统的 `浏览器 10:06` 挤在同一行。 + * + * ★ 与旁边那个背景透明 bug 是**同一个形状**: + * `this.isWide ? A : B` 里,A 那一支是照 WebUI 几何写的, + * 而 WebUI 没有"状态栏避让"这个概念(浏览器里没有状态栏), + * 所以抄几何时把这一维也一起丢了。 + * + * ⇒ 避让是**窗口级事实**,与宽窄无关 —— 它不该出现在任何三元的 + * "宽屏那一支"里。宽屏只是**额外多留** paneGap(面板投影的余量), + * 不是**代替**避让。 + */ + top: this.windowInsets.statusBar + (this.isWide ? Theme.paneGap : 0), bottom: this.isWide ? Theme.paneGap : 0 }) diff --git a/client/harmony/entry/src/main/ets/pages/WideSidebar.ets b/client/harmony/entry/src/main/ets/pages/WideSidebar.ets index 831e370..d5c7225 100644 --- a/client/harmony/entry/src/main/ets/pages/WideSidebar.ets +++ b/client/harmony/entry/src/main/ets/pages/WideSidebar.ets @@ -45,7 +45,7 @@ import { navBadgeTone, sidebarContentIndex } from '../model/NavItems'; -import { Insets, topInset } from '../model/WindowInsets'; +import { Insets } from '../model/WindowInsets'; /** 侧栏宽度(vp)。WebUI 的 `Sidebar` 用 `w-[60px]` */ export const SIDEBAR_WIDTH: number = 60; @@ -289,7 +289,35 @@ export struct WideSidebar { */ .width(SIDEBAR_WIDTH) .height('100%') - .padding({ top: 12 + topInset(this.windowInsets), bottom: 12 + this.windowInsets.navIndicator }) + /* + * ★★ 2026-09-19 修(用户报「侧边栏的避让有点用力过猛了」): + * + * 这里原来写的是: + * `.padding({ top: 12 + topInset(this.windowInsets), + * bottom: 12 + this.windowInsets.navIndicator })` + * + * 而**父级已经避让过一次了** —— `MainPage` 包住这整排窗格的 + * `Row` 上有: + * `.padding({ top: this.windowInsets.statusBar + paneGap, … })` + * 而 `WideSidebar` 是那个 `Row` 的**第一个子节点** + * ⇒ 状态栏高度被加了 **两遍**,侧栏顶部空出一大块。 + * + * 真机现场(HUAWEI MatePad Pro,状态栏 83px、密度 2.125): + * 83px(父级) + 83px(这里) = 166px ≈ 78vp 的空白, + * 然后才轮到第一个导航项。用户看就是"避让用力过猛"。 + * + * ★ 同一条纪律的第二次应用(见 `Theme.ets` 里 `textSubtleFor` 那段): + * **避让高度是父级的事**。子组件再取一次,就变成"两个地方各让一遍"。 + * 而且错得很安静 —— 两边单独看都是"对的"(都确实避让了), + * 只有把树读一遍才看得出是**两遍**。 + * + * ★ 底部**不动**(这个区分很重要,我第一版差点删过头): + * 父级底部只有 `paneGap`(10vp,面板投影的余量),**没有** `navIndicator` + * ⇒ 那个手势条高度只在这里加了一次,不是两遍。 + * 把它也删掉反而会让底部一簇压在手势条上。 + * —— 所以"重复"要**逐边**看,不能"上面重复了就把这条 padding 整条删掉"。 + */ + .padding({ top: 12, bottom: 12 + this.windowInsets.navIndicator }) /* * ★ 2026-09-16:玻璃面板(对齐 WebUI app-shell)。 * bgActive(自定义壁纸开)时:系统材质 BlurStyle(半透明由系统给,不许手写 alpha)