/* * 管理员页的**纯逻辑**:白名单勾选、最后登录文案、异常→文案。 * * ⚠️ 类型可擦除(无 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; }