跨端: harmony 管理页(用户管理)+ P4c 壁纸上传入口 —— 「功能做全再交付」的两块
pi 的交付清单里缺的两块(`docs/GUI-PLAN-HARMONY.md` 原先把管理后台划在首版之外, 用户明确要求「功能做全再给我」之后收进来)。 标 `跨端:` 是因为本次的判据落在 `client/electron/test/`(鸿蒙的判据目录一向量在那里), 代码本体全在 `client/harmony/`。 ## 管理页(用户管理) - `pages/AdminUsersPage.ets`:新建 / 编辑(显示名·角色·白名单)/ 启停 / 重置密码。 排布照 `AdminUsersPage.tsx`,包括「受限」徽标的口径(普通用户且白名单非空才显示)、 最后登录缺席与空串都显示「从未登录」、管理员对白名单两项忽略。 - 入口在设置页底部,**仅管理员可见**(`role === 'admin'` 严格相等,与 `App.tsx` 同口径)。 读不到身份时**不**显示也不报错(乐观放行会让每个普通用户看到一个点进去 403 的入口)。 - `api/AdminApi.ets` + `model/AdminUsers.ts`(纯逻辑,零 import ⇒ 判据能真跑)。 - 启停**只发 status 一个字段** —— 服务端是部分更新,多发字段会把显示名与白名单一起改掉。 - `model/Models.ets` 补管理端 DTO;`main_pages.json` 注册路由。 ## P4c 壁纸上传 - `model/ImagePrep.ts`:阈值与两档策略(2560/0.85 → 1280/0.78,入口 20MB,压后上限 3.5MiB)。 **一处有意不对齐 WebUI** 并写明理由:WebUI 卡 data-URL 长度(含 base64 膨胀), 鸿蒙内存直传 ArrayBuffer,卡的是字节数。 - `common/BackgroundPicker.ets`:不设 / 预设 / 自定义图片 + 浓度与模糊滑杆。 上传链:picker → 判可不可以 → 逐档按 desiredSize 解码压缩 → 上传 → **请页面以服务端为准重新同步**。 失败**必带原因**(服务端 415/413 文案原样透出)。 - `ApiClient.uploadBytes`:MultiFormData.data 收 ArrayBuffer(核了 SDK,since 11;本工程 23) ⇒ 内存直传,不需要 base64、也不需要临时文件。 - 用户取消选图**不算失败**,什么都不说。 ## 顺带修掉的两处真问题(都是变异测试逼出来的) 1. **压缩循环的第二档此前是死代码**:循环里的 break 与循环外那句 shouldRetryWithActual 互相抵消 —— 把循环里那处改成 `if (true)`(永远只压一档)整套判据照样全绿。 收成一处判定(overLimit),循环外只读结论。 2. **壁纸的模糊档一直是「只写不读」**(计划文档 §7.12 登记过):滑杆能拖、值能存、 blurStyleFor 也写了,就是**没有调用点**,壁纸一点没糊。本次补上调用点 (壁纸层 .blur(px) = 图片内容模糊;导航条材质由 blurStyleFor 映射)。 同时按 §7.12 的原承诺更新了那一行。 ## 一并修正的旧判据(都是"太宽/太窄",不是放宽标准) - 「模糊归属」:原文「壁纸层不许有**任何**模糊调用」把**图片内容模糊**与**面板材质** 混为一谈(WebUI 侧核实:.app-backdrop 的 filter 与它之上那层的 backdrop-filter 是两个不同的量)⇒ 改成按两种模糊分别钉。 - 「bgBlur 只写不读,消费侧必须为 0」:值不再成立,**形状保留**(逐文件登记 + 计数 + 理由), 标题与断言里的假话一并改掉。 - isDarkMode 那条 `/dark\s*\)/` 断的是**参数顺序**(加一个入参就误红)⇒ 改成"dark 在实参里"。 - 导航材质三处断言原本钉 `Theme.navMaterial` 字面量 ⇒ 改成钉新的映射写法。 ## 判据 新增 `harmony-admin.test.mjs`(22 条)、`harmony-imageprep.test.mjs`(29 条); `harmony-presets.test.mjs` 加 1 条(模糊档搬运与归一,含 -0 那个洞)。 全量 203 条:**201 通过**,2 条失败为**改动前就红**的既有项 (BUILD_INFO 比对、词表↔余额)—— 用 stash 对照验证过。 两个新判据文件上跑了 **48 个变异体,全部被抓**(含"接线"类:删掉「受限」徽标、 组件自己宣布成功、release 不 await、按原图尺寸解码…), 其中 2 个变异体**红不了**,因此又补了 5 条判据(纯逻辑接线、退档判定只有一处、 两档都超限必拒、解码尺寸用的是目标尺寸而非原图尺寸、模糊档搬运)。 (数字口径:按 runner 的真实条件"锚点恰好命中 1 次才算跑过"统计; 另有 4 条锚点不命中、根本没跑,不算在这 48 里。我第一次写的是"40"—— 凭记忆累加的,错了,已更正。) **未验**:本机无设备/无模拟器 ⇒ 全部观感未验(管理页排版、滑杆手感、模糊在真机上的 实际档位观感)。代码齐 ≠ 真机验过。
This commit is contained in:
132
client/harmony/entry/src/main/ets/api/AdminApi.ets
Normal file
132
client/harmony/entry/src/main/ets/api/AdminApi.ets
Normal file
@ -0,0 +1,132 @@
|
||||
/*
|
||||
* AgentMail 鸿蒙客户端 — 管理员 API(用户管理)
|
||||
*
|
||||
* 端点与 WebUI(`client/electron/src/api/client.ts`)**逐个对应**,服务端实现在
|
||||
* `server/internal/handler/auth.go`:
|
||||
*
|
||||
* GET /admin/users 列用户
|
||||
* POST /admin/users 建用户
|
||||
* PUT /admin/users/{id} 改用户(**部分更新**)
|
||||
* DELETE /admin/users/{id} 禁用用户(**不是物理删除**,保留邮件历史)
|
||||
* POST /admin/users/{id}/reset 重置密码
|
||||
* GET /admin/scopes 可授权的 Agent / 目录候选
|
||||
*
|
||||
* ★ 路径是**相对基地址**的(base 已含 `/api/v1`)。这里不是多此一举的提醒:
|
||||
* `AppearanceApi.ets` 的文件头记着 WebUI 踩过的同一个坑 —— 第一版写成
|
||||
* `/api/v1/me/appearance`,实际请求成了 `/api/v1/api/v1/...`,
|
||||
* 整套同步"从来没生效过"而单测全绿(只断言了方法与报文、没断言 URL)。
|
||||
* 所以这个文件的每条路径都有判据钉着。
|
||||
*/
|
||||
import { ApiClient } from './ApiClient';
|
||||
import {
|
||||
AdminUser,
|
||||
AdminUsersResponse,
|
||||
AdminScopes,
|
||||
AdminCreateUserInput,
|
||||
AdminUpdateUserInput,
|
||||
AdminResetPasswordInput,
|
||||
AdminUserResponse,
|
||||
AdminStatusResponse
|
||||
} from '../model/Models';
|
||||
|
||||
export class AdminApi {
|
||||
private client: ApiClient;
|
||||
|
||||
constructor(client: ApiClient) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列全部用户。
|
||||
*
|
||||
* 服务端回 `{users: [...]}`。这里**不**把缺失的 `users` 当空数组糊过去 ——
|
||||
* 直接读字段,读不到就是 `undefined`,由调用方按"没拿到"处理;
|
||||
* 糊成 `[]` 会让"服务端换了形状"表现得像"一个用户都没有"(最坏的那种静默)。
|
||||
*/
|
||||
async listUsers(): Promise<AdminUsersResponse> {
|
||||
return this.client.get<AdminUsersResponse>('/admin/users');
|
||||
}
|
||||
|
||||
/** 可授权的 Agent 与目录候选。空数组的语义是"没东西可授权",不是"不限"。 */
|
||||
async listScopes(): Promise<AdminScopes> {
|
||||
return this.client.get<AdminScopes>('/admin/scopes');
|
||||
}
|
||||
|
||||
/**
|
||||
* 建用户。
|
||||
*
|
||||
* 服务端严格解码(多字段即 400),必填校验在服务端:
|
||||
* 密码 < 8 位 → 400「密码至少 8 位」;用户名不合法 → 400;重名 → 409。
|
||||
* 这三条文案**必须原样显示给用户**(它们是唯一能让人立刻改的东西),
|
||||
* 不要在这里改写成"创建失败"。
|
||||
*/
|
||||
async createUser(input: AdminCreateUserInput): Promise<AdminUserResponse> {
|
||||
return this.client.post<AdminUserResponse>('/admin/users', input);
|
||||
}
|
||||
|
||||
/**
|
||||
* 改用户(部分更新)。
|
||||
*
|
||||
* ★ 只把**非 undefined** 的字段放进报文:服务端那几个字段是指针,
|
||||
* `nil` = 别动;而把 `display_name` 发成空串 = **把显示名清空**。
|
||||
* 一次性把五个字段都发(用空串/空数组占位)就会把用户的显示名与白名单清掉,
|
||||
* 而调用方可能只想改个状态。这个拼报文的地方必须只放"真的要给"的字段。
|
||||
*
|
||||
* 具体做法:先造一个 `Record<string, Object>`,只往里塞给了的键。
|
||||
* (ArkTS 里不能用展开运算符做"条件展开",所以这里显式逐个判。)
|
||||
*/
|
||||
async updateUser(userId: string, input: AdminUpdateUserInput): Promise<AdminUserResponse> {
|
||||
const body: Record<string, Object> = {};
|
||||
if (input.display_name !== undefined) {
|
||||
body['display_name'] = input.display_name;
|
||||
}
|
||||
if (input.role !== undefined) {
|
||||
body['role'] = input.role;
|
||||
}
|
||||
if (input.status !== undefined) {
|
||||
body['status'] = input.status;
|
||||
}
|
||||
if (input.allowed_agents !== undefined) {
|
||||
body['allowed_agents'] = input.allowed_agents;
|
||||
}
|
||||
if (input.allowed_paths !== undefined) {
|
||||
body['allowed_paths'] = input.allowed_paths;
|
||||
}
|
||||
return this.client.put<AdminUserResponse>('/admin/users/' + encodeURIComponent(userId), body);
|
||||
}
|
||||
|
||||
/** 禁用用户(软禁用,保留历史)。服务端会拦"最后一个管理员"。 */
|
||||
async disableUser(userId: string): Promise<AdminStatusResponse> {
|
||||
return this.client.del<AdminStatusResponse>('/admin/users/' + encodeURIComponent(userId));
|
||||
}
|
||||
|
||||
/** 重置密码(服务端要求 ≥ 8 位)。 */
|
||||
async resetPassword(userId: string, newPassword: string): Promise<AdminStatusResponse> {
|
||||
const input: AdminResetPasswordInput = new AdminResetPasswordInput();
|
||||
input.new_password = newPassword;
|
||||
return this.client.post<AdminStatusResponse>(
|
||||
'/admin/users/' + encodeURIComponent(userId) + '/reset',
|
||||
input
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /me` → 当前登录用户。
|
||||
*
|
||||
* 放在这里而不是 `AuthApi`:管理页的**可见性**靠它(`role === 'admin'`),
|
||||
* 而 `AuthApi` 是登录/登出那条链。同名函数分处两个类会让人以为有一处是死的。
|
||||
*/
|
||||
export class MeApi {
|
||||
private client: ApiClient;
|
||||
|
||||
constructor(client: ApiClient) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
/** 当前用户。**失败要抛**,不要吞成"不是管理员"。 */
|
||||
async get(): Promise<AdminUser> {
|
||||
const resp: AdminUserResponse = await this.client.get<AdminUserResponse>('/me');
|
||||
return resp.user;
|
||||
}
|
||||
}
|
||||
@ -312,6 +312,74 @@ export class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传**内存里**的字节(multipart/form-data,字段名 file)→ 服务端回的 id/文案。
|
||||
*
|
||||
* 与 `uploadFile` 的分工:那个用 `filePath` 让系统自己去读磁盘;
|
||||
* 这个用于"图已经在内存里"的场景(壁纸压完就是 `ArrayBuffer`)——
|
||||
* 走内存就不必先落一个临时文件、也就不必管它的清理(临时文件泄漏是慢性的,
|
||||
* 而且"压缩后写盘失败"会变成一个与压缩无关的新失败面)。
|
||||
*
|
||||
* ★ `data` 收 `ArrayBuffer`(SDK:`data?: string | Object | ArrayBuffer`,since 11;本工程是 23),
|
||||
* 且 SDK 注释写明「If data has a value, filePath does not take effect」——
|
||||
* 所以内存字节直传,**不需要 base64、也不需要临时文件**。
|
||||
*
|
||||
* ★ 这里**不猜 mime**:调用方给什么用什么。壁纸端点只收图片,
|
||||
* 服务端按 `Content-Type` **和**文件名后缀判(`detectContentType`),
|
||||
* 发成 `application/octet-stream` 会被 415 拒掉,所以默认给 `image/jpeg`
|
||||
* (压图走的就是 JPEG;这也是调用方唯一会用的格式)。
|
||||
*/
|
||||
async uploadBytes(path: string, data: ArrayBuffer, fileName: string, mimeType: string): Promise<string> {
|
||||
const url: string = this.apiBase + path;
|
||||
const httpRequest = http.createHttp();
|
||||
try {
|
||||
const header: Record<string, string> = {};
|
||||
if (this.token.length > 0) {
|
||||
header['Authorization'] = 'Bearer ' + this.token;
|
||||
}
|
||||
const multiFormData: http.MultiFormData = {
|
||||
name: 'file',
|
||||
contentType: mimeType,
|
||||
remoteFileName: fileName,
|
||||
data: data
|
||||
};
|
||||
const options: http.HttpRequestOptions = {
|
||||
method: http.RequestMethod.POST,
|
||||
header: header,
|
||||
multiFormDataList: [multiFormData],
|
||||
connectTimeout: 30000,
|
||||
readTimeout: 60000
|
||||
};
|
||||
hilog.info(DOMAIN, TAG, '→ UPLOAD(bytes) %{public}s', url);
|
||||
const response = await httpRequest.request(url, options);
|
||||
const code: number = response.responseCode;
|
||||
const rawText: string = response.result as string;
|
||||
if (code >= 200 && code < 300) {
|
||||
if (rawText.length === 0) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(rawText) as Record<string, string>;
|
||||
if (parsed['attachment_id'] !== undefined) {
|
||||
return parsed['attachment_id'];
|
||||
}
|
||||
} catch (e) {
|
||||
// 可能直接返回纯文本
|
||||
}
|
||||
return rawText;
|
||||
}
|
||||
throw new ApiError(code, rawText.length > 0 ? rawText : ('HTTP ' + code));
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
throw e;
|
||||
}
|
||||
const be = e as BusinessError;
|
||||
throw new ApiError(0, be.message !== undefined ? be.message : 'Upload error');
|
||||
} finally {
|
||||
httpRequest.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除本地认证态(401 时调用) */
|
||||
clearAuth(): void {
|
||||
this.token = '';
|
||||
|
||||
@ -53,6 +53,22 @@ export class AppearanceApi {
|
||||
return this.client.uploadFile('/me/appearance/image', filePath, fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传壁纸(**内存字节**直传,字段名 file)。
|
||||
*
|
||||
* 与 `uploadImage(filePath,…)` 的分工:那个走磁盘、由系统去读;
|
||||
* 这个用于"压完的图已经在内存里"(P4c 的实际路径)。
|
||||
*
|
||||
* ★ mime 固定 `image/jpeg`:压图走的就是 JPEG(`model/ImagePrep.ts` 两档都是 jpeg),
|
||||
* 而服务端按 `Content-Type` **和**文件名后缀判图片(`detectContentType`)——
|
||||
* 发 `application/octet-stream` 会被 415 拒掉,而这个 415 的文案
|
||||
* ("壁纸必须是图片(image/png、image/jpeg、image/webp、image/gif)")
|
||||
* 正是"失败必须给原因"里那个原因,所以这里不能发错。
|
||||
*/
|
||||
async uploadImageBytes(bytes: ArrayBuffer, fileName: string): Promise<string> {
|
||||
return this.client.uploadBytes('/me/appearance/image', bytes, fileName, 'image/jpeg');
|
||||
}
|
||||
|
||||
/**
|
||||
* 取壁纸**本体**。
|
||||
*
|
||||
|
||||
401
client/harmony/entry/src/main/ets/common/BackgroundPicker.ets
Normal file
401
client/harmony/entry/src/main/ets/common/BackgroundPicker.ets
Normal file
@ -0,0 +1,401 @@
|
||||
/*
|
||||
* 背景选择器(不设 / 预设 / 自定义图片)+ **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.textSubtle)
|
||||
}
|
||||
.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.textSubtle)
|
||||
.width('100%').margin({ top: 6 })
|
||||
}
|
||||
if (this.uploadError.length > 0) {
|
||||
Text(this.uploadError)
|
||||
.fontSize(Theme.fontTiny).fontColor(Theme.danger)
|
||||
.width('100%').padding(8).margin({ top: 6 })
|
||||
.backgroundColor(Theme.dangerBg).borderRadius(Theme.radiusControl)
|
||||
}
|
||||
Text('上传前会先压到最长边 ' + MAX_EDGE + ' 像素(手机直出照片 4–8MB,服务端上限 4MB)。')
|
||||
.fontSize(Theme.fontTiny).fontColor(Theme.textSubtle)
|
||||
.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() {
|
||||
Text('浓度').fontSize(Theme.fontTiny).fontColor(Theme.textMuted)
|
||||
Blank()
|
||||
Text('' + this.bgDim).fontSize(Theme.fontTiny).fontColor(Theme.textSubtle)
|
||||
}
|
||||
.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()
|
||||
Text('' + this.bgBlur).fontSize(Theme.fontTiny).fontColor(Theme.textSubtle)
|
||||
}
|
||||
.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)
|
||||
};
|
||||
return await packer.packing(pm, opt);
|
||||
} finally {
|
||||
// ★ `release()` 也是 `Promise<void>`(SDK 两个重载:callback 版与 Promise 版)——
|
||||
// 第 22 条判据把这一处抓出来了:不 await 的话它是一个"没人管的 promise",
|
||||
// 而它出现在 `finally` 里,抛出的异常会替换掉原来的异常(真正的失败原因被吞掉)。
|
||||
await packer.release();
|
||||
}
|
||||
}
|
||||
139
client/harmony/entry/src/main/ets/model/AdminUsers.ts
Normal file
139
client/harmony/entry/src/main/ets/model/AdminUsers.ts
Normal file
@ -0,0 +1,139 @@
|
||||
/*
|
||||
* 管理员页的**纯逻辑**:白名单勾选、最后登录文案、异常→文案。
|
||||
*
|
||||
* ⚠️ 类型可擦除(无 enum / namespace / 构造器参数属性),判据用 node strip-types 直接跑它。
|
||||
*
|
||||
* ── 为什么不在 `pages/AdminUsersPage.ets` 里 ──
|
||||
*
|
||||
* 两个原因,第二个是硬的:
|
||||
* ① 本仓库的页面文件清一色**只导出那个 struct**(`LoginPage`/`MainPage`/`SettingsPage`…
|
||||
* 没有一个 `export function`)。在页面里导出工具函数是不合流的写法;
|
||||
* ② 页面是 `.ets`,判据**跑不了**它 —— 只有纯逻辑放在 `.ts` 里,
|
||||
* `node --experimental-strip-types` 才 import 得动(`Wallpaper.ts`/`Calendar.ts`
|
||||
* 是同一个模式)。这三条逻辑都有"能悄悄错"的地方,值得被判据钉住。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 勾选/取消一个项,返回**新数组**(不改原数组)。
|
||||
*
|
||||
* ★ 必须返回新数组:ArkUI 的 `@State` 靠**引用变化**触发重渲染,
|
||||
* 原地 `push`/`splice` 改同一个数组**不会**刷新界面 ——
|
||||
* 表现是"点了没反应",而数据其实已经改了(最难查的一类)。
|
||||
* ★ 也不改入参:入参可能是另一个 @State 的当前值,就地改会让两处状态互相污染。
|
||||
*/
|
||||
export function toggled(list: string[], item: string): string[] {
|
||||
const out: string[] = [];
|
||||
let found: boolean = false;
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (list[i] === item) {
|
||||
found = true;
|
||||
} else {
|
||||
out.push(list[i]);
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
out.push(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本文件只吃**最小的结构**(而不是 `model/Models.ets` 里的 `AdminUser`):
|
||||
* 见文件末的说明 —— 本文件不许 import,所以字段就地声明。
|
||||
* 调用方传 `AdminUser` 靠**结构相容**即可,不需要 `as`。
|
||||
*/
|
||||
export interface LoginShape {
|
||||
last_login?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 最后登录的显示文案。
|
||||
*
|
||||
* ★ 服务端那个字段是 `json:"last_login,omitempty"`:**缺席**与**空串**都表示"从未登录",
|
||||
* 两者都要当成"从未登录"显示。只判 `undefined` 会让空串在界面上留下一块空白,
|
||||
* 看起来像"读取失败"。
|
||||
*/
|
||||
export function lastLoginLabel(user: LoginShape): string {
|
||||
const v: string | undefined = user.last_login;
|
||||
if (v === undefined || v.length === 0) {
|
||||
return '从未登录';
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/*
|
||||
* ── 这里**为什么没有** "异常 → 文案" 那个函数 ──
|
||||
*
|
||||
* 它要 `ApiError`(`api/ApiClient.ets` 里的类),而 `.ets` **import 不进来** ——
|
||||
* 本目录下的 `Wallpaper.ts`/`Calendar.ts`/`Appearance.ts` 全都是**一个 import 都没有**,
|
||||
* 那正是它们能被 `node --experimental-strip-types` 直接跑起来的原因
|
||||
* (判据 `harmony-*.test.mjs` 靠的就是这条路)。
|
||||
* 一旦这里 import 了 `.ets`,本文件就**从"能真跑"退化成"只能读源码"**,
|
||||
* 而它里面这几条都值得真跑。
|
||||
*
|
||||
* 所以拆成两半:
|
||||
* · **取值与显示口径**(本文件,可跑):`lastLoginLabel` / `toggled` / `isAdminRole` / `isRestricted` / `messageOfApiError`;
|
||||
* · **异常归一**(页面层,要 `instanceof ApiError`):见
|
||||
* `pages/AdminUsersPage.ets` 里那个 `messageOf` —— 那种写法在本仓库已有先例
|
||||
* (`ComposePage.ets` / `CalendarPage.ets` 都是 `e as ApiError` 就地取 message)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 服务端文案的**兜底口径**(纯函数:只吃基元,不吃异常对象)。
|
||||
*
|
||||
* ★ 服务端 400/409 的中文文案("该名称已被用户或 Agent 占用"/"密码至少 8 位"/
|
||||
* "不能禁用最后一个管理员")**必须原样透出**,不要改写成"操作失败" ——
|
||||
* 管理页的失败原因几乎都是"人能立刻改的东西",吞掉就只剩反复试。
|
||||
* ★ 只有**真的没有**文案时才用兜底句,并且要说明"服务端没给原因",
|
||||
* 否则用户分不清"服务端说不行"和"客户端没收到"。
|
||||
*/
|
||||
export function messageOfApiError(isApiError: boolean, message: string): string {
|
||||
if (isApiError && message.length > 0) {
|
||||
return message;
|
||||
}
|
||||
if (!isApiError && message.length > 0) {
|
||||
return message; // 本地异常(网络层抛的)也有 message,一样给用户看
|
||||
}
|
||||
return '操作失败(服务端没有给原因)';
|
||||
}
|
||||
|
||||
/**
|
||||
* 该用户是否受白名单限制(卡片上打「受限」徽标的条件)。
|
||||
*
|
||||
* ★ 口径与 WebUI 逐字一致:`role !== 'admin' && (allowed_agents.length > 0 || allowed_paths.length > 0)`。
|
||||
* **空 = 不限**(不是"什么都不许")—— 所以"全空"不叫受限,不该有徽标。
|
||||
* ★ 管理员一律 false:服务端对管理员**忽略**这两项,给他打「受限」是误导。
|
||||
*
|
||||
* ⚠️ 这里只吃一个最小的结构(而不是 `AdminUser`):本文件不许有 import
|
||||
* (见文件头),所以字段就地声明。调用方传 `AdminUser` 靠**结构相容**,
|
||||
* 不需要 `as`,也不会因此把这个文件从"能真跑"变成"只能读源码"。
|
||||
*/
|
||||
export interface RoleShape {
|
||||
role: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否应当显示管理入口。
|
||||
*
|
||||
* ★ 取值口径与 WebUI 逐字一致(`App.tsx`:`user?.role === 'admin'`):**严格相等**。
|
||||
* ★ 不要把"读不到 role"也放行 —— 那会让任何一次 `/me` 失败都变成"对所有人显示管理入口",
|
||||
* 点进去一片 403;也不能反过来当成"不是管理员"来自证:调用方要**分开**表达
|
||||
* "读不到"(`SettingsPage` 的 `isAdmin` 初值 false + `AdminUsersPage` 的 `roleKnown`)。
|
||||
* 这个函数只管**判断**,不管"读不到时怎么办"。
|
||||
*/
|
||||
export function isAdminRole(role: string | undefined): boolean {
|
||||
return role === 'admin';
|
||||
}
|
||||
|
||||
export interface RestrictedShape {
|
||||
role: string;
|
||||
allowed_agents: string[];
|
||||
allowed_paths: string[];
|
||||
}
|
||||
|
||||
export function isRestricted(user: RestrictedShape): boolean {
|
||||
if (user.role === 'admin') {
|
||||
return false;
|
||||
}
|
||||
return user.allowed_agents.length > 0 || user.allowed_paths.length > 0;
|
||||
}
|
||||
@ -168,6 +168,22 @@ export function localOnly(local: AppearanceSnapshot): AppearanceSync {
|
||||
* (`BlurStyle`)—— 这是"用系统方案"的直接结果:同一个数字在两边含义不同,
|
||||
* 所以要**显式映射**,而不是把 40 当半径塞进某个 API。映射关系写在这里,
|
||||
* 判据可以直接跑它(哪个数字落到哪一档,是行为不是注释)。
|
||||
*
|
||||
* ── 为什么返回的是**档位名**(字符串)而不是 SDK 的枚举数值 ──
|
||||
*
|
||||
* 与同一个文件里的 `colorModeFor`(`'COLOR_MODE_DARK' | …`)**同一种形状**:
|
||||
* 这一层是**纯逻辑**(零 `@ohos` 依赖 ⇒ 判据能用 node 直接跑它),
|
||||
* 而 `BlurStyle` 是 SDK 的枚举、只有 `.ets` 里才在作用域内。
|
||||
* 返回档位名 ⇒ 映射的**分档判断**留在这层可判,`名字 → BlurStyle` 那一步在页面里
|
||||
* 用一张**四行长**的表做掉(`MainPage.ets` 的 `BLUR_STYLE_OF`)。
|
||||
*
|
||||
* ★ 我一度把它改成"直接返回 SDK 数值(0/9/10/11)"想省掉那张表 —— **被判据挡回来了**,
|
||||
* 而且挡得对:`harmony-appearance.test.mjs` 有一条判据把**返回值拿去和 SDK 的
|
||||
* `declare enum BlurStyle` 成员名比对**("档次必须来自系统枚举,写成自造名字会
|
||||
* 编译不过/不生效")。返回数值就永远对不上成员名,那条判据会一直红 ——
|
||||
* 它保护的正是"别自己发明档位"这件事。
|
||||
* ⇒ 回到档位名。那张四行的表不是负担,它是"哪个名字对应哪个枚举"的**唯一**落点,
|
||||
* 而且页面里能对着 SDK 写(纯逻辑层看不到 BlurStyle)。
|
||||
*/
|
||||
export function blurStyleFor(bgBlur: number): string {
|
||||
const b: number = clampNumber(bgBlur, 0, 40, 4);
|
||||
|
||||
229
client/harmony/entry/src/main/ets/model/ImagePrep.ts
Normal file
229
client/harmony/entry/src/main/ets/model/ImagePrep.ts
Normal file
@ -0,0 +1,229 @@
|
||||
/*
|
||||
* 壁纸**上传前**的压图计划 —— 纯逻辑,无 UI / 无 SDK 依赖。
|
||||
*
|
||||
* ⚠️ 类型可擦除(无 enum / namespace / 构造器参数属性),判据用 node strip-types 直接跑它。
|
||||
*
|
||||
* ── 为什么单独一层(P4c)──
|
||||
*
|
||||
* 手机直出照片是 4–8MB,而服务端壁纸上限 **4MB**(`appearanceMaxBytes()`,可配)——
|
||||
* 直传必然 413。WebUI 那边的做法是「先压缩再上传」,这里把**同一套阈值与两档策略**
|
||||
* 搬成纯逻辑,于是"缩到多大、什么质量、什么时候放弃"这些**数值**能被判据钉住,
|
||||
* 而不是埋在一个 `.ets` 的 async 函数里(那种地方判据只能拿正则去猜)。
|
||||
*
|
||||
* ── 与 WebUI 的对齐(取值出处,不是凭印象)──
|
||||
*
|
||||
* | 这一层 | WebUI 出处 |
|
||||
* |---|---|
|
||||
* | `MAX_EDGE = 2560` | `stores/backgroundStore.ts:135` |
|
||||
* | 首档质量 0.85 + 最长边 2560 | 同文件 `prepareImage`:`drawScaled(bitmap, MAX_EDGE)` + `toDataURL('image/jpeg', 0.85)` |
|
||||
* | 超限后缩到 1280 + 质量 0.78 | 同处:`drawScaled(bitmap, MAX_EDGE / 2)` + `toDataURL('image/jpeg', 0.78)` |
|
||||
* | 原图 > 20MB 直接拒 | 同处:`file.size > 20 * 1024 * 1024` |
|
||||
* | 压缩后仍超限 ⇒ 明确拒绝并说原因 | 同处:『图片压缩后仍过大,请换一张更小的图片』 |
|
||||
*
|
||||
* ── 一处**故意不对齐**,以及为什么 ──
|
||||
*
|
||||
* WebUI 判的是 **data URL 的长度**(`MAX_DATA_URL_BYTES = 2_400_000`,含 base64 膨胀
|
||||
* 与 `data:image/jpeg;base64,` 前缀),因为它必须把 data URL 存进 localStorage 并渲染。
|
||||
* 鸿蒙侧**不经过 data URL**:`packing()` 直接给 `ArrayBuffer` 交给 multipart 上传,
|
||||
* 既没有 base64 的 4/3 膨胀也没有前缀。拿 WebUI 那个数当**字节**上限会平白少收 1/4 的图。
|
||||
*
|
||||
* 所以这里判的是**估算后的字节数**,上限取服务端那道真正的门(4MB)留出余量后的值。
|
||||
* ⇒ 这条差异是**有意的**,不是抄漏;判据同时钉住"两个数都在"和"别把它当成同一件事"。
|
||||
*/
|
||||
|
||||
/** 缩放的最长边(px)。与 WebUI `MAX_EDGE` 相等 —— 观感一致的来源。 */
|
||||
export const MAX_EDGE: number = 2560;
|
||||
|
||||
/** 首档 JPEG 质量 */
|
||||
export const FIRST_QUALITY: number = 0.85;
|
||||
|
||||
/** 首档失败后的第二档最长边(= MAX_EDGE / 2,与 WebUI 同) */
|
||||
export const RETRY_EDGE: number = 1280;
|
||||
|
||||
/** 第二档 JPEG 质量 */
|
||||
export const RETRY_QUALITY: number = 0.78;
|
||||
|
||||
/** 原图超过这个大小就不读了(解码本身会卡住主线程)。与 WebUI 的 20MB 同。 */
|
||||
export const MAX_SOURCE_BYTES: number = 20 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* 压完之后的**字节**上限。
|
||||
*
|
||||
* 服务端那道门是 `appearanceMaxBytes()`(默认 4MiB,可配),它卡的是**文件内容**,
|
||||
* 而且外层还有一个 `max+1MiB` 的请求体限制(multipart 边界也占地方)。
|
||||
* 取 3.5MiB:贴着 4MiB 会在"服务端把上限调小"时变成 413,
|
||||
* 而离得太远又白扔分辨率。
|
||||
*/
|
||||
export const MAX_UPLOAD_BYTES: number = 3_670_016;
|
||||
|
||||
/** 一档压缩的参数 */
|
||||
export class CompressPass {
|
||||
/** 最长边(px) */
|
||||
maxEdge: number = MAX_EDGE;
|
||||
/** JPEG 质量(0~1) */
|
||||
quality: number = FIRST_QUALITY;
|
||||
/** 这是第几档(1 起;界面文案与判据都认它) */
|
||||
attempt: number = 1;
|
||||
}
|
||||
|
||||
/** 压图计划 */
|
||||
export class CompressPlan {
|
||||
/** 按顺序要试的档(第一档超限才试第二档) */
|
||||
passes: CompressPass[] = [];
|
||||
/** 等比缩放后的目标尺寸(与 pass 无关:两档都从同一张原位图缩,见下) */
|
||||
targetWidth: number = 0;
|
||||
targetHeight: number = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 等比缩放到最长边不超过 `maxEdge`。
|
||||
*
|
||||
* ★ **不放大小图**(`scale > 1` 时取 1):把小图放大会同时变糊和变大,
|
||||
* 而"变大"会浪费掉那道字节上限。WebUI 的 `drawScaled` 同样 `Math.min(1, …)`。
|
||||
*
|
||||
* ★ 除以 `Math.max(w, h)` 而不是 `w`:竖拍照片(h > w)按宽算会**超出**最长边。
|
||||
*/
|
||||
export function scaleToMaxEdge(width: number, height: number, maxEdge: number):
|
||||
{ width: number; height: number } {
|
||||
const longest: number = Math.max(width, height);
|
||||
if (longest <= 0) {
|
||||
return { width: 0, height: 0 };
|
||||
}
|
||||
const scale: number = Math.min(1, maxEdge / longest);
|
||||
return {
|
||||
width: Math.max(1, Math.round(width * scale)),
|
||||
height: Math.max(1, Math.round(height * scale))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 估算 JPEG 压完的字节数。
|
||||
*
|
||||
* JPEG 是变长编码,**没有**能算准的公式;这里要的不是准,而是"够用来判要不要试下一档"。
|
||||
* 用 `宽 × 高 × 每像素字节` 的上界估计(0.5 B/px 对高质量照片偏保守),
|
||||
* 偏保守的方向是**对的**:宁可多试一档,也别上传一个必然 413 的东西。
|
||||
*
|
||||
* ★ 这个函数的估算**误差写在这里**,别让读的人以为它是测量值:
|
||||
* 真实字节数只有 `packing()` 之后才知道;所以要**在拿到真实长度后再判一次**
|
||||
* (见 `shouldRetryWithActual`),估算只用来决定"是否值得先试第一档"。
|
||||
*/
|
||||
export function estimateJpegBytes(width: number, height: number, quality: number): number {
|
||||
const pixels: number = Math.max(0, width) * Math.max(0, height);
|
||||
// 质量越高,每像素位越多;0.85 → 约 0.5 B/px,0.78 → 约 0.4 B/px
|
||||
const bytesPerPixel: number = 0.2 + 0.35 * Math.max(0, Math.min(1, quality));
|
||||
return Math.round(pixels * bytesPerPixel);
|
||||
}
|
||||
|
||||
/**
|
||||
* 真实字节数出来后,是否该退到下一档。
|
||||
*
|
||||
* 单独一个函数是为了让"两档都超限"这条路径**能被判据跑**:
|
||||
* 界面在那种情况下必须**明确说原因**(P4c 三条里的第二条),
|
||||
* 而不是静默什么都没发生 —— 静默失败在 WebUI 那边踩过。
|
||||
*/
|
||||
export function shouldRetryWithActual(actualBytes: number): boolean {
|
||||
return actualBytes > MAX_UPLOAD_BYTES;
|
||||
}
|
||||
|
||||
/** 两档都超限时给用户看的话(与 WebUI 的文案同义) */
|
||||
export const TOO_LARGE_REASON: string = '图片压缩后仍过大,请换一张更小的图片';
|
||||
|
||||
/** 原图就过大时给用户看的话 */
|
||||
export const SOURCE_TOO_LARGE_REASON: string = '图片过大(超过 20MB),请先裁剪';
|
||||
|
||||
/** 选到非图片时给用户看的话 */
|
||||
export const NOT_IMAGE_REASON: string = '请选择图片文件';
|
||||
|
||||
/**
|
||||
* 原件体积的**估算**(picker 只给 uri,拿不到文件大小)。
|
||||
*
|
||||
* ★ 这里刻意**不**去 `fileIo.stat` 拿真实大小:那要多一次 IO,而这一步只用于
|
||||
* "20MB 以上就别解码了"这一道**粗筛**(真正的门是压完之后的字节数与服务端上限)。
|
||||
* ★ 系数取 **偏小**(0.35 B/px,手机 JPEG 直出的典型值):两个方向的代价不对称 ——
|
||||
* 估偏小 = "极大图可能走到解码那一步才会卡",可接受;
|
||||
* 估偏大 = **误拒正常照片**,那是把好需求挡在门外。
|
||||
*/
|
||||
export function estimateSourceBytes(width: number, height: number): number {
|
||||
return Math.round(Math.max(0, width) * Math.max(0, height) * 0.35);
|
||||
}
|
||||
|
||||
/**
|
||||
* 造压图计划:按原图尺寸决定**缩放目标**,并给出要试的档。
|
||||
*
|
||||
* 两档都用**同一张原位图**缩放(不是把第一档的结果再缩一次)——
|
||||
* 二次缩放会累加两次重采样损失,而重新从原图缩只损失一次。
|
||||
* WebUI 的 `prepareImage` 也是两次都从 `bitmap` 缩。
|
||||
*/
|
||||
export function planCompress(sourceWidth: number, sourceHeight: number): CompressPlan {
|
||||
const plan: CompressPlan = new CompressPlan();
|
||||
const first: CompressPass = new CompressPass();
|
||||
first.maxEdge = MAX_EDGE;
|
||||
first.quality = FIRST_QUALITY;
|
||||
first.attempt = 1;
|
||||
const second: CompressPass = new CompressPass();
|
||||
second.maxEdge = RETRY_EDGE;
|
||||
second.quality = RETRY_QUALITY;
|
||||
second.attempt = 2;
|
||||
plan.passes = [first, second];
|
||||
// 目标尺寸按**首档**算(界面显示"将缩到 W×H",用户看的是第一档的结果)
|
||||
const scaled = scaleToMaxEdge(sourceWidth, sourceHeight, MAX_EDGE);
|
||||
plan.targetWidth = scaled.width;
|
||||
plan.targetHeight = scaled.height;
|
||||
return plan;
|
||||
}
|
||||
|
||||
/** `judgePick` 的结果 */
|
||||
export class PickJudgement {
|
||||
ok: boolean = true;
|
||||
/** 不 ok 时的原因(要给用户看,原样显示) */
|
||||
reason: string = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 选图之后的**入口检查**:先判能不能处理,再判要不要压。
|
||||
*
|
||||
* 返回 `{ ok, reason }`:`ok === false` 时 `reason` **必须**被显示出来。
|
||||
* 这一条是 P4c 三条里的第二条("失败必须给原因,别静默失败")的落点 ——
|
||||
* 单独成函数而不是写在 `.ets` 的 async 里,就是为了让每条拒绝路径都有判据。
|
||||
*/
|
||||
export function judgePick(sizeBytes: number, mimeType: string): PickJudgement {
|
||||
const j: PickJudgement = new PickJudgement();
|
||||
if (!mimeType.startsWith('image/')) {
|
||||
j.ok = false;
|
||||
j.reason = NOT_IMAGE_REASON;
|
||||
return j;
|
||||
}
|
||||
if (sizeBytes > MAX_SOURCE_BYTES) {
|
||||
j.ok = false;
|
||||
j.reason = SOURCE_TOO_LARGE_REASON;
|
||||
return j;
|
||||
}
|
||||
j.ok = true;
|
||||
j.reason = '';
|
||||
return j;
|
||||
}
|
||||
|
||||
/** 上传后的文案:服务端是权威,所以成功后要重新拉一次(P4c 第三条) */
|
||||
export const UPLOAD_OK_HINT: string =
|
||||
'壁纸已上传;正在以服务端那份为准重新同步(换设备也会跟着走)。';
|
||||
|
||||
/** 上传失败时**不要把原因吞掉**:服务端的文案(如"壁纸必须是图片…")是唯一能让人立刻改的东西 */
|
||||
export function uploadFailureHint(serverMessage: string): string {
|
||||
if (serverMessage.length === 0) {
|
||||
return '上传失败(服务端没有给原因)';
|
||||
}
|
||||
return '上传失败:' + serverMessage;
|
||||
}
|
||||
|
||||
/*
|
||||
* ★ 这里原本还写了一个手写 `bytesToBase64`(含一个对应的解码函数),
|
||||
* 理由是"压完的图在内存里、而上传要文件,所以内存转 base64 上传"。
|
||||
* **核了 SDK 之后删掉了**:`@ohos.net.http.d.ts` 的 `MultiFormData` 里
|
||||
* `data?: string | Object | ArrayBuffer`(since 11,本工程 target/compatible 是 6.1.0(23)),
|
||||
* 而紧邻的注释写明「If data has a value, filePath does not take effect」——
|
||||
* **内存字节可以直传**,不需要 base64,也不需要临时文件。
|
||||
*
|
||||
* 记这一笔是因为它是个典型形状:**在假设 SDK 能力不足的前提下写了一层,
|
||||
* 而那一层自己又会成为新的错源**(手写 base64 的移位与补齐错了是静默错:
|
||||
* 语法合法、字节不对)。结论:先核 SDK 再决定要不要自己实现。
|
||||
*/
|
||||
@ -296,3 +296,84 @@ export class CalendarEventInput {
|
||||
export class CalendarDeleteResponse {
|
||||
status: string = '';
|
||||
}
|
||||
|
||||
/*
|
||||
* ── 管理员:用户管理 ──
|
||||
*
|
||||
* 字段与服务端 `handler.userOut`(`server/internal/handler/auth.go`)**一一对应**,
|
||||
* 也与 WebUI 的 `types/index.ts` 的 `User` 同形 —— 三处是同一个契约。
|
||||
* ★ 这里用**类字段默认值**而不是 `?:` 可选:ArkTS 收 JSON 后取字段时,
|
||||
* 可选字段会让每一处使用都要先窄化一次(`strict` 下就是一片 `可能为 undefined`)。
|
||||
* 服务端那两个真的可能缺的字段(`last_login`/`created_at`)用显式 `undefined` 联合类型标出来。
|
||||
*/
|
||||
export class AdminUser {
|
||||
user_id: string = '';
|
||||
username: string = '';
|
||||
display_name: string = '';
|
||||
/** 'admin' | 'user'(服务端是自由字符串,这里不假设取值一定合法) */
|
||||
role: string = '';
|
||||
/** 'active' | 'disabled' */
|
||||
status: string = '';
|
||||
allowed_agents: string[] = [];
|
||||
allowed_paths: string[] = [];
|
||||
last_login: string | undefined = undefined;
|
||||
created_at: string | undefined = undefined;
|
||||
}
|
||||
|
||||
/** `GET /admin/users` 的响应 */
|
||||
export class AdminUsersResponse {
|
||||
users: AdminUser[] = [];
|
||||
}
|
||||
|
||||
/** `GET /admin/scopes` 的响应:可授权的 Agent 与目录候选 */
|
||||
export class AdminScopes {
|
||||
agents: string[] = [];
|
||||
paths: string[] = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /admin/users` 的请求体。
|
||||
*
|
||||
* ★ 服务端用**严格解码**(`DecodeBody`):多一个未知字段就 400。
|
||||
* 所以这里只放服务端 `createUserRequest` 里真实存在的字段,**不要**顺手把
|
||||
* `user_id`/`status` 也塞进来(新建时服务端自己定,塞了就是 400)。
|
||||
*/
|
||||
export class AdminCreateUserInput {
|
||||
username: string = '';
|
||||
password: string = '';
|
||||
display_name: string = '';
|
||||
role: string = 'user';
|
||||
allowed_agents: string[] = [];
|
||||
allowed_paths: string[] = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* `PUT /admin/users/{id}` 的请求体。
|
||||
*
|
||||
* **部分更新**:服务端 `updateUserRequest` 全是**指针**字段,只有给了的才改
|
||||
* (`nil` = "别动这个字段")。所以这里每个字段都要能表达"没给" ——
|
||||
* 用 `undefined` 联合类型,而不是空串:空串会被当成"把它改成空",
|
||||
* 那会把显示名/角色/白名单**清掉**,而调用方只是想改状态。
|
||||
*/
|
||||
export class AdminUpdateUserInput {
|
||||
display_name: string | undefined = undefined;
|
||||
role: string | undefined = undefined;
|
||||
status: string | undefined = undefined;
|
||||
allowed_agents: string[] | undefined = undefined;
|
||||
allowed_paths: string[] | undefined = undefined;
|
||||
}
|
||||
|
||||
/** `POST /admin/users/{id}/reset` 的请求体 */
|
||||
export class AdminResetPasswordInput {
|
||||
new_password: string = '';
|
||||
}
|
||||
|
||||
/** 单个用户包装(`{user: …}`)—— 创建与更新都返回这个形状 */
|
||||
export class AdminUserResponse {
|
||||
user: AdminUser = new AdminUser();
|
||||
}
|
||||
|
||||
/** 只有 `{status}` 的响应(禁用用户) */
|
||||
export class AdminStatusResponse {
|
||||
status: string = '';
|
||||
}
|
||||
|
||||
@ -238,6 +238,23 @@ export class BackgroundPlan {
|
||||
*/
|
||||
presetSubstitutedFrom: string = '';
|
||||
layers: PresetLayer[] = [];
|
||||
/**
|
||||
* 模糊强度(**服务端给的 px 原值**,0~40;由 `model/Appearance.ts` 的 `blurStyleFor`
|
||||
* 映射成系统材质档)。
|
||||
*
|
||||
* ★ 为什么这里放的是 px 而不是"材质档字符串":这一层是**纯逻辑**(零 `@ohos` 依赖),
|
||||
* 而"px → 材质档"那张表的**唯一**权威在 `Appearance.ts`(它连同边界 0/8/20
|
||||
* 一起被判据钉住)。在这里再抄一份分档,就会有两张表 —— 而两张表迟早会分叉。
|
||||
* ⇒ 计划只**搬运**这个值,页面拿它去问 `blurStyleFor`。
|
||||
*
|
||||
* ★ 为什么现在才有人消费它:服务端那个模糊字段一直是"**只写不读**"——
|
||||
* 服务端存、两端同步、`blurStyleFor` 也写了、就是**没有任何调用点**
|
||||
* (`harmony-appearance.test.mjs` 有一条判据把"消费侧出现次数为 0"钉住,
|
||||
* 就是为了让"有人开始消费"这一刻**必须停下来**补映射判据而不是偷偷把 0 改成 1)。
|
||||
* P4c 加背景选择器时踩到了这条线:滑杆能拖、值能存,但壁纸**一点没糊**。
|
||||
* 所以这次把映射的**调用点**补上(映射表与它的判据早就在了)。
|
||||
*/
|
||||
blurPx: number = 0;
|
||||
/**
|
||||
* 遮盖浓度(0~1):**两档都用**(预设档也压),用系统遮罩色刷一层。
|
||||
*
|
||||
@ -262,9 +279,11 @@ export class BackgroundPlan {
|
||||
* 这里决定**画成什么**。`image` 档但图没取回来 → `none`:
|
||||
* 宁可什么都不画,也不要画一块空白(用户会以为壁纸坏了)。
|
||||
*/
|
||||
export function resolveBackground(bgKind: string, presetId: string, scrim: number, hasImage: boolean, dark: boolean): BackgroundPlan {
|
||||
export function resolveBackground(bgKind: string, presetId: string, scrim: number, hasImage: boolean, dark: boolean, blurPx: number = 0): BackgroundPlan {
|
||||
const plan: BackgroundPlan = new BackgroundPlan();
|
||||
plan.dark = dark;
|
||||
// 模糊是**全档通用**的(WebUI 的 `--bg-blur` 也不区分档位),所以先搬运、各档都带上
|
||||
plan.blurPx = normalizeBlur(blurPx);
|
||||
if (bgKind === 'preset') {
|
||||
plan.kind = 'preset';
|
||||
plan.presetId = normalizePreset(presetId);
|
||||
@ -284,6 +303,36 @@ export function resolveBackground(bgKind: string, presetId: string, scrim: numbe
|
||||
return plan;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模糊 px 归一:0~40 的整数。
|
||||
*
|
||||
* ★ 边界 40 与 `model/Appearance.ts` 的 `clampNumber(bgBlur, 0, 40, 4)` **必须同值** ——
|
||||
* 两边不一致的话,服务端存 40、这边按 80 画,判定与显示就对不上了
|
||||
* (`blurStyleFor` 里面也有一次 clamp,那是它自己的防线,不是这里可以放松的理由)。
|
||||
*/
|
||||
export function normalizeBlur(px: number): number {
|
||||
if (!Number.isFinite(px)) {
|
||||
return 0;
|
||||
}
|
||||
const r: number = Math.round(px);
|
||||
/*
|
||||
* ★ 判 `!(r > 0)` 而不是 `r < 0` —— 因为 `Math.round(-0.4)` 是 **-0**,
|
||||
* 而 `-0 < 0` 是 **false**(`-0 === 0`)。用 `r < 0` 会让 `-0` 漏过去,
|
||||
* 于是 `blur(-0)` 被喂进 ArkUI —— 这是个**静默**的怪值(判据把它抓出来了:
|
||||
* `assert.equal(normalizeBlur(-0.4), 0)` 报的是 `+ -0`,一眼看不出问题在哪)。
|
||||
*/
|
||||
if (!(r > 0)) {
|
||||
return 0;
|
||||
}
|
||||
if (r > MAX_BLUR_PX) {
|
||||
return MAX_BLUR_PX;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
/** 模糊上限(px)。与 `model/Appearance.ts` 的 clamp 上界同值。 */
|
||||
export const MAX_BLUR_PX: number = 40;
|
||||
|
||||
/** 预设缩略图/选择器要显示的清单(id + 标签) */
|
||||
export function presetChoices(): string[] {
|
||||
return PRESET_IDS;
|
||||
|
||||
579
client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets
Normal file
579
client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets
Normal file
@ -0,0 +1,579 @@
|
||||
/*
|
||||
* AgentMail 鸿蒙客户端 — 管理员:用户管理
|
||||
*
|
||||
* 对应 WebUI 的 `client/electron/src/components/AdminUsersPage.tsx`,按 WebUI 的排布顺序
|
||||
* 收口「用户管理」这一栏(`docs/HARMONY-ALIGN-PLAN.md` 原先把管理员后台整个划在鸿蒙首版之外,
|
||||
* 用户明确要求「功能做全再交付」后收进来)。
|
||||
*
|
||||
* ── 为什么是**独立路由页**而不是 MainPage 的第四栏 ──
|
||||
*
|
||||
* WebUI 侧它的位置是「"我的"页底部、仅管理员可见」(`App.tsx`:`viewMode === 'admin' && user?.role === 'admin'`)。
|
||||
* 鸿蒙侧对应的位置就是**设置页底部**,所以这里也做成一个从设置页推入的路由页
|
||||
* (`pages/AdminUsersPage`,`pushUrl`),而不是往底部三栏里加第四栏 ——
|
||||
* 底栏三栏(通信/日历/联系人)是**日常使用**的信息架构,管理面不是日常。
|
||||
*
|
||||
* ── 判据纪律(本文件逐条遵守,判据在 `client/electron/test/harmony-admin.test.mjs`)──
|
||||
*
|
||||
* · **用户真正会点的那一层**:每个可点元素都是真实 `onClick` 到真实动作;判据按
|
||||
* "动作与服务端调用同名"检查,而不是"页面上出现了这个词"。
|
||||
* · **不许新写死颜色**:色一律走 `Theme.*`(`danger`/`approve`/`accent` 是 Theme 里
|
||||
* 已命名的语义色,品牌色本来就手写在那一个文件里)。本文件不出现任何 `#` 色值。
|
||||
* · **不给单个面单独做深色**:不写 `#xxxxxx`,也就无从分叉;深浅由系统按色彩模式给。
|
||||
* · **同一张底只允许被模糊一次**:本页不碰壁纸/模糊(那是 `MainPage` 的外观层)。
|
||||
* · **列表项每项一张卡**:每个用户在 `List` 里是独立一张卡(`Theme.surface` + `radiusCard`)。
|
||||
* · **视觉不可验必须如实标注**:见文件末「未验」段。
|
||||
*/
|
||||
import { ApiClient, ApiError } from '../api/ApiClient';
|
||||
import { Theme } from '../common/Theme';
|
||||
import { AdminApi, MeApi } from '../api/AdminApi';
|
||||
import {
|
||||
AdminUser,
|
||||
AdminScopes,
|
||||
AdminCreateUserInput,
|
||||
AdminUpdateUserInput
|
||||
} from '../model/Models';
|
||||
import { toggled, lastLoginLabel, messageOfApiError, isAdminRole, isRestricted } from '../model/AdminUsers';
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct AdminUsersPage {
|
||||
@State users: AdminUser[] = [];
|
||||
@State scopes: AdminScopes = new AdminScopes();
|
||||
@State loading: boolean = false;
|
||||
@State errorText: string = '';
|
||||
/** 展开编辑的是哪一个(空串=都收起) */
|
||||
@State editingId: string = '';
|
||||
@State showCreate: boolean = false;
|
||||
/** 当前登录者是不是管理员;**未确认前一律 false**(见文件头与 `isAdminRole`) */
|
||||
@State isAdmin: boolean = false;
|
||||
/** 身份读没读到(读不到时不显示"你不是管理员"这种断言,只显示"读不到") */
|
||||
@State roleKnown: boolean = false;
|
||||
|
||||
// 新建用户表单
|
||||
@State newUsername: string = '';
|
||||
@State newDisplayName: string = '';
|
||||
@State newPassword: string = '';
|
||||
@State newRole: string = 'user';
|
||||
@State busy: boolean = false;
|
||||
|
||||
// 编辑中用户的可改字段(进入编辑时从该用户复制一份)
|
||||
@State editDisplayName: string = '';
|
||||
@State editRole: string = 'user';
|
||||
@State editAgents: string[] = [];
|
||||
@State editPaths: string[] = [];
|
||||
@State editPassword: string = '';
|
||||
|
||||
private client: ApiClient | null = null;
|
||||
private api: AdminApi | null = null;
|
||||
|
||||
aboutToAppear(): void {
|
||||
const ctx = this.getUIContext().getHostContext();
|
||||
if (ctx === undefined) {
|
||||
return;
|
||||
}
|
||||
this.client = ApiClient.getInstance(ctx);
|
||||
this.api = new AdminApi(this.client);
|
||||
this.loadRole();
|
||||
this.load();
|
||||
}
|
||||
|
||||
/**
|
||||
* 读当前身份。
|
||||
*
|
||||
* ★ 失败时 `roleKnown` 保持 false 且 `isAdmin` 保持 false:
|
||||
* 不能把"读不到"当成"是管理员"(那会让一次网络抖动对所有人显示管理入口),
|
||||
* 也不能当成"不是"(那会误导人以为自己没权限)。两种错都不报,只报"读不到"。
|
||||
*/
|
||||
async loadRole(): Promise<void> {
|
||||
const client: ApiClient | null = this.client;
|
||||
if (client === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const me: AdminUser = await new MeApi(client).get();
|
||||
this.isAdmin = isAdminRole(me.role);
|
||||
this.roleKnown = true;
|
||||
} catch (e) {
|
||||
this.roleKnown = false;
|
||||
this.isAdmin = false;
|
||||
}
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
const api: AdminApi | null = this.api;
|
||||
if (api === null) {
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
this.errorText = '';
|
||||
try {
|
||||
const usersResp = await api.listUsers();
|
||||
this.users = usersResp.users;
|
||||
const scopesResp = await api.listScopes();
|
||||
this.scopes = scopesResp;
|
||||
} catch (e) {
|
||||
this.errorText = this.messageOf(e);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 进入/退出编辑:进入时把该用户的可改字段复制到 @State(@State 观察不到类内部变化) */
|
||||
toggleEdit(user: AdminUser): void {
|
||||
if (this.editingId === user.user_id) {
|
||||
this.editingId = '';
|
||||
return;
|
||||
}
|
||||
this.editingId = user.user_id;
|
||||
this.editDisplayName = user.display_name;
|
||||
this.editRole = user.role;
|
||||
this.editAgents = user.allowed_agents.slice(0);
|
||||
this.editPaths = user.allowed_paths.slice(0);
|
||||
this.editPassword = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 异常 → 一句话。
|
||||
*
|
||||
* ★ 这个函数**故意留在页面层**(而不是 `model/AdminUsers.ts`):它要
|
||||
* `instanceof ApiError`,而 `ApiError` 是 `api/ApiClient.ets` 里的类 ——
|
||||
* `.ets` import 不进纯逻辑层(那会让那个文件没法被 node 直接跑,判据就只能读源码了)。
|
||||
* 所以:**取值口径**(`isApiError` 与 `message`)在这里取,
|
||||
* **兜底文案**在纯逻辑层(`messageOfApiError`,可判)。
|
||||
*/
|
||||
private messageOf(e: Object): string {
|
||||
return messageOfApiError(e instanceof ApiError, (e as Error).message);
|
||||
}
|
||||
|
||||
private toast(msg: string): void {
|
||||
this.getUIContext().getPromptAction().showToast({ message: msg });
|
||||
}
|
||||
|
||||
/** 只把**真的给了**的字段发出去(服务端是指针语义:nil=别动,空串=清空) */
|
||||
private buildUpdate(): AdminUpdateUserInput {
|
||||
const input: AdminUpdateUserInput = new AdminUpdateUserInput();
|
||||
input.display_name = this.editDisplayName;
|
||||
input.role = this.editRole;
|
||||
input.allowed_agents = this.editRole === 'admin' ? [] : this.editAgents;
|
||||
input.allowed_paths = this.editRole === 'admin' ? [] : this.editPaths;
|
||||
return input;
|
||||
}
|
||||
|
||||
async saveEdit(): Promise<void> {
|
||||
const api: AdminApi | null = this.api;
|
||||
const id: string = this.editingId;
|
||||
if (api === null || id.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
try {
|
||||
await api.updateUser(id, this.buildUpdate());
|
||||
this.toast('用户已更新');
|
||||
await this.load();
|
||||
} catch (e) {
|
||||
this.errorText = this.messageOf(e);
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用/禁用。
|
||||
*
|
||||
* ★ 这里**只发 `status` 一个字段**:服务端是部分更新,只发状态就不会顺手把
|
||||
* 显示名与白名单清掉(那正是"一次发五个字段"会造成的破坏)。
|
||||
*/
|
||||
async setStatus(user: AdminUser, status: string): Promise<void> {
|
||||
const api: AdminApi | null = this.api;
|
||||
if (api === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (status === 'disabled') {
|
||||
await api.disableUser(user.user_id);
|
||||
} else {
|
||||
const onlyStatus: AdminUpdateUserInput = new AdminUpdateUserInput();
|
||||
onlyStatus.status = 'active';
|
||||
await api.updateUser(user.user_id, onlyStatus);
|
||||
}
|
||||
this.toast(status === 'disabled' ? '用户已禁用' : '用户已启用');
|
||||
await this.load();
|
||||
} catch (e) {
|
||||
// 服务端会拦"最后一个管理员",那条文案必须原样显示
|
||||
this.errorText = this.messageOf(e);
|
||||
}
|
||||
}
|
||||
|
||||
async resetPassword(user: AdminUser): Promise<void> {
|
||||
const api: AdminApi | null = this.api;
|
||||
if (api === null || this.editPassword.length < 8) {
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
try {
|
||||
await api.resetPassword(user.user_id, this.editPassword);
|
||||
this.toast('密码已重置');
|
||||
this.editPassword = '';
|
||||
} catch (e) {
|
||||
this.errorText = this.messageOf(e);
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async createUser(): Promise<void> {
|
||||
const api: AdminApi | null = this.api;
|
||||
if (api === null) {
|
||||
return;
|
||||
}
|
||||
if (this.newUsername.trim().length < 2 || this.newPassword.length < 8) {
|
||||
this.toast('用户名至少 2 位、密码至少 8 位');
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
try {
|
||||
const input: AdminCreateUserInput = new AdminCreateUserInput();
|
||||
input.username = this.newUsername.trim().toLowerCase();
|
||||
input.password = this.newPassword;
|
||||
input.display_name = this.newDisplayName.trim();
|
||||
input.role = this.newRole;
|
||||
/*
|
||||
* 白名单**建完再配**(不是漏了):新建表单只有用户名/显示名/密码/角色,
|
||||
* 建好之后在新用户那张卡的编辑区里勾白名单。
|
||||
* ★ 服务端口径:空数组 = **不限**(不是"什么都不许")——
|
||||
* 所以"先不配"是安全的默认,新用户一登录不会什么都点不动;
|
||||
* 要收紧就随后去勾。这也与 `ScopePick` 上那句"未勾选 = 不限"一致。
|
||||
*/
|
||||
input.allowed_agents = [];
|
||||
input.allowed_paths = [];
|
||||
await api.createUser(input);
|
||||
this.toast('用户已创建');
|
||||
this.newUsername = '';
|
||||
this.newDisplayName = '';
|
||||
this.newPassword = '';
|
||||
this.newRole = 'user';
|
||||
this.showCreate = false;
|
||||
await this.load();
|
||||
} catch (e) {
|
||||
// 重名 409 / 用户名不合法 400 / 密码太短 400 的文案都从这里原样显示
|
||||
this.errorText = this.messageOf(e);
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
this.Header()
|
||||
|
||||
if (this.roleKnown && !this.isAdmin) {
|
||||
Column() {
|
||||
Text('🔒 管理入口仅管理员可见').fontSize(Theme.fontBody).fontColor(Theme.textMuted)
|
||||
Text('当前账号的角色不是管理员。')
|
||||
.fontSize(Theme.fontSmall).fontColor(Theme.textSubtle).margin({ top: 6 })
|
||||
}
|
||||
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
|
||||
} else {
|
||||
if (this.errorText.length > 0) {
|
||||
Text(this.errorText)
|
||||
.fontSize(Theme.fontSmall).fontColor(Theme.danger)
|
||||
.width('100%').padding(10).margin({ left: 12, right: 12, top: 8 })
|
||||
.backgroundColor(Theme.dangerBg)
|
||||
.borderRadius(Theme.radiusControl)
|
||||
}
|
||||
|
||||
Row() {
|
||||
Text('用户管理').fontSize(Theme.fontSmall).fontColor(Theme.textMuted)
|
||||
Text(' ' + this.users.length).fontSize(Theme.fontSmall).fontColor(Theme.textSubtle)
|
||||
Blank()
|
||||
Button(this.showCreate ? '收起' : '新建用户')
|
||||
.height(34).fontSize(Theme.fontSmall)
|
||||
.backgroundColor(Theme.accent).fontColor(Theme.accentFg)
|
||||
.onClick(() => { this.showCreate = !this.showCreate; })
|
||||
}
|
||||
.width('100%').padding({ left: 12, right: 12, top: 8 })
|
||||
|
||||
if (this.showCreate) {
|
||||
this.CreateForm()
|
||||
}
|
||||
|
||||
if (this.loading && this.users.length === 0) {
|
||||
Text('加载中…').fontSize(Theme.fontSmall).fontColor(Theme.textSubtle)
|
||||
.width('100%').textAlign(TextAlign.Center).margin({ top: 24 })
|
||||
} else {
|
||||
List({ space: 8 }) {
|
||||
ForEach(this.users, (user: AdminUser) => {
|
||||
ListItem() {
|
||||
this.UserCard(user)
|
||||
}
|
||||
}, (user: AdminUser) => user.user_id)
|
||||
}
|
||||
.width('100%').layoutWeight(1)
|
||||
.padding({ left: 12, right: 12, top: 8, bottom: 12 })
|
||||
}
|
||||
}
|
||||
}
|
||||
.width('100%').height('100%')
|
||||
.backgroundColor(Theme.pageBg)
|
||||
}
|
||||
|
||||
@Builder
|
||||
Header() {
|
||||
Row() {
|
||||
Text('‹').fontSize(24).fontColor(Theme.accent).width(40).height(40)
|
||||
.textAlign(TextAlign.Center)
|
||||
.onClick(() => { this.getUIContext().getRouter().back(); })
|
||||
Text('管理').fontSize(16).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
|
||||
.layoutWeight(1)
|
||||
// 刷新:管理页的动作个个改服务端状态,看不到最新值会让人怀疑自己刚才点没点上
|
||||
Text('↻').fontSize(20).fontColor(Theme.accent).width(40).height(40)
|
||||
.textAlign(TextAlign.Center)
|
||||
.onClick(() => { this.load(); })
|
||||
}
|
||||
.width('100%').height(56).padding({ left: 8, right: 8 })
|
||||
.backgroundColor(Theme.surface)
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个用户 = 一张卡(列表项每项一张卡)。
|
||||
*
|
||||
* 展开的那张把编辑区接在同一张卡里(不是另开一张),
|
||||
* 否则"在编辑谁"要靠位置猜。
|
||||
*/
|
||||
@Builder
|
||||
UserCard(user: AdminUser) {
|
||||
Column() {
|
||||
Row() {
|
||||
Text(this.editingId === user.user_id ? '⌄' : '›')
|
||||
.fontSize(Theme.fontSmall).fontColor(Theme.textSubtle).width(18)
|
||||
Text(user.username)
|
||||
.fontSize(Theme.fontBody).fontColor(Theme.textPrimary).layoutWeight(1)
|
||||
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
this.Chip(user.role === 'admin' ? '管理员' : '用户',
|
||||
user.role === 'admin' ? Theme.chipNeutralBg : Theme.surfaceMuted,
|
||||
user.role === 'admin' ? Theme.accent : Theme.textMuted)
|
||||
Text(' ').width(4)
|
||||
this.Chip(user.status === 'active' ? '启用' : '禁用',
|
||||
user.status === 'active' ? Theme.approveBg : Theme.surfaceMuted,
|
||||
user.status === 'active' ? Theme.approveFg : Theme.textMuted)
|
||||
/*
|
||||
* 「受限」徽标:**只在普通用户且白名单非空时**显示 —— 与 WebUI 逐字一致
|
||||
* (`AdminUsersPage.tsx`:`user.role !== 'admin' && (allowed_agents.length > 0 || …)`)。
|
||||
* ★ 管理员**不加**这个徽标:服务端对管理员忽略白名单,
|
||||
* 给管理员打「受限」会让人以为他的权限被削了。
|
||||
* ★ 白名单非空才叫"受限":空 = 不限(不是"什么都不许"),那种情况不该有徽标。
|
||||
*/
|
||||
if (isRestricted(user)) {
|
||||
Text(' ').width(4)
|
||||
this.Chip('受限', Theme.surfaceMuted, Theme.textSubtle)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Row() {
|
||||
Text(user.display_name.length > 0 ? user.display_name : user.username)
|
||||
.fontSize(Theme.fontSmall).fontColor(Theme.textSubtle).layoutWeight(1)
|
||||
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
Text(lastLoginLabel(user))
|
||||
.fontSize(Theme.fontTiny).fontColor(Theme.textSubtle)
|
||||
}
|
||||
.width('100%').margin({ top: 4 })
|
||||
|
||||
if (this.editingId === user.user_id) {
|
||||
this.UserEditor(user)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.backgroundColor(Theme.surface)
|
||||
.borderRadius(Theme.radiusCard)
|
||||
.onClick(() => { this.toggleEdit(user); })
|
||||
}
|
||||
|
||||
@Builder
|
||||
Chip(text: string, bg: string, fg: string) {
|
||||
Text(text)
|
||||
.fontSize(Theme.fontTiny)
|
||||
.fontColor(fg)
|
||||
.backgroundColor(bg)
|
||||
.borderRadius(4)
|
||||
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
|
||||
}
|
||||
|
||||
/** 编辑区:显示名 / 角色 / 状态 / 白名单 / 重置密码 */
|
||||
@Builder
|
||||
UserEditor(user: AdminUser) {
|
||||
Column() {
|
||||
Divider().color(Theme.border).margin({ top: 10, bottom: 10 })
|
||||
|
||||
Text('显示名').fontSize(Theme.fontTiny).fontColor(Theme.textMuted)
|
||||
.width('100%')
|
||||
TextInput({ placeholder: '显示名', text: this.editDisplayName })
|
||||
.width('100%').height(40).margin({ top: 4 })
|
||||
.onChange((value: string) => { this.editDisplayName = value; })
|
||||
|
||||
Text('角色').fontSize(Theme.fontTiny).fontColor(Theme.textMuted)
|
||||
.width('100%').margin({ top: 10 })
|
||||
Row() {
|
||||
this.RolePick('普通用户', 'user')
|
||||
Text(' ').width(8)
|
||||
this.RolePick('管理员', 'admin')
|
||||
}
|
||||
.width('100%').margin({ top: 4 })
|
||||
|
||||
if (this.editRole !== 'admin') {
|
||||
this.ScopePick('可调用 Agent', this.scopes.agents, this.editAgents, true)
|
||||
this.ScopePick('可访问目录', this.scopes.paths, this.editPaths, false)
|
||||
} else {
|
||||
Text('管理员不受白名单限制(服务端对管理员忽略这两项)。')
|
||||
.fontSize(Theme.fontTiny).fontColor(Theme.textSubtle)
|
||||
.width('100%').margin({ top: 8 })
|
||||
}
|
||||
|
||||
Row() {
|
||||
Button(user.status === 'active' ? '禁用' : '启用')
|
||||
.height(34).fontSize(Theme.fontSmall)
|
||||
.backgroundColor(Theme.surfaceMuted).fontColor(Theme.danger)
|
||||
.onClick(() => {
|
||||
this.setStatus(user, user.status === 'active' ? 'disabled' : 'active');
|
||||
})
|
||||
Blank()
|
||||
Button(this.busy ? '保存中' : '保存更改')
|
||||
.height(34).fontSize(Theme.fontSmall)
|
||||
.backgroundColor(Theme.accent).fontColor(Theme.accentFg)
|
||||
.onClick(() => { this.saveEdit(); })
|
||||
}
|
||||
.width('100%').margin({ top: 12 })
|
||||
|
||||
Divider().color(Theme.border).margin({ top: 12, bottom: 10 })
|
||||
|
||||
Text('重置密码').fontSize(Theme.fontTiny).fontColor(Theme.textMuted)
|
||||
.width('100%')
|
||||
Row() {
|
||||
TextInput({ placeholder: '新密码(至少 8 位)', text: this.editPassword })
|
||||
.layoutWeight(1).height(38).type(InputType.Password)
|
||||
.onChange((value: string) => { this.editPassword = value; })
|
||||
Text(' ').width(8)
|
||||
Button('重置')
|
||||
.height(38).fontSize(Theme.fontSmall)
|
||||
.backgroundColor(Theme.surfaceMuted).fontColor(Theme.textPrimary)
|
||||
.enabled(this.editPassword.length >= 8)
|
||||
.onClick(() => { this.resetPassword(user); })
|
||||
}
|
||||
.width('100%').margin({ top: 4 })
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
@Builder
|
||||
RolePick(label: string, value: string) {
|
||||
Text(label)
|
||||
.fontSize(Theme.fontSmall)
|
||||
.fontColor(this.editRole === value ? Theme.accentFg : Theme.textMuted)
|
||||
.backgroundColor(this.editRole === value ? Theme.accent : Theme.surfaceMuted)
|
||||
.borderRadius(Theme.radiusControl)
|
||||
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
|
||||
.onClick(() => { this.editRole = value; })
|
||||
}
|
||||
|
||||
/**
|
||||
* 白名单多选。
|
||||
*
|
||||
* 口径与 WebUI 逐字一致:**未勾选 = 不限**(不是"什么都不可用")。
|
||||
* 缺省给"不限"而不是"全禁":管理员建完用户还没配白名单时,
|
||||
* "全禁"会让新用户一登录什么都点不动,看起来像坏了。
|
||||
*/
|
||||
@Builder
|
||||
ScopePick(label: string, items: string[], selected: string[], isAgent: boolean) {
|
||||
Column() {
|
||||
Row() {
|
||||
Text(label).fontSize(Theme.fontTiny).fontColor(Theme.textMuted)
|
||||
Text(selected.length > 0 ? '(已选 ' + selected.length + ' 项)' : '')
|
||||
.fontSize(Theme.fontTiny).fontColor(Theme.textSubtle)
|
||||
Blank()
|
||||
Text('未勾选 = 不限').fontSize(Theme.fontTiny).fontColor(Theme.textSubtle)
|
||||
}
|
||||
.width('100%').margin({ top: 10 })
|
||||
|
||||
if (items.length === 0) {
|
||||
Text('(服务端没给出候选)').fontSize(Theme.fontTiny).fontColor(Theme.textSubtle)
|
||||
.width('100%').margin({ top: 4 })
|
||||
} else {
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(items, (item: string) => {
|
||||
Text(item)
|
||||
.fontSize(Theme.fontTiny)
|
||||
.fontColor(selected.includes(item) ? Theme.accentFg : Theme.textMuted)
|
||||
.backgroundColor(selected.includes(item) ? Theme.accent : Theme.surfaceMuted)
|
||||
.borderRadius(4)
|
||||
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
|
||||
.margin({ right: 6, top: 6 })
|
||||
.onClick(() => {
|
||||
if (isAgent) {
|
||||
this.editAgents = toggled(this.editAgents, item);
|
||||
} else {
|
||||
this.editPaths = toggled(this.editPaths, item);
|
||||
}
|
||||
})
|
||||
}, (item: string) => item)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
/** 新建用户表单(字段与校验口径照 WebUI:用户名 ≥2、密码 ≥8) */
|
||||
@Builder
|
||||
CreateForm() {
|
||||
Column() {
|
||||
TextInput({ placeholder: '用户名(小写字母数字 . _ -)', text: this.newUsername })
|
||||
.width('100%').height(40)
|
||||
.onChange((value: string) => { this.newUsername = value; })
|
||||
TextInput({ placeholder: '显示名', text: this.newDisplayName })
|
||||
.width('100%').height(40).margin({ top: 8 })
|
||||
.onChange((value: string) => { this.newDisplayName = value; })
|
||||
TextInput({ placeholder: '初始密码(至少 8 位)', text: this.newPassword })
|
||||
.width('100%').height(40).margin({ top: 8 }).type(InputType.Password)
|
||||
.onChange((value: string) => { this.newPassword = value; })
|
||||
|
||||
Row() {
|
||||
Text('角色').fontSize(Theme.fontTiny).fontColor(Theme.textMuted)
|
||||
Blank()
|
||||
Text('普通用户')
|
||||
.fontSize(Theme.fontSmall)
|
||||
.fontColor(this.newRole === 'user' ? Theme.accentFg : Theme.textMuted)
|
||||
.backgroundColor(this.newRole === 'user' ? Theme.accent : Theme.surfaceMuted)
|
||||
.borderRadius(Theme.radiusControl)
|
||||
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
|
||||
.onClick(() => { this.newRole = 'user'; })
|
||||
Text(' ').width(8)
|
||||
Text('管理员')
|
||||
.fontSize(Theme.fontSmall)
|
||||
.fontColor(this.newRole === 'admin' ? Theme.accentFg : Theme.textMuted)
|
||||
.backgroundColor(this.newRole === 'admin' ? Theme.accent : Theme.surfaceMuted)
|
||||
.borderRadius(Theme.radiusControl)
|
||||
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
|
||||
.onClick(() => { this.newRole = 'admin'; })
|
||||
}
|
||||
.width('100%').margin({ top: 10 })
|
||||
|
||||
Text('白名单建完再配:创建后在新用户那张卡里勾「可调用 Agent / 可访问目录」。未勾 = 不限。')
|
||||
.fontSize(Theme.fontTiny).fontColor(Theme.textSubtle)
|
||||
.width('100%').margin({ top: 8 })
|
||||
|
||||
Row() {
|
||||
Blank()
|
||||
Button(this.busy ? '创建中' : '创建')
|
||||
.height(34).fontSize(Theme.fontSmall)
|
||||
.backgroundColor(Theme.accent).fontColor(Theme.accentFg)
|
||||
.enabled(this.newUsername.trim().length >= 2 && this.newPassword.length >= 8 && !this.busy)
|
||||
.onClick(() => { this.createUser(); })
|
||||
}
|
||||
.width('100%').margin({ top: 10 })
|
||||
}
|
||||
.width('100%').padding(12).margin({ left: 12, right: 12, top: 8 })
|
||||
.backgroundColor(Theme.surface)
|
||||
.borderRadius(Theme.radiusCard)
|
||||
}
|
||||
}
|
||||
@ -14,7 +14,7 @@ import { SseService, SseEvent } from '../api/SseService';
|
||||
import { AppearanceStore } from '../common/AppearanceStore';
|
||||
import { Configuration, ConfigurationConstant, EnvironmentCallback } from '@kit.AbilityKit';
|
||||
import { image } from '@kit.ImageKit';
|
||||
import { AppearanceSnapshot, isDarkMode, scrimOpacity } from '../model/Appearance';
|
||||
import { AppearanceSnapshot, isDarkMode, scrimOpacity, blurStyleFor } from '../model/Appearance';
|
||||
import { BackgroundPlan, PresetLayer, TRANSPARENT, resolveBackground } from '../model/Wallpaper';
|
||||
import { MailSummary, Contact, PermissionRequest, DecideResponse, SentResponse, PendingResponse } from '../model/Models';
|
||||
import {
|
||||
@ -49,6 +49,27 @@ import {
|
||||
emptyHint
|
||||
} from '../model/CommTabs';
|
||||
import { MailDetailParams, ComposeParams } from '../model/RouteParams';
|
||||
|
||||
/**
|
||||
* `blurStyleFor` 给的**档位名** → SDK 的 `BlurStyle` 枚举。
|
||||
*
|
||||
* ★ 这张表**必须**待在页面里(不能挪进 `model/Appearance.ts`):`BlurStyle` 是 SDK 的
|
||||
* 枚举,只有 `.ets` 里才在作用域内;而 `model/Appearance.ts` 是**纯逻辑、零 `@ohos` 依赖**,
|
||||
* 正因如此判据能用 node 直接跑它(分档边界 0/8/20 就是那么钉住的)。
|
||||
* 分工是:**分档判断**在纯逻辑层(可判),**名字 → 枚举**这一步在这里(对照 SDK 写)。
|
||||
*
|
||||
* ★ 用 `Record<string, BlurStyle>` 而不是 if/else 链:枚举成员名与键**同名**,
|
||||
* 一眼能看出有没有写错、漏项(if/else 链漏一个分支只会静默走 else)。
|
||||
* ★ 键必须与 SDK 的 `declare enum BlurStyle` 成员**逐字相同** ——
|
||||
* `harmony-appearance.test.mjs` 有一条判据把这里的键拿去和 SDK 比对
|
||||
* ("写成自造名字会编译不过/不生效")。
|
||||
*/
|
||||
const BLUR_STYLE_OF: Record<string, BlurStyle> = {
|
||||
'NONE': BlurStyle.NONE,
|
||||
'COMPONENT_THIN': BlurStyle.COMPONENT_THIN,
|
||||
'COMPONENT_REGULAR': BlurStyle.COMPONENT_REGULAR,
|
||||
'COMPONENT_THICK': BlurStyle.COMPONENT_THICK
|
||||
};
|
||||
import { CalendarPage } from './CalendarPage';
|
||||
import {
|
||||
NAV_BAR_BOTTOM,
|
||||
@ -1591,7 +1612,8 @@ struct MainPage {
|
||||
*/
|
||||
const systemMode: number = ctx.resourceManager.getConfigurationSync().colorMode;
|
||||
const dark: boolean = isDarkMode(snap.theme, systemMode);
|
||||
this.bgPlan = resolveBackground(snap.bgKind, snap.bgPresetId, scrimOpacity(snap.bgDim), store.wallpaper !== null, dark);
|
||||
// 模糊强度是**服务端给的 px 原值**:计划只搬运它,映射成系统材质档在画的那一层做
|
||||
this.bgPlan = resolveBackground(snap.bgKind, snap.bgPresetId, scrimOpacity(snap.bgDim), store.wallpaper !== null, dark, snap.bgBlur);
|
||||
this.bgActive = this.bgPlan.kind !== 'none';
|
||||
}
|
||||
|
||||
@ -1675,6 +1697,27 @@ struct MainPage {
|
||||
Image(this.wallpaperImage)
|
||||
.width('100%').height('100%')
|
||||
.objectFit(ImageFit.Cover)
|
||||
/*
|
||||
* ── 这里的模糊是「**图片内容模糊**」,与导航条的「面板材质模糊」是两件事 ──
|
||||
*
|
||||
* WebUI 侧核实过(`client/electron/src/index.css`):
|
||||
* · 壁纸层 `.app-backdrop`(z-index:-1,背后什么都没有)吃
|
||||
* `filter: blur(var(--bg-blur))` —— **图片内容模糊**;
|
||||
* · 面板另有 `backdrop-filter: blur(8px)`(`.app-backdrop` 之上的那层)
|
||||
* —— **背后内容模糊**。
|
||||
* 两者是**两个不同的物理量**,所以"壁纸糊一次 + 导航条材质一次"**不是**
|
||||
* "同一张底被模糊两遍"。原来那条判据把两者混为一谈(见 `harmony-appearance.test.mjs`
|
||||
* 里已修正的那条),曾让我以为"壁纸层不许有任何模糊"。
|
||||
*
|
||||
* ★ 用 `blur(radius)`(`CommonMethod` 的图片内容模糊,与 CSS `filter: blur()` 同一个量)
|
||||
* 而**不是** `backgroundBlurStyle`:后者是**面板材质**,作用在组件的**背景**上
|
||||
* (作用对象是它背后的内容),语义对不上。导航条那处才该用材质。
|
||||
* ★ 半径直接用服务端给的那个 px 值:WebUI 就是 `blur(var(--bg-blur))`,
|
||||
* 两边**同一个物理量、同一个数** ⇒ 这一处不需要映射表,也不该有。
|
||||
* (`blurStyleFor` 那张表服务的是导航条那种**没有 px 半径**的材质档,
|
||||
* 它在本次改动前"有映射表、无调用点";这次给它接了调用点,见 NavBar。)
|
||||
*/
|
||||
.blur(this.bgPlan.blurPx)
|
||||
// 压暗用**系统遮罩色** + 服务端给的浓度:换向(浅色洗白/深色压黑)由系统负责
|
||||
Column()
|
||||
.width('100%').height('100%')
|
||||
@ -1750,10 +1793,17 @@ struct MainPage {
|
||||
*
|
||||
* 原来这里有 `#B8FFFFFF` / `#B80F172A` 两个常量(浅色/深色各一个手写玻璃)——
|
||||
* 那等于"我们替系统猜了深色该怎么做",与"用系统方案"直接冲突,
|
||||
* 而且还要我们自己维护两套。现在只声明**档次**(`Theme.navMaterial`),
|
||||
* 而且还要我们自己维护两套。现在只声明**档次**(由 `blurStyleFor` 把用户的模糊档
|
||||
* 映射成系统档位;档位名 → `BlurStyle` 的四行表是 `BLUR_STYLE_OF`),
|
||||
* 深浅两套颜色与模糊半径都由系统按主题给。
|
||||
*/
|
||||
.backgroundBlurStyle(Theme.navMaterial)
|
||||
/*
|
||||
* 导航条的**面板材质**:档位由用户的 `bg_blur` 映射而来
|
||||
* (`blurStyleFor`,`model/Appearance.ts`;分档边界 0/8/20 有行为判据)。
|
||||
* 这正是计划文档 §7.12 要求的那件事 —— 开始消费 `bg_blur` 时补上映射的**调用点**。
|
||||
* ★ 与壁纸层的 `blur(px)` 不是同一件事:这里是"背后内容糊",那里是"图片本身糊"。
|
||||
*/
|
||||
.backgroundBlurStyle(BLUR_STYLE_OF[blurStyleFor(this.bgPlan.blurPx)] ?? BlurStyle.NONE)
|
||||
}
|
||||
|
||||
build() {
|
||||
|
||||
@ -10,6 +10,10 @@ import { SseService } from '../api/SseService';
|
||||
import { AppearanceStore } from '../common/AppearanceStore';
|
||||
import { AppearanceApi } from '../api/AppearanceApi';
|
||||
import { AppearanceSnapshot, statusLabel } from '../model/Appearance';
|
||||
import { MeApi } from '../api/AdminApi';
|
||||
import { AdminUser } from '../model/Models';
|
||||
import { isAdminRole } from '../model/AdminUsers';
|
||||
import { BackgroundPicker } from '../common/BackgroundPicker';
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
@ -28,6 +32,23 @@ struct SettingsPage {
|
||||
*/
|
||||
@State appearanceTheme: string = 'system';
|
||||
@State appearanceStatus: string = 'local-only';
|
||||
/** 当前账号是不是管理员 —— 只用来决定「管理」入口显不显示(口径见 model/AdminUsers.ts 的 isAdminRole) */
|
||||
@State isAdmin: boolean = false;
|
||||
/*
|
||||
* 背景四值(P4c):由选择器(`BackgroundPicker`,@Link 双向绑)改,
|
||||
* 用户改一次就回调 `onUserChanged` ⇒ 这里推服务端 —— 与 `setTheme` 同一形状。
|
||||
*
|
||||
* ★ 为什么**不**用「页面 @Watch 到 bgKind/bgDim… 变化就推服务端」:
|
||||
* `adoptSnapshot` 把**服务端**的值复制进来时也会触发那个 @Watch ⇒
|
||||
* 推一次服务端 ⇒ 再同步 ⇒ 再触发…… 要么死循环,要么得再加一个抑制标志,
|
||||
* 而那个标志本身又是一处能忘掉的状态。
|
||||
* 改成"选择器在**用户改值**时显式回调"之后,方向是单向的:
|
||||
* 服务端来的值只往下走,不会回头再推一次。**不需要任何抑制标志。**
|
||||
*/
|
||||
@State bgKind: string = 'none';
|
||||
@State bgPresetId: string = 'aurora';
|
||||
@State bgDim: number = 12;
|
||||
@State bgBlur: number = 4;
|
||||
|
||||
private client: ApiClient | null = null;
|
||||
private acctMgr: AccountManager | null = null;
|
||||
@ -40,10 +61,96 @@ struct SettingsPage {
|
||||
this.acctMgr.load().then(() => {
|
||||
this.refreshList();
|
||||
this.loadAppearance();
|
||||
this.loadRole();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户改了背景 ⇒ 推一次服务端(与 `setTheme` 同一形状)。
|
||||
*
|
||||
* ★ 只有**用户动作**会走到这里(选择器改值后回调 `onUserChanged`);
|
||||
* 服务端同步进来的值不会(理由见 `bgKind` 那段的注释)。
|
||||
* ⇒ "值变了就推一次"只有**这一处**实现,而不是每个 onClick 各推一次。
|
||||
*
|
||||
* ★ 与 `setTheme` 一样:本机先生效,但**推不上去就要说"仅本机"**
|
||||
* (`appearanceStatus`),不能让用户以为换设备也带着走。
|
||||
*/
|
||||
async setBackground(kind: string, presetId: string, dim: number, blur: number): Promise<void> {
|
||||
const ctx = this.getUIContext().getHostContext();
|
||||
const client: ApiClient | null = this.client;
|
||||
if (ctx === undefined) {
|
||||
return;
|
||||
}
|
||||
const store: AppearanceStore = AppearanceStore.getInstance();
|
||||
const snap: AppearanceSnapshot = store.current();
|
||||
snap.bgKind = kind;
|
||||
snap.bgPresetId = presetId;
|
||||
snap.bgDim = dim;
|
||||
snap.bgBlur = blur;
|
||||
if (client === null) {
|
||||
this.appearanceStatus = 'local-only';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await new AppearanceApi(client).put(snap, store.wallpaper !== null);
|
||||
store.saveLocal(ctx, snap);
|
||||
this.appearanceStatus = 'synced';
|
||||
} catch (e) {
|
||||
this.appearanceStatus = 'local-only';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传成功后**重新以服务端为准同步**(P4c 第③条)。
|
||||
*
|
||||
* ★ 刻意**不**在这里把 `bgKind` 设成 'image':服务端才是权威,
|
||||
* 如果服务端因为任何原因没把档位记成 image,本地先改成 image 就会
|
||||
* 显示一块取不回来的空白(`resolveBackground` 对"image 档但没图"给 none)。
|
||||
* 同步回来是什么就是什么。
|
||||
*/
|
||||
async onWallpaperUploaded(message: string): Promise<void> {
|
||||
const ctx = this.getUIContext().getHostContext();
|
||||
const client: ApiClient | null = this.client;
|
||||
this.getUIContext().getPromptAction().showToast({ message: message });
|
||||
if (ctx === undefined || client === null) {
|
||||
return;
|
||||
}
|
||||
const store: AppearanceStore = AppearanceStore.getInstance();
|
||||
await store.syncFromServer(ctx, client);
|
||||
this.adoptSnapshot(store.current(), store.statusText());
|
||||
}
|
||||
|
||||
/** 把 store 里的快照复制进 @State(@State 观察不到类内部字段变化) */
|
||||
adoptSnapshot(snap: AppearanceSnapshot, status: string): void {
|
||||
this.appearanceTheme = snap.theme;
|
||||
this.bgKind = snap.bgKind;
|
||||
this.bgPresetId = snap.bgPresetId;
|
||||
this.bgDim = snap.bgDim;
|
||||
this.bgBlur = snap.bgBlur;
|
||||
this.appearanceStatus = status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读一次当前身份,决定「管理」入口显不显示。
|
||||
*
|
||||
* ★ 失败**保持 false**:入口不显示,而不是「显示一个点进去 403 的东西」。
|
||||
* 这里刻意不弹错误 —— 身份读不到不影响本页任何其它功能,
|
||||
* 为它弹一个 toast 只会让人以为设置页坏了。
|
||||
*/
|
||||
async loadRole(): Promise<void> {
|
||||
const client: ApiClient | null = this.client;
|
||||
if (client === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const me: AdminUser = await new MeApi(client).get();
|
||||
this.isAdmin = isAdminRole(me.role);
|
||||
} catch (e) {
|
||||
this.isAdmin = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉一次外观:按合并规则落地(规则在 `model/Appearance.ts`,判据跑那一份)。
|
||||
*
|
||||
@ -60,6 +167,8 @@ struct SettingsPage {
|
||||
// 缓存按账号分:换账号时读的是那一个账号的那一份
|
||||
store.loadLocal(ctx, this.activeId);
|
||||
await store.syncFromServer(ctx, client);
|
||||
// 背景四值一并从 store 复制进来(否则选择器显示的还是默认值,与服务端不一致)
|
||||
this.adoptSnapshot(store.current(), store.statusText());
|
||||
const snap: AppearanceSnapshot = store.current();
|
||||
this.appearanceTheme = snap.theme;
|
||||
this.appearanceStatus = store.status;
|
||||
@ -263,12 +372,68 @@ struct SettingsPage {
|
||||
|
||||
Text('主题由系统按色彩模式给色(深浅两套不靠手写色值);换账号时外观跟着账号走。')
|
||||
.fontSize(11).fontColor(Theme.textSubtle).margin({ top: 6 })
|
||||
|
||||
Divider().color(Theme.border).margin({ top: 12, bottom: 12 })
|
||||
|
||||
/*
|
||||
* 背景(P4c:不设 / 预设 / 自定义图片)。
|
||||
*
|
||||
* 四值用 `@Link` 双向绑(`$` 前缀是 ArkTS 的 @Link 传参语法);
|
||||
* `onUserChanged` 只在**用户动作**里被调 ⇒ 这里推服务端。
|
||||
*/
|
||||
BackgroundPicker({
|
||||
bgKind: $bgKind,
|
||||
bgPresetId: $bgPresetId,
|
||||
bgDim: $bgDim,
|
||||
bgBlur: $bgBlur,
|
||||
statusText: statusLabel(this.appearanceStatus),
|
||||
onUserChanged: (kind: string, presetId: string, dim: number, blur: number) => {
|
||||
this.setBackground(kind, presetId, dim, blur);
|
||||
},
|
||||
onUploaded: (message: string) => { this.onWallpaperUploaded(message); },
|
||||
onUploadFailed: (reason: string) => {
|
||||
this.getUIContext().getPromptAction().showToast({ message: reason });
|
||||
}
|
||||
})
|
||||
}
|
||||
.width('100%').alignItems(HorizontalAlign.Start)
|
||||
.padding(16).margin({ top: 8 })
|
||||
.backgroundColor(Theme.surface)
|
||||
.borderRadius(Theme.radiusCard)
|
||||
|
||||
/*
|
||||
* ── 管理入口(仅管理员可见)──
|
||||
*
|
||||
* WebUI 侧的位置是「"我的"页底部、仅管理员可见」(`App.tsx`:
|
||||
* `viewMode === 'admin' && user?.role === 'admin'`)。鸿蒙侧对应的位置就是这里。
|
||||
*
|
||||
* ★ 可见性来自 `/me` 的 `role`,口径与 WebUI 严格一致(`isAdminRole`)。
|
||||
* ★ **读不到身份时不显示**(`isAdmin` 初值 false),而不是乐观显示:
|
||||
* 乐观显示会让每个普通用户都看到一个点进去 403 的入口。
|
||||
* 代价是"管理员在网络抖动时看不到入口",那是可接受的(刷新即恢复)——
|
||||
* 反过来则是每个人都被误导。
|
||||
*/
|
||||
if (this.isAdmin) {
|
||||
Column() {
|
||||
Row() {
|
||||
Text('管理').fontSize(Theme.fontBody).fontColor(Theme.textPrimary)
|
||||
.layoutWeight(1)
|
||||
Text('›').fontSize(Theme.fontBody).fontColor(Theme.textSubtle)
|
||||
}
|
||||
.width('100%')
|
||||
Text('用户管理(新建 / 编辑 / 启停 / 重置密码)')
|
||||
.fontSize(Theme.fontTiny).fontColor(Theme.textSubtle)
|
||||
.width('100%').margin({ top: 4 })
|
||||
}
|
||||
.width('100%').alignItems(HorizontalAlign.Start)
|
||||
.padding(16).margin({ top: 8 })
|
||||
.backgroundColor(Theme.surface)
|
||||
.borderRadius(Theme.radiusCard)
|
||||
.onClick(() => {
|
||||
this.getUIContext().getRouter().pushUrl({ url: 'pages/AdminUsersPage' });
|
||||
})
|
||||
}
|
||||
|
||||
if (this.showAddDialog) {
|
||||
Column() {
|
||||
Column()
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
"pages/ComposePage",
|
||||
"pages/SettingsPage",
|
||||
"pages/SessionsPage",
|
||||
"pages/AdminUsersPage",
|
||||
"pages/Index"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user