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:
2026-09-14 14:29:27 +08:00
parent a6b5e52f45
commit 2e42aacc07
3 changed files with 356 additions and 4 deletions

View File

@ -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;
}

View File

@ -0,0 +1,207 @@
/*
* 壁纸的**画法**:预设档(渐变)与图片档 —— 纯逻辑,无 UI 依赖。
*
* 为什么单独一层:服务端存的只是 `bg_kind` + `bg_preset_id` + 参数,
* 「这个 id 该画成什么样」是客户端的事。这层决定"画哪些层、什么色、什么位置"
* 页面只负责把层交给系统原语(`radialGradient` / `linearGradient` / `Canvas`)。
*
* ⚠️ 这一层是**能力对等**的关键pi 指出2026-09-14WebUI 的背景有 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~100linear/grid 不用 */
cx: number = 50;
cy: number = 50;
/** radial半径交给系统用百分比或 vplinear角度 */
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` 画线(仍是系统绘制能力,不是手写模糊那一类)。
* 线色与间隔照抄 CSSgray-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;
}

View File

@ -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` 画线(线色/间隔照抄 CSSgray-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%')
}