fix(webui): 修「列表与正文之间那条大空隙」—— 包装层撑满而列表面板固定 320
用户发截图指出:「外框列表和右边正文那么大一个空隙」。
## 根因(在代码里很难看出来)
我为了让「通信」有内部页签与悬浮加号,给中栏加了一层包装 div,它写着 `flex-1`;
而里面的列表面板是 `lg:w-[320px]` **固定宽度** ⇒ 包装层把剩余宽度全占了,
**空隙在包装层里、不在任何面板里** —— 于是既看不出是哪一层的错,也不像"布局 bug",
看起来就像设计上多留了一条白。
## 改法
`.app-shell .comm-pane { flex: 0 0 auto; width: 320px }` —— 宽屏下中栏贴住列表面板的宽度。
窄屏不动:那边外壳是**列**方向,`flex-1` 管的是高度,宽度由 `w-full` 决定。
## 实测(自有浏览器)
宽屏 1600: 包装层 320 / 列表 320 / 详情 1180 → 包装层内空隙 0px,到详情 10px(设计间距)
窄屏 390: 包装层 370 / 列表 370 → 包装层内空隙 0px
顺带说一句:这条与前面「日历没有圆角」「列表项变成一张大框」是**同一个包装层**引出的
三个症状(撑满宽度 / 内层直角戳出圆角 / 每项没有独立卡)。包装层是我为合并导航加的,
当时的验证只看了结构(页签在不在、点得到点不到),**没有量过宽度**,
所以它一连串地出问题。现在宽度已有判据(上面的数字就是判据)。
This commit is contained in:
@ -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;
|
||||
}
|
||||
|
||||
207
client/harmony/entry/src/main/ets/model/Wallpaper.ts
Normal file
207
client/harmony/entry/src/main/ets/model/Wallpaper.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -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<void> {
|
||||
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,6 +1614,9 @@ struct MainPage {
|
||||
* (status 与 from_agent 参考实现也不显示,见 `WorkCard.tsx` 的字段集,
|
||||
* 判据里有一条"卡片字段两边一致"钉住这件事,避免以后以为漏了。)
|
||||
*/
|
||||
Stack() {
|
||||
// 背景在最底下:内容面(系统色)盖在它上面
|
||||
this.WallpaperLayer()
|
||||
Tabs({ barPosition: BarPosition.End }) {
|
||||
TabContent() {
|
||||
CommPage()
|
||||
@ -1509,4 +1634,7 @@ struct MainPage {
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user