用户:「「我的」页面显得非常挤」
先量再改。取了宽屏(3184px)与窄屏(1008px)两态,用 `dumpLayout` 拿真实 bounds,
反推 density=2.875。三个真因,**宽度是主因**。
## ① 内容列没有宽度上限(主因)
WebUI `AccountPage.tsx:104`:
<div className="w-full max-w-3xl mx-auto px-4 md:px-6 lg:px-10 py-6 space-y-6">
`max-w-3xl` = **48rem = 768px** + `mx-auto` 居中。
鸿蒙这边原来只有 `.width('100%')` —— **没有任何上限**。
后果(实测):宽屏下卡片铺满 **2923px(1376vp)**,而里面全是
"标签 + 短值"的两列行,文字只占左边一小块 ⇒ 右边一大片留白,
整页读起来像一条被拉长的表格。这正是"挤"的观感来源 ——
不是行距小(实测行距 30vp / 字 14vp,其实偏松),是**横向没有收拢**。
修后实测:内容列 **2208px = 768.0vp**(与目标逐位吻合),
中心 1692.0 vs 父容器 1692.5 ⇒ 居中生效。
★ 形状:`Scroll > Row(居中容器)> Column(带上限)`。
**不能把居中挂在 `Scroll` 上** —— 官方 `ScrollAttribute` 没有
`.justifyContent()`。我第一版就是直接给 Scroll 挂,那是个编出来的属性。
ArkUI 没有 `mx-auto` 的直接对应物,"居中"必须靠一个容器节点。
★ 窄屏(1008px = 350vp < 768)下约束**自动不生效**,逐像素确认窄屏形态未变。
## ② 段间距只有 WebUI 的 1/3
WebUI `space-y-6` = **24px**;我们每张卡各写 `margin({ top: 8 })`(7 处)
与 `margin({ top: 10 })`(1 处)—— 既不统一,也贴得太近,
卡片挨在一起时"段"的边界看不出来,整页就是一大块密集表单。
⇒ 收成一个 `SECTION_GAP = 24` 常量,8 处全部改用它。
实测卡片间实测 **69px = 24.0vp** ✓
## ③ `SectionTitle()` 定义了却只调用一次
WebUI `AccountPage` 有 **5 个 `<h3>`** 段标题(基本资料 / 权限范围 /
修改密码 / 登录状态 / 管理),鸿蒙这边只有 **1 个**('权限范围')——
基本资料那六行是裸放在卡片里的,与下面的权限范围只靠一条 `Divider` 硬分。
⇒ 补「基本资料」标题。没有标题就没有"这是另一段"的语义,只能靠线。
## ④ 顺手清掉 3 条自有 ArkTS 告警(37 → 34)
上一条提交我说"`fill` 那 2 处留给下一轮"—— 不该推,这轮做完:
- **2× `fill` API 是 SDK 26.0.0**(`Circle().fill()`,在 WideSidebar 连接点
与 InboxPage/MainPage 未读点)。查 SDK 头文件确认不是误报:
`circle.d.ts` 的 `CircleAttribute.fill()` 标 **@since 26.0.0**,
而基类 `CommonShapeMethod.fill()` 才是 @since 11 —— 子类重载**遮蔽**了它。
设备实测点**确实渲染**(那是碰巧兼容),但契约上它比编译目标(23)新。
⇒ 换成与 `CalendarPage` 小圆点同一形状(普通容器 + width/height + borderRadius),
只用 API 11 起的通用属性。
- **1× `'packing' has been deprecated`** → `packToData`(SDK 明确给了
`@useinstead image.ImagePacker#packToData`;两者签名逐字相同:
`(PixelMap, PackingOption) => Promise<ArrayBuffer>`,只改名字)。
- **1× `This API is unavailable to 2in1`** → 加 `deviceInfo.deviceType !== '2in1'`
守卫。SDK 原文:「From API version 12, this API does not take effect on
2-in-1 devices.」告警仍在(静态检查不看运行时分支),但行为已正确分流 ——
留着这条注释说明为什么不断言消失。
★ 这条告警的噪声价值:同一批里就藏着 `fill` 那个真隐患。
逐条筛之前,它只是"每次重编都出现的一行字"。
## 验证
✓ 宽屏:内容列 768.0vp、居中、段间距 24.0vp(`dumpLayout` 实测 bounds)
✓ 窄屏:约束不生效、形态与改前一致(像素对照)
✓ 自有 ArkTS 告警 37 → 34(`fill`×2 与 `packing` 消失)
✓ 编译通过、设备安装后进程存活
429 lines
19 KiB
Plaintext
429 lines
19 KiB
Plaintext
/*
|
||
* 背景选择器(不设 / 预设 / 自定义图片)+ **P4c 壁纸上传**。
|
||
*
|
||
* 对应 WebUI 的 `client/electron/src/components/BackgroundPicker.tsx`(三选一 + 预设网格 +
|
||
* 图片上传 + 浓度滑杆),照它踩过的三条做(`docs/HARMONY-ALIGN-PLAN.md` §7.17 的 P4c 段):
|
||
*
|
||
* ① **先压缩再上传**(手机直出照片 4–8MB,服务端上限 4MB ⇒ 直传必然 413);
|
||
* ② **失败必须给原因**(别静默失败);
|
||
* ③ **上传成功后仍以服务端为权威**(`saved` 那套规则对图片同样适用)。
|
||
*
|
||
* ── 这个组件的边界(与设置页的分工)──
|
||
*
|
||
* 本组件**只管画与选**:四个值用 `@Link` 双向绑到设置页的 `@State`,
|
||
* 设置页那边 `@Watch` 到变化就推服务端(`setBackground`,与既有的 `setTheme` 同一个形状)。
|
||
* 组件里**不写**服务端调用 —— 两个理由:
|
||
* ① 本仓库既有的父子通信只有 `@Prop`/`@State`,没有"传回调函数"的先例
|
||
* (`CalendarPage` 被 `MainPage` 用时只传 `bgActive`/`visible`),
|
||
* 凭空引入一种新接法会让下一个人看不懂数据从哪来;
|
||
* ② 推服务端要 `ApiClient` + `AppearanceStore` + 本地缓存,那是**页面**的职责。
|
||
*
|
||
* **唯一一处例外**是上传:它必须自己走 `picker`/`image`/`upload` 那一条链
|
||
* (设置页拿不到 uri),所以上传完成后它调用 `onUploaded` 请页面重新同步。
|
||
*
|
||
* 压缩的**数值**不在这个文件里 —— 在 `model/ImagePrep.ts`(纯逻辑、判据直接跑):
|
||
* 这里只负责"拿到路径 → 解码 → 按计划压 → 上传 → 报结果"。
|
||
*
|
||
* ⚠️ **视觉未验**:本机无设备/无模拟器,配色与观感一律未验(只保证机制与数值)。
|
||
*/
|
||
import { photoAccessHelper } from '@kit.MediaLibraryKit';
|
||
import { image } from '@kit.ImageKit';
|
||
import { BusinessError } from '@kit.BasicServicesKit';
|
||
import { ApiClient } from '../api/ApiClient';
|
||
import { AppearanceApi } from '../api/AppearanceApi';
|
||
import { Theme } from '../common/Theme';
|
||
import { PRESET_IDS, presetLabel, normalizePreset } from '../model/Wallpaper';
|
||
import {
|
||
CompressPlan,
|
||
CompressPass,
|
||
MAX_EDGE,
|
||
PickJudgement,
|
||
TOO_LARGE_REASON,
|
||
UPLOAD_OK_HINT,
|
||
estimateSourceBytes,
|
||
judgePick,
|
||
planCompress,
|
||
scaleToMaxEdge,
|
||
shouldRetryWithActual,
|
||
uploadFailureHint
|
||
} from '../model/ImagePrep';
|
||
|
||
/** 三选一的选项(与 WebUI 的 `options` 逐项对应) */
|
||
class KindOption {
|
||
value: string = 'none';
|
||
label: string = '';
|
||
}
|
||
|
||
function kindOption(value: string, label: string): KindOption {
|
||
const o: KindOption = new KindOption();
|
||
o.value = value;
|
||
o.label = label;
|
||
return o;
|
||
}
|
||
|
||
const KIND_OPTIONS: KindOption[] = [
|
||
kindOption('none', '不设'),
|
||
kindOption('preset', '预设'),
|
||
kindOption('image', '自定义图片')
|
||
];
|
||
|
||
@Component
|
||
export struct BackgroundPicker {
|
||
/*
|
||
* 背景四值:`@Link` 双向绑(V1 家规:**@Link 不许给初值**,父组件用 `$bgKind` 传)。
|
||
* 组件改它们 = 立刻改到页面的 @State(界面即时反馈),随后由 `onUserChanged` 通知页面推服务端。
|
||
*/
|
||
/** 背景档:none | preset | image */
|
||
@Link bgKind: string;
|
||
/** 预设 id */
|
||
@Link bgPresetId: string;
|
||
/** 浓度(口径与 clamp 在 model/Appearance.ts) */
|
||
@Link bgDim: number;
|
||
/** 模糊 */
|
||
@Link bgBlur: number;
|
||
/** 服务端状态文案(只读透传:组件不解释它) */
|
||
@Prop statusText: string = '';
|
||
/**
|
||
* **用户**改了背景四值 ⇒ 通知页面推服务端。
|
||
*
|
||
* ★ 只在**用户动作**里调(onClick / onChange),**不在** `@Link` 值被页面
|
||
* 复制进来时调 —— 否则"服务端同步进来"会被当成"用户改的"再推一次。
|
||
* 组件自己不知道值是谁改的,所以这个区分只能靠**调用点**(见各处 onClick)。
|
||
*/
|
||
onUserChanged: (kind: string, presetId: string, dim: number, blur: number) => void = () => {};
|
||
/** 上传成功后请页面**重新以服务端为准同步**(P4c 第③条:组件不自己宣布成功) */
|
||
onUploaded: (message: string) => void = () => {};
|
||
/** 上传失败的原因(页面负责显示;空串=没事) */
|
||
onUploadFailed: (reason: string) => void = () => {};
|
||
|
||
private client: ApiClient | null = null;
|
||
|
||
@State uploading: boolean = false;
|
||
/** 进度文案(**必须显示**:这一步可能几秒,没有反馈就会被当成卡死) */
|
||
@State progress: string = '';
|
||
/** 上传失败的原因(**不吞**,就地也显示一份) */
|
||
@State uploadError: string = '';
|
||
|
||
aboutToAppear(): void {
|
||
const ctx = this.getUIContext().getHostContext();
|
||
if (ctx !== undefined) {
|
||
this.client = ApiClient.getInstance(ctx);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* P4c 主流程:选图 → 判可不可以 → 逐档解码压缩 → 上传 → 请页面重新同步。
|
||
*
|
||
* ★ 每一步失败都**带原因**(第②条),而且**两条路都给**:就地显示(`uploadError`)
|
||
* 与回调页面(`onUploadFailed`)。只给一处的话,换一个父组件就会静默。
|
||
*/
|
||
async pickAndUpload(): Promise<void> {
|
||
const client: ApiClient | null = this.client;
|
||
if (client === null) {
|
||
this.fail('还没拿到网络客户端(页面没初始化完?)');
|
||
return;
|
||
}
|
||
this.uploadError = '';
|
||
this.progress = '';
|
||
|
||
// ① 选图:用**照片选择器(picker)**,**不需要媒体权限**(自己去读媒体库才需要)。
|
||
// 走 `photoAccessHelper` 而不是 `@kit.CoreFileKit` 的 `picker`:
|
||
// SDK 里 `@ohos.file.picker` 的这几个类都标了 `@deprecated`
|
||
// (`@useinstead @ohos.file.photoAccessHelper:...`),两边 API 形状相同。
|
||
// 用废弃入口的后果不是"编不过",而是**某天构建开始报警告、下一个人不知道该换哪个**。
|
||
let uri: string = '';
|
||
try {
|
||
const options = new photoAccessHelper.PhotoSelectOptions();
|
||
options.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
|
||
options.maxSelectNumber = 1;
|
||
const photoPicker = new photoAccessHelper.PhotoViewPicker();
|
||
const result: photoAccessHelper.PhotoSelectResult = await photoPicker.select(options);
|
||
if (result.photoUris.length === 0) {
|
||
return; // 用户取消:不是错误,什么都不说(说"失败"会让人以为自己点错了)
|
||
}
|
||
uri = result.photoUris[0];
|
||
} catch (e) {
|
||
this.fail('打开相册失败:' + businessMessage(e));
|
||
return;
|
||
}
|
||
|
||
let packed: ArrayBuffer | null = null;
|
||
let usedBytes: number = 0;
|
||
/**
|
||
* 最后一档压完**是否仍超限**。
|
||
*
|
||
* ★ 这个变量存在的理由是"把判定收成一处":见下面 for 循环里那段说明 ——
|
||
* 循环里判一次(决定 break 还是试下一档),循环外只读这个**结论**,不重算。
|
||
*/
|
||
let overLimit: boolean = false;
|
||
this.uploading = true;
|
||
try {
|
||
// ② 读原始尺寸,先过入口检查。
|
||
//
|
||
// ★ `head` 用 try/finally 收口**只释放一次**:这条路径上有三处出口
|
||
// (入口检查不过 / 压完仍超限 / 正常走完),每处各写一次 release
|
||
// 就会漏一处或重一处 —— 重一处是"对一个已释放对象再 release",
|
||
// 在真机上是难查的原生层异常。**释放写在 finally 里就不会有第二个答案。**
|
||
const head: image.ImageSource = image.createImageSource(uri);
|
||
try {
|
||
const info: image.ImageInfo = await head.getImageInfo();
|
||
const sourceBytes: number = estimateSourceBytes(info.size.width, info.size.height);
|
||
const verdict: PickJudgement = judgePick(sourceBytes, 'image/jpeg');
|
||
if (!verdict.ok) {
|
||
this.fail(verdict.reason);
|
||
return;
|
||
}
|
||
|
||
// ③ 逐档压。每档都**从原 uri 重新解码到目标尺寸**(`DecodingOptions.desiredSize`):
|
||
// 峰值内存只有"目标尺寸"那一份,而不是"原图 + 缩放副本"两份
|
||
// (4K 照片解码后约 48MB,两份会把低端机推爆)。
|
||
// 代价是解码两次 —— 只在第一档超限时才发生,正常照片一次都不多。
|
||
const plan: CompressPlan = planCompress(info.size.width, info.size.height);
|
||
for (let i = 0; i < plan.passes.length; i++) {
|
||
const pass: CompressPass = plan.passes[i];
|
||
this.progress = '压缩中(第 ' + pass.attempt + ' 档,最长边 ' + pass.maxEdge + ')…';
|
||
const size = scaleToMaxEdge(info.size.width, info.size.height, pass.maxEdge);
|
||
const passSource: image.ImageSource = image.createImageSource(uri);
|
||
// 同样收口:解码或压缩中途抛异常时,这个 ImageSource 也必须释放
|
||
try {
|
||
const decodeOptions: image.DecodingOptions = {
|
||
desiredSize: { width: size.width, height: size.height }
|
||
};
|
||
const scaled: image.PixelMap = await passSource.createPixelMap(decodeOptions);
|
||
try {
|
||
packed = await packJpeg(scaled, pass.quality);
|
||
usedBytes = packed.byteLength;
|
||
} finally {
|
||
await scaled.release();
|
||
}
|
||
} finally {
|
||
await passSource.release();
|
||
}
|
||
/*
|
||
* ④ 用**真实**字节数判要不要退下一档(估算只用来决定"值不值得先试第一档")。
|
||
*
|
||
* ★ 这里就是**唯一**的"要不要再压一档"判定 —— 循环结束后**不再重判一次**。
|
||
* 原来循环外面还有一句
|
||
* 循环后一句「若(没压出来 或 仍超限)则 fail(TOO_LARGE_REASON)」,
|
||
* 而那两句是**互相抵消**的,把真正的判据架空了:
|
||
* · 超限 ⇒ 循环不 break(去试第二档);两档都超限 ⇒ 循环自然走完、`usedBytes` 仍超限 ⇒ 那句红;
|
||
* · 第二档压完不超限 ⇒ 循环 break ⇒ 那句也不红。
|
||
* 结果:把循环里的 `break` 改成 `if (true)`(**永远只压一档,第二档彻底死掉**),
|
||
* 整套判据照样全绿 —— 因为超限这件事被循环外那句接住了。
|
||
* 而循环外那句自己也有个洞:它不区分"第一档超了就认定失败"和"两档都超了"。
|
||
*
|
||
* ⇒ 收成一处:**循环里判**(决定 break / 试下一档),循环外只看 `overLimit` 这个结论。
|
||
*/
|
||
overLimit = shouldRetryWithActual(usedBytes);
|
||
if (!overLimit) {
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (packed === null || overLimit) {
|
||
this.fail(TOO_LARGE_REASON);
|
||
return;
|
||
}
|
||
} finally {
|
||
await head.release();
|
||
}
|
||
|
||
// ⑤ 上传(内存直传,不落临时文件)
|
||
this.progress = '上传中(' + Math.round(usedBytes / 1024) + ' KB)…';
|
||
await new AppearanceApi(client).uploadImageBytes(packed, 'wallpaper.jpg');
|
||
|
||
// ⑥ 服务端是权威:**请页面**重新同步,而不是组件自己宣布成功并改档位
|
||
this.progress = '已上传,正在以服务端为准重新同步…';
|
||
this.onUploaded(UPLOAD_OK_HINT);
|
||
} catch (e) {
|
||
// 服务端的 415「壁纸必须是图片…」/413「超过上限…」文案在这里原样透出
|
||
this.fail(uploadFailureHint(businessMessage(e)));
|
||
} finally {
|
||
this.uploading = false;
|
||
this.progress = '';
|
||
}
|
||
}
|
||
|
||
/** 一处收口:把"用户改了"这件事报给页面(调用点只有一个形状,免得漏掉某个入口) */
|
||
private emitUserChange(): void {
|
||
this.onUserChanged(this.bgKind, this.bgPresetId, this.bgDim, this.bgBlur);
|
||
}
|
||
|
||
/** 一处收口:就地显示 + 通知页面(两条路都给,换父组件也不会静默) */
|
||
private fail(reason: string): void {
|
||
this.uploadError = reason;
|
||
this.onUploadFailed(reason);
|
||
}
|
||
|
||
build() {
|
||
Column() {
|
||
Row() {
|
||
Text('背景').fontSize(Theme.fontBody).fontColor(Theme.textPrimary).layoutWeight(1)
|
||
Text(this.statusText).fontSize(Theme.fontTiny).fontColor(Theme.textSubtleFor())
|
||
}
|
||
.width('100%')
|
||
|
||
Row() {
|
||
ForEach(KIND_OPTIONS, (opt: KindOption) => {
|
||
Text(opt.label)
|
||
.fontSize(Theme.fontSmall)
|
||
.fontColor(this.bgKind === opt.value ? Theme.accentFg : Theme.textMuted)
|
||
.backgroundColor(this.bgKind === opt.value ? Theme.accent : Theme.surfaceMuted)
|
||
.borderRadius(Theme.radiusControl)
|
||
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
|
||
.margin({ right: 8 })
|
||
.onClick(() => { this.bgKind = opt.value; this.emitUserChange(); })
|
||
}, (opt: KindOption) => opt.value)
|
||
}
|
||
.width('100%').margin({ top: 10 })
|
||
|
||
if (this.bgKind === 'preset') {
|
||
Flex({ wrap: FlexWrap.Wrap }) {
|
||
ForEach(PRESET_IDS, (pid: string) => {
|
||
Text(presetLabel(pid))
|
||
.fontSize(Theme.fontTiny)
|
||
.fontColor(this.bgPresetId === pid ? Theme.accentFg : Theme.textMuted)
|
||
.backgroundColor(this.bgPresetId === pid ? Theme.accent : Theme.surfaceMuted)
|
||
.borderRadius(4)
|
||
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
|
||
.margin({ right: 6, top: 6 })
|
||
.onClick(() => { this.bgPresetId = normalizePreset(pid); this.emitUserChange(); })
|
||
}, (pid: string) => pid)
|
||
}
|
||
.width('100%').margin({ top: 8 })
|
||
}
|
||
|
||
if (this.bgKind === 'image') {
|
||
Column() {
|
||
Button(this.uploading ? '处理中…' : '选择图片并上传')
|
||
.height(38).fontSize(Theme.fontSmall)
|
||
.backgroundColor(Theme.accent).fontColor(Theme.accentFg)
|
||
.enabled(!this.uploading)
|
||
.onClick(() => { this.pickAndUpload(); })
|
||
|
||
if (this.progress.length > 0) {
|
||
Text(this.progress).fontSize(Theme.fontTiny).fontColor(Theme.textSubtleFor())
|
||
.width('100%').margin({ top: 6 })
|
||
}
|
||
if (this.uploadError.length > 0) {
|
||
Text(this.uploadError)
|
||
.fontSize(Theme.fontTiny).fontColor(Theme.dangerFor())
|
||
.width('100%').padding(8).margin({ top: 6 })
|
||
.backgroundColor(Theme.dangerBgFor()).borderRadius(Theme.radiusControl)
|
||
}
|
||
Text('上传前会先压到最长边 ' + MAX_EDGE + ' 像素(手机直出照片 4–8MB,服务端上限 4MB)。')
|
||
.fontSize(Theme.fontTiny).fontColor(Theme.textSubtleFor())
|
||
.width('100%').margin({ top: 6 })
|
||
}
|
||
.width('100%').alignItems(HorizontalAlign.Start).margin({ top: 8 })
|
||
}
|
||
|
||
if (this.bgKind !== 'none') {
|
||
this.DimSlider()
|
||
this.BlurSlider()
|
||
}
|
||
}
|
||
.width('100%').alignItems(HorizontalAlign.Start)
|
||
}
|
||
|
||
/**
|
||
* 浓度滑杆。
|
||
*
|
||
* ★ 成员名是 `bgDim`/`bgBlur` 这种领域名,**不叫** `opacity`:
|
||
* `@State opacity` 会与通用属性重名(ArkTS 那条"成员名不得与通用属性冲突")。
|
||
*/
|
||
@Builder
|
||
DimSlider() {
|
||
Column() {
|
||
Row() {
|
||
/*
|
||
* ★★ 2026-09-18 修两处(都是"看着就不对",只在设备上跑才看得见):
|
||
*
|
||
* ① 标签:原来是「浓度」,WebUI `BackgroundPicker.tsx:172` 写的是**「压暗」**
|
||
* (`label="压暗"`,`hint="背景越花,正文越需要一层遮罩才读得动"`)。
|
||
* "浓度"是个没主语的词,看不出在调什么;"压暗"直接说了这一层在干什么。
|
||
*
|
||
* ② 数值:原来 `Text('' + this.bgDim)` —— 屏上印的是 **`56.000000`**。
|
||
* 原因是这个值来自服务端(或本地缓存)的数值字段,是 number,
|
||
* 字符串拼接把浮点原样吐出来了。WebUI 的 `Slider` 带 `suffix="%"`,
|
||
* 显示成 `56%`。⇒ 取整 + 单位。
|
||
*
|
||
* ★ 单位要跟 WebUI 一样:压暗是**百分比**(0-80 就是 0%-80%),模糊是 **px**。
|
||
*/
|
||
Text('压暗').fontSize(Theme.fontTiny).fontColor(Theme.textMuted)
|
||
Blank()
|
||
Text(Math.round(this.bgDim) + '%').fontSize(Theme.fontTiny).fontColor(Theme.textSubtleFor())
|
||
}
|
||
.width('100%')
|
||
Slider({ value: this.bgDim, min: 0, max: 80, step: 1 })
|
||
.width('100%')
|
||
.onChange((v: number) => { this.bgDim = Math.round(v); this.emitUserChange(); })
|
||
}
|
||
.width('100%').margin({ top: 10 })
|
||
}
|
||
|
||
@Builder
|
||
BlurSlider() {
|
||
Column() {
|
||
Row() {
|
||
Text('模糊').fontSize(Theme.fontTiny).fontColor(Theme.textMuted)
|
||
Blank()
|
||
/* 同 ①:原先印 `3.000000`;WebUI 是 `suffix="px"` ⇒ `3px` */
|
||
Text(Math.round(this.bgBlur) + 'px').fontSize(Theme.fontTiny).fontColor(Theme.textSubtleFor())
|
||
}
|
||
.width('100%')
|
||
Slider({ value: this.bgBlur, min: 0, max: 40, step: 1 })
|
||
.width('100%')
|
||
.onChange((v: number) => { this.bgBlur = Math.round(v); this.emitUserChange(); })
|
||
}
|
||
.width('100%').margin({ top: 10 })
|
||
}
|
||
}
|
||
|
||
/* ── 模块级小工具(组件文件只导出 struct,所以这些都不导出) ── */
|
||
|
||
/** 异常 → 一句话。`ApiError.message` 已经是服务端中文文案(`ApiClient` 从 `{"error":…}` 取的)。 */
|
||
function businessMessage(e: Object): string {
|
||
const be = e as BusinessError;
|
||
if (be.message !== undefined && be.message.length > 0) {
|
||
return be.message;
|
||
}
|
||
return '未知原因';
|
||
}
|
||
|
||
/**
|
||
* 按给定质量压成 JPEG,返回内存字节。
|
||
*
|
||
* `quality` 是 **0~100 的整数**(SDK:`PackingOption.quality`,[0,100]),
|
||
* 而 `model/ImagePrep.ts` 里的质量是 0~1 的小数(照 WebUI 的 `toDataURL` 口径)——
|
||
* 换算只在这一处做。别在调用方各写一遍 `* 100`:两处一漂移
|
||
* (一边 85、一边 0.85)就会得到"压完比原图还大"这种看不懂的结果。
|
||
*/
|
||
async function packJpeg(pm: image.PixelMap, quality: number): Promise<ArrayBuffer> {
|
||
const packer: image.ImagePacker = image.createImagePacker();
|
||
try {
|
||
const opt: image.PackingOption = {
|
||
format: 'image/jpeg',
|
||
quality: Math.round(quality * 100)
|
||
};
|
||
/*
|
||
* ★★ 2026-09-21 修(编译器告警:`'packing' has been deprecated.`):
|
||
* `packer.packing(pm, opt)` → `packer.packToData(pm, opt)`。
|
||
*
|
||
* SDK 头文件的 `@useinstead` 直接给了这个目标:
|
||
* @since 6/8 @deprecated since 13 @useinstead image.ImagePacker#packToData
|
||
*
|
||
* 两者形状**逐字相同**(已比对 SDK 声明):
|
||
* packing(pm: PixelMap, options: PackingOption): Promise<ArrayBuffer>
|
||
* packToData(pm: PixelMap, options: PackingOption): Promise<ArrayBuffer>
|
||
* ⇒ 改名字就行,返回值/异常语义/调用方全部不变。
|
||
*/
|
||
return await packer.packToData(pm, opt);
|
||
} finally {
|
||
// ★ `release()` 也是 `Promise<void>`(SDK 两个重载:callback 版与 Promise 版)——
|
||
// 第 22 条判据把这一处抓出来了:不 await 的话它是一个"没人管的 promise",
|
||
// 而它出现在 `finally` 里,抛出的异常会替换掉原来的异常(真正的失败原因被吞掉)。
|
||
await packer.release();
|
||
}
|
||
}
|