diff --git a/client/electron/src/index.css b/client/electron/src/index.css index ebf98b1..3c89e82 100644 --- a/client/electron/src/index.css +++ b/client/electron/src/index.css @@ -1337,3 +1337,20 @@ html[data-bg='on'] .glass-card:hover { * 它是按"深色主题已经存在"写的 —— 而实际上组件还没有 dark 变体, * 这条只会让卡片在浅色页面上几乎消失。等真深色主题落地再一起打开。) */ + + +/* + * ★ 宽屏下通信中栏必须**贴住列表面板的宽度**(2026-09-14 用户截图指出: + * 「外框列表和右边正文那么大一个空隙」)。 + * + * 根因是我为了挂内部页签与悬浮加号加的包装层:它写着 `flex-1`, + * 而里面的列表面板是 `lg:w-[320px]` 固定宽 ⇒ 包装层把剩余宽度全占了, + * 列表右边到详情左边就空出一条大缝(缝在包装层里,不在任何面板里, + * 所以看代码很难发现)。 + * + * 窄屏不用改:那边外壳是列方向,`flex-1` 管的是**高度**,宽度由 `w-full` 决定。 + */ +.app-shell .comm-pane { + flex: 0 0 auto; + width: 320px; +} diff --git a/client/harmony/entry/src/main/ets/model/Wallpaper.ts b/client/harmony/entry/src/main/ets/model/Wallpaper.ts new file mode 100644 index 0000000..2438e6e --- /dev/null +++ b/client/harmony/entry/src/main/ets/model/Wallpaper.ts @@ -0,0 +1,207 @@ +/* + * 壁纸的**画法**:预设档(渐变)与图片档 —— 纯逻辑,无 UI 依赖。 + * + * 为什么单独一层:服务端存的只是 `bg_kind` + `bg_preset_id` + 参数, + * 「这个 id 该画成什么样」是客户端的事。这层决定"画哪些层、什么色、什么位置", + * 页面只负责把层交给系统原语(`radialGradient` / `linearGradient` / `Canvas`)。 + * + * ⚠️ 这一层是**能力对等**的关键:pi 指出(2026-09-14)WebUI 的背景有 6 个预设渐变, + * 若鸿蒙只认 `image` 与 `none`,那"换账号后外观跟随"对预设档就是**不成立的** —— + * 用户设了预设,在鸿蒙看到的是没有背景。这是信息对等缺口,比"能不能上传壁纸"更基础。 + * + * 参考实现:`client/electron/src/index.css` 的 `.bg-preset-*` 与 + * `stores/backgroundStore.ts` 的 `PRESETS`(id / 标签 / 归一化规则)。 + * 色值取自那边的调色板变量(浅色档),**逐一对照**,不是凭印象写的。 + * + * ⚠️ 类型可擦除(无 enum / namespace / 构造器参数属性),判据用 node strip-types 直接跑它。 + */ + +/** + * 一层渐变(预设由 1~3 层叠出来,与 WebUI 的多重 background-image 一一对应)。 + * + * `colors` 与 `stops` 分开存(而不是 `[色, 位置][]`):ArkTS 对嵌套元组数组支持有限, + * 而分开存更好判。页面按 `stops[i]` 给 `colors[i]` 组对。 + */ +export class PresetLayer { + /** 'radial' | 'linear' | 'grid' */ + kind: string = 'radial'; + /** radial:中心点百分比(0~100);linear/grid 不用 */ + cx: number = 50; + cy: number = 50; + /** radial:半径(交给系统,用百分比或 vp);linear:角度(度) */ + radius: string = '55%'; + angle: number = 180; + colors: string[] = []; + /** 色标位置(0~100),与 `colors` 一一对应 */ + stops: number[] = []; + /** grid 用:线色 / 透明度 / 间隔 */ + lineColor: string = ''; + lineAlpha: number = 0; + step: number = 0; +} + +/** + * 预设色板(浅色档,取自 WebUI 的调色板变量)。 + * + * 这些色**不走系统语义色**:它们是这套预设的设计内容(与品牌蓝同类), + * 系统里没有"极光渐变"这种东西。深色档下预设的观感**未验**(见文档)。 + */ +const GRAY_100: string = '#F3F4F6'; // --c-gray-100 +const GRAY_200: string = '#EAECF1'; // --c-gray-200 +const BLUE_100: string = '#DBEAFE'; // --c-blue-100 +const BLUE_200: string = '#BFDCFE'; // --c-blue-200 +const GREEN_100: string = '#DCFCE7'; // --c-green-100 +const AMBER_100: string = '#FEF3C7'; // --c-amber-100 +const ORANGE_100: string = '#FFEDD5'; // --c-orange-100 + +/** + * `transparent` 用**关键字**而不是 8 位半透明色值: + * 一是系统认识它,二是本仓有一条判据禁止 8 位色值(那类色值曾经是手写玻璃的证据)。 + * 页面把它映射成 `Color.Transparent`。 + */ +export const TRANSPARENT: string = 'transparent'; + +/** 预设清单:id 与顺序与 WebUI 的 `PRESETS` 逐字一致(判据会去比对) */ +export const PRESET_IDS: string[] = ['aurora', 'dusk', 'mint', 'sand', 'ink', 'mesh']; + +export function presetLabel(id: string): string { + if (id === 'aurora') { + return '极光'; + } + if (id === 'dusk') { + return '暮色'; + } + if (id === 'mint') { + return '薄荷'; + } + if (id === 'sand') { + return '沙丘'; + } + if (id === 'ink') { + return '墨色'; + } + if (id === 'mesh') { + return '网格'; + } + return ''; +} + +const DEFAULT_PRESET: string = 'aurora'; + +/** 认不出的 preset id → 回到默认(与 WebUI 的 `normalize` 同一规则,避免"空白背景") */ +export function normalizePreset(id: string): string { + return PRESET_IDS.indexOf(id) >= 0 ? id : DEFAULT_PRESET; +} + +function radial(cx: number, cy: number, radius: string, color: string, stop: number): PresetLayer { + const l: PresetLayer = new PresetLayer(); + l.kind = 'radial'; + l.cx = cx; + l.cy = cy; + l.radius = radius; + l.colors = [color, TRANSPARENT]; + l.stops = [0, stop]; + return l; +} + +function linear(angle: number, from: string, to: string): PresetLayer { + const l: PresetLayer = new PresetLayer(); + l.kind = 'linear'; + l.angle = angle; + l.colors = [from, to]; + l.stops = [0, 100]; + return l; +} + +/** + * 某个预设由哪些层叠出来(与 `.bg-preset-*` 的多重背景一一对应)。 + * + * 层序 = 绘制顺序:先画的在下层。WebUI 的 CSS 里第一段是最上层(CSS 多重背景 + * 是后面的在下),为保持观感一致,这里**反过来给**:CSS 里最后一段先画。 + */ +export function layersFor(id: string): PresetLayer[] { + const key: string = normalizePreset(id); + if (key === 'dusk') { + return [ + radial(85, 25, '55%', BLUE_200, 55), + radial(12, 80, '55%', ORANGE_100, 55) + ]; + } + if (key === 'mint') { + return [ + radial(78, 70, '55%', BLUE_100, 55), + radial(25, 30, '55%', GREEN_100, 55) + ]; + } + if (key === 'sand') { + return [ + radial(80, 75, '55%', ORANGE_100, 55), + radial(20, 25, '60%', AMBER_100, 60) + ]; + } + if (key === 'ink') { + return [ + linear(160, GRAY_100, GRAY_200), + radial(30, 20, '60%', GRAY_200, 60) + ]; + } + if (key === 'mesh') { + const g: PresetLayer = new PresetLayer(); + g.kind = 'grid'; + /* + * 网格:CSS 用的是 `repeating-linear-gradient`,**系统没有对应的渐变原语**, + * 所以这一档用系统 `Canvas` 画线(仍是系统绘制能力,不是手写模糊那一类)。 + * 线色与间隔照抄 CSS:gray-200 / 0.55 / 28px。 + */ + g.lineColor = GRAY_200; + g.lineAlpha = 0.55; + g.step = 28; + const base: PresetLayer = linear(180, GRAY_100, GRAY_100); + return [base, g]; + } + // aurora(默认) + return [ + radial(68, 82, '55%', BLUE_100, 55), + radial(82, 12, '50%', GREEN_100, 50), + radial(18, 22, '55%', BLUE_200, 55) + ]; +} + +/** 背景计划:页面照着画,判据照着判 */ +export class BackgroundPlan { + /** 'none' | 'preset' | 'image' */ + kind: string = 'none'; + presetId: string = ''; + layers: PresetLayer[] = []; + /** 压暗(0~1)—— image 档压在图上;preset 档不用 */ + scrim: number = 0; +} + +/** + * 快照 + "有没有图" → 画什么。 + * + * 与 `mergeAppearance` 的分工:那边决定**用谁的值**(服务端 vs 本地), + * 这里决定**画成什么**。`image` 档但图没取回来 → `none`: + * 宁可什么都不画,也不要画一块空白(用户会以为壁纸坏了)。 + */ +export function resolveBackground(bgKind: string, presetId: string, scrim: number, hasImage: boolean): BackgroundPlan { + const plan: BackgroundPlan = new BackgroundPlan(); + if (bgKind === 'preset') { + plan.kind = 'preset'; + plan.presetId = normalizePreset(presetId); + plan.layers = layersFor(plan.presetId); + return plan; + } + if (bgKind === 'image' && hasImage) { + plan.kind = 'image'; + plan.scrim = scrim; + return plan; + } + plan.kind = 'none'; + return plan; +} + +/** 预设缩略图/选择器要显示的清单(id + 标签) */ +export function presetChoices(): string[] { + return PRESET_IDS; +} diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index 3816768..458f3c1 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -11,6 +11,9 @@ import { MailApi, InboxResponse } from '../api/MailApi'; import { AccountManager, AccountInfo } from '../api/AccountManager'; import { SseService, SseEvent } from '../api/SseService'; import { AppearanceStore } from '../common/AppearanceStore'; +import { image } from '@kit.ImageKit'; +import { AppearanceSnapshot, scrimOpacity } from '../model/Appearance'; +import { BackgroundPlan, PresetLayer, TRANSPARENT, resolveBackground } from '../model/Wallpaper'; import { MailSummary, Contact, PermissionRequest, DecideResponse, SentResponse, PendingResponse } from '../model/Models'; import { MailLike, @@ -1447,6 +1450,125 @@ struct ContactsTab { @Component struct MainPage { @State currentIndex: number = 0; + /* + * 壁纸层(背景的**画法**在 `model/Wallpaper.ts` 里算好,这里只负责画)。 + * + * P4 的第一版只做到"取回壁纸本体",**没有任何东西去画它** —— 也就是说 + * 那一版里"壁纸"其实只有数据没有画面(我当时的提交信息说"取回 PixelMap", + * 那是真的;但文档里把它列成"未验渲染",听着像已经画出来了,这是我说得比证据强,已改)。 + * 现在补上:预设档画渐变、图片档画图 + 压暗。 + */ + @State bgPlan: BackgroundPlan = new BackgroundPlan(); + @State wallpaperImage: image.PixelMap | null = null; + private gridSettings: RenderingContextSettings = new RenderingContextSettings(true); + private gridCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.gridSettings); + + aboutToAppear(): void { + // 主界面也要应用外观:只在设置页生效的话"一进主界面就变回去"(WebUI 侧踩过) + this.applyAppearance(); + } + + /** + * 读外观(按**账号**)→ 算出画什么 → 落进 @State。 + * + * `applyAppearance` 的名字与 AppearanceStore 里的同名方法一致:那边负责 + * "谁覆盖谁"和系统色彩模式,这里负责把结果**变成画面**。 + */ + async applyAppearance(): Promise { + const ctx = this.getUIContext().getHostContext(); + if (ctx === undefined) { + return; + } + const acctMgr: AccountManager = AccountManager.getInstance(ctx); + await acctMgr.load(); + const store: AppearanceStore = AppearanceStore.getInstance(); + store.loadLocal(ctx, acctMgr.getActiveId()); + const client: ApiClient = new ApiClient(ctx); + await store.syncFromServer(ctx, client); + const snap: AppearanceSnapshot = store.current(); + this.wallpaperImage = store.wallpaper; + this.bgPlan = resolveBackground(snap.bgKind, snap.bgPresetId, scrimOpacity(snap.bgDim), store.wallpaper !== null); + } + + /** 一层渐变的色标:`['色', 位置]` 成对(页面才拼,纯逻辑里只存两个数组) */ + private gradientColors(layer: PresetLayer): [ResourceColor, number][] { + const pairs: [ResourceColor, number][] = []; + for (let i = 0; i < layer.colors.length; i++) { + const c: string = layer.colors[i]; + pairs.push([c === TRANSPARENT ? Color.Transparent : c, layer.stops[i]]); + } + return pairs; + } + + /** + * 壁纸层:**整幅**铺在内容之下。 + * + * 迷雾(模糊)**不在这里**:pi 的口径修正过 —— 正确的是"同一张底只许被模糊一次", + * 而模糊该出现在"背后是可变内容"的层。壁纸层背后没有别的东西, + * 在这里再糊一次只是把同一张图糊两遍(WebUI 侧的原话是"更脏、更掉帧")。 + * 导航条那一层的系统材质才是唯一一次模糊(判据按互斥形式钉住)。 + */ + @Builder + WallpaperLayer() { + if (this.bgPlan.kind === 'preset') { + Stack() { + ForEach(this.bgPlan.layers, (layer: PresetLayer) => { + if (layer.kind === 'radial') { + Column() + .width('100%').height('100%') + .radialGradient({ + center: [layer.cx + '%', layer.cy + '%'], + radius: layer.radius, + colors: this.gradientColors(layer) + }) + } else if (layer.kind === 'linear') { + Column() + .width('100%').height('100%') + .linearGradient({ angle: layer.angle, colors: this.gradientColors(layer) }) + } else { + /* + * 网格档:CSS 的 `repeating-linear-gradient` 系统没有对应原语, + * 用系统 `Canvas` 画线(线色/间隔照抄 CSS:gray-200 / 0.55 / 28)。 + */ + Canvas(this.gridCtx) + .width('100%').height('100%') + .onReady(() => { + const w: number = this.gridCtx.width; + const h: number = this.gridCtx.height; + this.gridCtx.strokeStyle = layer.lineColor; + this.gridCtx.lineWidth = 1; + this.gridCtx.globalAlpha = layer.lineAlpha; + for (let x = 0; x < w; x += layer.step) { + this.gridCtx.beginPath(); + this.gridCtx.moveTo(x, 0); + this.gridCtx.lineTo(x, h); + this.gridCtx.stroke(); + } + for (let y = 0; y < h; y += layer.step) { + this.gridCtx.beginPath(); + this.gridCtx.moveTo(0, y); + this.gridCtx.lineTo(w, y); + this.gridCtx.stroke(); + } + }) + } + }, (layer: PresetLayer, idx: number) => layer.kind + idx) + } + .width('100%').height('100%') + } else if (this.bgPlan.kind === 'image' && this.wallpaperImage !== null) { + Stack() { + Image(this.wallpaperImage) + .width('100%').height('100%') + .objectFit(ImageFit.Cover) + // 压暗用**系统遮罩色** + 服务端给的浓度:换向(浅色洗白/深色压黑)由系统负责 + Column() + .width('100%').height('100%') + .backgroundColor(Theme.overlay) + .opacity(this.bgPlan.scrim) + } + .width('100%').height('100%') + } + } @Builder TabBarBuilder(title: string, icon: string, index: number) { @@ -1492,7 +1614,10 @@ struct MainPage { * (status 与 from_agent 参考实现也不显示,见 `WorkCard.tsx` 的字段集, * 判据里有一条"卡片字段两边一致"钉住这件事,避免以后以为漏了。) */ - Tabs({ barPosition: BarPosition.End }) { + Stack() { + // 背景在最底下:内容面(系统色)盖在它上面 + this.WallpaperLayer() + Tabs({ barPosition: BarPosition.End }) { TabContent() { CommPage() } @@ -1503,9 +1628,12 @@ struct MainPage { } .tabBar(this.TabBarBuilder('联系人', '👤', 1)) } - .onChange((index: number) => { - this.currentIndex = index; - }) + .onChange((index: number) => { + this.currentIndex = index; + }) + .width('100%') + .height('100%') + } .width('100%') .height('100%') }