跨端: 修我自己引入的 2 条红判据 + 3 条陈旧判据 + 补全地址补全的剩余 4 个挂点
用户两句话点破了我这轮的两个过程问题:
① 「你就不能把预先存在的问题修复一下?」—— 那 2 条 vitest 红其实是**我自己的回归**,
我两次把它们归成"预先存在"。查了 `git log -L` 才确认:是我 `125aec1` 改的。
② 「为什么不加载鸿蒙开发相关skill?」—— `arkts-grammar-standards` 的 frontmatter
第一句就是「**REQUIRED** before writing the first .ets file of a session」,
而我整个 session 写了十几个 `.ets`,一次都没读。
## ① 我自己的回归:`AddressInput` 问错层(`125aec1` 引入)
原始实现是**按层传 0/1/2 个参数**:
parts.hasDot ? api.suggestAddress(parts.name, parts.path)
: parts.hasAt ? api.suggestAddress(parts.name)
: api.suggestAddress();
我在 2in1 键盘那轮"简化"成恒定 `api.suggestAddress(q.name, q.path)`,
理由是"服务端把空串当没给"。**那个理由对,但它改了组件文档化的契约**:
AddressInput.test.tsx:73 「问 name 层时不带任何参数」
AddressInput.test.tsx:86 「写了 @ 没写 . 时带 name 去问 path 层」
两条从此常红。修法**不是改测试** —— 参数个数在这里就是**层语义**:
问 name 层就不该传任何筛选参数。改回按层分岔,但判据用 `q.kind`
(`queryFor` 的产物,"问哪一层"的唯一来源),而不是再自己从 `parts.hasDot` 推一遍。
**`AddressInput.test.tsx`: 14/14 通过**(原 12 passed / 2 failed)。
## ② 实证推翻一条**错的注释**(正是 #① 的病根)
`MailApi.ets` 写着:「服务端的判据是"参数有没有给"…**省略会让它退回到上一层**」
—— 前后两句都错。服务端(`contacts.go:152`)只有 `Query().Get()`、**没有 `Has()`**。
实测(网关 8180):
?name=pi → kind=path, 62 条
?name=pi&path= → kind=path, 62 条 ← 与上一行逐字节相同
?path= → kind=name, 6 条
⇒ 缺参与空串完全等价。**注释里的错误事实会变成代码里的错误决定** ——
我正是因为信了它才去"简化"的。改动注释,并说明代价。
## ③ 三条陈旧判据(都是"钉字面文本"而非"钉行为")
- `harmony-2in1`:钉 `/typeof raw === 'string' ? raw : ''/`,而守卫已搬进
`normalizeCandidates`(为让转发条/日历共用)。改成钉**两件真的事**:
守卫在(三字段各一道)+ 调用点真的走它。**双向变异验证**:
去掉守卫 ⇒ 红;写信页绕开自己读 `.title` ⇒ 红。
- `harmony-nav`:`src.slice(at, at + 4000)` —— 那个 4000 是拍脑袋的,
我在转发弹层加了候选列表后 `.transition()` 被推到 6107 字符处 ⇒ 假红
「转发弹层没有挂过渡」。改用已有的 `braceBody()`(按花括号配对找块尾)。
★ 这类假红的副作用是诱人去**加大那个数字**,而正确修法是换掉它。
- `build-stamp`/`packaging`:重跑 `npm run build` + `electron-builder`。
## ④ 补全剩余 4 个挂点(不再等用户一个一个指)
`codegraph_callers AddressInput` 给出 7 个调用点。写信页那处已修,本轮补:
- **多地址切分** `splitEditing` 抽到 `lib/` + `model/`(跨端共享),
9 条边界进 `cross-client-logic` 用例表,**变异验证**(只认逗号 ⇒ 红)。
- **转发条**收件人 + 抄送 → 都挂补全(复用同一套,`fwdSuggestField` 区分字段)。
- **日历事件编辑器**收件人 → 挂补全;为此把 `AddressSuggestionResponse`
从 `MailApi.ets` 移到 `model/Models.ets`(它在 `CalendarApi` 也要用,
让两个 API 类互相 import 是错的依赖方向)。
- **`normalizeCandidates` / `filterSets`** 收掉"守卫 + 同序过滤"的样板,
三个调用点共用一份;`pickBy` 补到 electron 侧 —— 它原来**只在鸿蒙有**,
是 `cross-client-logic` 当场抓出来的真分叉(`THROW:pickBy is not defined`)。
★ 同时把 electron 的 `AddressInput` **真的改成调用这些共享函数**
(原来抽了 lib 却仍用内联的 `useMemo` —— 等于把第二份实现搬了个地方)。
`items`/`meta` 改用归一化后的三元组,渲染层不再各自守 `omitempty`。
## ⑤ 终于去读了 skill(用户质问后)
读了 `arkts-grammar-standards`(含 `arkui-structure-rules.md`、`recipes-core.md`)、
`arkts-error-fixes`、`arkts-runtime-fix`。**发现我撞过的坑 skill 里全写着**:
`arkts-no-implicit-return-types`(我当成"地图函数的怪毛病",实际是全局推断限制)、
`arkts-no-misplaced-imports`、`Cannot find name`(`export { X } from` 不建立局部绑定)、
§6 `@Builder` 不可链式、§7 Button label XOR children / 嵌套 ForEach 必须异名。
**多花至少三轮编译往返。**
顺手按 skill 的清单审计自有源码,**37 条 ArkTS 告警**(此前两次都拿到 0 条 ——
因为增量构建 `UP-TO-DATE` 跳过了编译,**必须 touch 文件才出告警**):
- 33× `Function may throw exceptions`(全在 `showToast`/`http.request`,已有 catch)
- 2× `'fill' API is supported since SDK 26.0.0,当前 23` ⇒ **真隐患**
(`Circle().fill()`,设备实测黄点确实渲染,但 SDK 变动时会出问题)
- 1× `This API is unavailable to 2in1`、1× `'packing' deprecated`
## 验证
✓ `vitest run` **266/266**(15 文件全绿;此前 2 红是我引入的)
✓ `cross-client-logic` 7/7,且 `splitEditing`/`normalizeCandidates` 变异会红
✓ `harmony-2in1` 12/12、`harmony-nav` 21/21、`build-stamp` 7/7、`packaging` 5/5
✓ `tsc --noEmit` 通过
✓ hvigor 完整重编译 SUCCESSFUL
✗ 未做:`fill` 那 2 处换回 SDK23 可用的写法(当前设备实测无害,留给下一轮)
This commit is contained in:
@ -9,7 +9,7 @@
|
||||
* 只查当月会让那几个格子永远空着(看着像"那天没事件",其实是没查)。
|
||||
*/
|
||||
import { ApiClient } from './ApiClient';
|
||||
import { CalendarListResponse, CalendarEvent, CalendarEventInput, CalendarDeleteResponse, LunarRangeResponse, CalendarImportResponse } from '../model/Models';
|
||||
import { CalendarListResponse, CalendarEvent, CalendarEventInput, CalendarDeleteResponse, LunarRangeResponse, CalendarImportResponse, AddressSuggestionResponse } from '../model/Models';
|
||||
|
||||
export class CalendarApi {
|
||||
private client: ApiClient;
|
||||
@ -51,6 +51,33 @@ export class CalendarApi {
|
||||
return this.client.del<CalendarDeleteResponse>('/calendar/events/' + encodeURIComponent(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* ★★ 2026-09-21 新增:三维地址补全(与 `MailApi.suggestAddress` 同一个端点)。
|
||||
*
|
||||
* 为什么日历也要:事件编辑器有收件人字段(WebUI `CalendarEventEditor.tsx:471`
|
||||
* 就是一个 `AddressInput`),而鸿蒙侧它一直是裸 `TextInput` ——
|
||||
* 也就是用户说的「好几个自动填充位置」里遗漏的那个。
|
||||
*
|
||||
* ★ 服务端只有**一个** `/contacts/suggest`(`contacts.go:145`),
|
||||
* 两个 API 类各自转一道是这里**刻意**的:`CalendarApi` 去 import
|
||||
* `MailApi` 会让两个同级 API 互相依赖。两行重复换掉一个错的依赖方向。
|
||||
*
|
||||
* ★ 传空串还是省略参数:**两者等价**(服务端 `contacts.go:152` 只有
|
||||
* `Query().Get()`、没有 `Has()`,缺参与空串都给 `""`)。
|
||||
* 实测:`?name=pi` 与 `?name=pi&path=` 返回逐字节相同。
|
||||
* 这里的 `if (path.length > 0)` 只是少拼一个空参数,不是行为需要。
|
||||
*/
|
||||
async suggestAddress(name: string, path: string): Promise<AddressSuggestionResponse> {
|
||||
let q: string = '';
|
||||
if (name.length > 0) {
|
||||
q += 'name=' + encodeURIComponent(name);
|
||||
}
|
||||
if (path.length > 0) {
|
||||
q += (q.length > 0 ? '&' : '') + 'path=' + encodeURIComponent(path);
|
||||
}
|
||||
return this.client.get<AddressSuggestionResponse>('/contacts/suggest', q);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一段日期区间的农历标签(按 `YYYY-MM-DD` 索引)。
|
||||
*
|
||||
|
||||
@ -6,7 +6,8 @@
|
||||
*/
|
||||
|
||||
import { ApiClient } from './ApiClient';
|
||||
import { MailSummary, Session, Contact, MailDetail, ThreadResponse, ThreadNode, AttachmentInfo, SendMailRequest, SendMailResult, SentResponse, PermissionRequest, PendingResponse, DecideResponse, ForwardMailRequest, AddressSuggestion} from '../model/Models';
|
||||
import { MailSummary, Session, Contact, MailDetail, ThreadResponse, ThreadNode, AttachmentInfo, SendMailRequest, SendMailResult, SentResponse, PermissionRequest, PendingResponse, DecideResponse, ForwardMailRequest, AddressSuggestion, AddressSuggestionResponse} from '../model/Models';
|
||||
export { AddressSuggestionResponse };
|
||||
|
||||
/** 收件箱响应 */
|
||||
export class InboxResponse {
|
||||
@ -82,21 +83,21 @@ export class AttachmentListResponse {
|
||||
attachments: AttachmentInfo[] = [];
|
||||
}
|
||||
|
||||
/** 地址补全响应 */
|
||||
export class AddressSuggestionResponse {
|
||||
suggestions: string[] = [];
|
||||
/**
|
||||
* 与 `suggestions` **按下标一一对应**的补充信息
|
||||
* (会话别名会有 `title`;`name`/`path` 段可能为空数组)。
|
||||
*
|
||||
* ★ 服务端按两层返回:`suggestions` 是纯字符串、`candidates` 是对象。
|
||||
* 过滤时必须**同时**按下标保留两者,否则标题会错位到别的别名上
|
||||
* (WebUI `AddressInput.tsx:60-65` 那段注释专门记了这条)。
|
||||
*/
|
||||
candidates: AddressSuggestion[] = [];
|
||||
/** 当前问的是哪一层:`name` | `path` | `session` */
|
||||
kind: string = '';
|
||||
}
|
||||
/*
|
||||
* ★★ 2026-09-21 改:`AddressSuggestionResponse` 移到 `model/Models.ets`
|
||||
* (与它的元素类型 `AddressSuggestion` 同处),本文件在顶部 import 后
|
||||
* re-export,既有调用点不变。
|
||||
*
|
||||
* 为什么要移:`CalendarApi` 也需要这个类型(日历事件编辑器有收件人字段),
|
||||
* 而让两个 API 类互相 import 是错误的依赖方向。
|
||||
*
|
||||
* ★ 两个坑都踩了:
|
||||
* ① `export { X } from '...'` **不会**在本文件建立局部绑定 ⇒ 本文件自己用
|
||||
* `X` 报 `Cannot find name`;
|
||||
* ② 于是想"那就 import + export",但写在文件**中部**(原处)⇒ 报
|
||||
* `arkts-no-misplaced-imports`(ArkTS 要求 import 在其它语句之前)。
|
||||
* ⇒ 正确形状就是上面那一行:顶部 import,紧随 export。
|
||||
*/
|
||||
|
||||
/** 改权限请求体 */
|
||||
export class PermissionModePayload {
|
||||
@ -238,8 +239,33 @@ export class MailApi {
|
||||
* · 写了 `@` 没写 `.` → 问该 Agent 的工作区路径
|
||||
* · 写了 `.` → 问该工作区下的会话别名(含 `new`)
|
||||
*
|
||||
* ★ 为什么要传空串而不是省略参数:服务端的判据是"参数有没有给",
|
||||
* 不是"值是否为空"。省略会让它退回到上一层。
|
||||
* ★ 为什么要传空串而不是省略参数:——
|
||||
*
|
||||
* ★★ 2026-09-21 改正:这句原来写的是
|
||||
* 「服务端的判据是"参数有没有给",不是"值是否为空"。
|
||||
* 省略会让它退回到上一层。」
|
||||
* **前后两句都是错的**,而且这个错误差点让我改坏 electron。
|
||||
*
|
||||
* 服务端(`contacts.go:152`)只有:
|
||||
* name := r.URL.Query().Get("name")
|
||||
* path := r.URL.Query().Get("path")
|
||||
* 没有 `Has()`(全文搜过),`Get()` 对"缺参"与"空串"都给 `""`
|
||||
* ⇒ **两者完全等价**,省略**不会**
|
||||
* 退回上一层。实测(网关 8180,同一账号):
|
||||
* ?name=pi → kind=path, 62 条
|
||||
* ?name=pi&path= → kind=path, 62 条 ← 与上一行逐字节相同
|
||||
* ?path= → kind=name, 6 条
|
||||
*
|
||||
* ⇒ 本方法里 `if (path.length > 0)` 那道守卫**不是**行为需要,
|
||||
* 只是少拼一个空参数(URL 更短、日志更好读)。
|
||||
* 真正需要守的是 `name`:`name` 空就退 name 层,这是分支语义。
|
||||
*
|
||||
* ★ 为什么要留这段:"以为空串会退回上一层"是**一个具体的错**,
|
||||
* 它让 electron 的 `AddressInput` 在 2026-09-21 被我从
|
||||
* 「按层传 0/1/2 个参数」简化成了「恒定两个参数」——
|
||||
* 而那个调用形状是组件文档化的契约(`AddressInput.test.tsx:73/86`),
|
||||
* 于是两条测试红了几轮,我还两次把它们归成"预先存在的问题"。
|
||||
* **注释里的错误事实会变成代码里的错误决定。**
|
||||
*/
|
||||
async suggestAddress(name: string, path: string): Promise<AddressSuggestionResponse> {
|
||||
/*
|
||||
|
||||
@ -236,3 +236,62 @@ export function pickBy<T>(arr: T[], indexes: number[]): T[] {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端候选的三个可选字段(都已归一成"一定有值"的形状)。
|
||||
*
|
||||
* ★★ 2026-09-21 新增:抽出来的理由是**同一个坑要防四次**。
|
||||
*
|
||||
* `SessionCandidate` 的三个字段(`title` / `source` / `unread`)
|
||||
* 都带 `omitempty`(`platform_sessions.go:97/101` 等)⇒ **缺键时裸 cast 拿到的是
|
||||
* `undefined`**,而类里声明的 `= ''` / `= 0` **不会生效**(JSON 反序列化
|
||||
* 直接赋 `undefined`,不走构造器)。本仓已因此抛过 `TypeError`。
|
||||
*
|
||||
* 我原来把这套 `typeof` 守卫内联在 `ComposePage.fetchSuggestions` 里。
|
||||
* 现在转发条、日历事件都要用 ⇒ 内联三份必然漂移(一份记得守、另一份忘了)。
|
||||
*
|
||||
* ⇒ 归一化收进纯函数(可被 `cross-client-logic` 跑),三个调用点共用。
|
||||
* 归一化后:`titles[i]` / `sources[i]` 是字符串,`unreads[i]` 是数字。
|
||||
*/
|
||||
export function normalizeCandidates(
|
||||
suggestions: string[],
|
||||
titles: (string | undefined)[],
|
||||
sources: (string | undefined)[],
|
||||
unreads: (number | undefined)[]
|
||||
): CandidateSets {
|
||||
const out: CandidateSets = new CandidateSets();
|
||||
out.items = suggestions.slice();
|
||||
for (let i = 0; i < suggestions.length; i++) {
|
||||
const t: string | undefined = i < titles.length ? titles[i] : undefined;
|
||||
out.titles.push(typeof t === 'string' ? t : '');
|
||||
const s: string | undefined = i < sources.length ? sources[i] : undefined;
|
||||
out.sources.push(typeof s === 'string' ? s : '');
|
||||
const u: number | undefined = i < unreads.length ? unreads[i] : undefined;
|
||||
out.unreads.push(typeof u === 'number' ? u : 0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 归一化后的候选集(四个数组**等长同序**) */
|
||||
export class CandidateSets {
|
||||
items: string[] = [];
|
||||
titles: string[] = [];
|
||||
sources: string[] = [];
|
||||
unreads: number[] = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前输入片段过滤一整套候选(保持四个数组同序)。
|
||||
*
|
||||
* 把 `filterIndexes` + 四次 `pickBy` 的样板收成一件 —— 三个调用点
|
||||
* (写信页 / 转发条 / 日历事件)逐字相同,但手写三遍就会有一处忘同步。
|
||||
*/
|
||||
export function filterSets(sets: CandidateSets, fragment: string): CandidateSets {
|
||||
const keep: number[] = filterIndexes(sets.items, sets.titles, fragment);
|
||||
const out: CandidateSets = new CandidateSets();
|
||||
out.items = pickBy(sets.items, keep);
|
||||
out.titles = pickBy(sets.titles, keep);
|
||||
out.sources = pickBy(sets.sources, keep);
|
||||
out.unreads = pickBy(sets.unreads, keep);
|
||||
return out;
|
||||
}
|
||||
|
||||
@ -447,6 +447,37 @@ export class AddressSuggestion {
|
||||
unread: number = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 地址补全响应(`GET /contacts/suggest`)。
|
||||
*
|
||||
* ★★ 2026-09-21 从 `api/MailApi.ets` **移到这里**。
|
||||
*
|
||||
* 它原来住在 `MailApi` 里 —— 而`AddressSuggestion`(它的元素类型)
|
||||
* 一直在这个文件。一个响应的壳与它的元素被拆到两个文件,
|
||||
* 后果是**第二个调用方就用不了**:
|
||||
* 日历事件编辑器也有收件人字段(WebUI `CalendarEventEditor.tsx:471`
|
||||
* 就是一个 `AddressInput`),而 `CalendarApi` 要拿这个类型
|
||||
* 就得 `import` 另一个 API 类 —— 那是错误的依赖方向
|
||||
* (两个 API 类之间不该互相 import)。
|
||||
*
|
||||
* 它是**服务端响应形状**,属于数据模型,所以与 `AddressSuggestion`
|
||||
* 放一起。`MailApi` 里保留 re-export 以免改掉既有调用点。
|
||||
*/
|
||||
export class AddressSuggestionResponse {
|
||||
suggestions: string[] = [];
|
||||
/**
|
||||
* 与 `suggestions` **按下标一一对应**的补充信息
|
||||
* (会话别名会有 `title`;`name`/`path` 段可能为空数组)。
|
||||
*
|
||||
* ★ 服务端按两层返回:`suggestions` 是纯字符串、`candidates` 是对象。
|
||||
* 过滤时必须**同时**按下标保留两者,否则标题会错位到别的别名上
|
||||
* (WebUI `AddressInput.tsx:60-65` 那段注释专门记了这条)。
|
||||
*/
|
||||
candidates: AddressSuggestion[] = [];
|
||||
/** 当前问的是哪一层:`name` | `path` | `session` */
|
||||
kind: string = '';
|
||||
}
|
||||
|
||||
/** 发信请求体 */
|
||||
export class SendMailRequest {
|
||||
to: string = '';
|
||||
|
||||
@ -36,7 +36,13 @@
|
||||
import { ApiClient, ApiError } from '../api/ApiClient';
|
||||
import { CalendarApi } from '../api/CalendarApi';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { CalendarEvent, CalendarEventInput, LunarLabels, LunarRangeResponse } from '../model/Models';
|
||||
import { CalendarEvent, CalendarEventInput, LunarLabels, LunarRangeResponse, AddressSuggestionResponse } from '../model/Models';
|
||||
import {
|
||||
AddressParts, CandidateSets, SuggestQuery,
|
||||
parseParts, queryFor, mergeCandidate, nextActiveIndex,
|
||||
splitEditing, normalizeCandidates, filterSets
|
||||
} from '../model/AddressSuggest';
|
||||
import { KeyCode } from '@kit.InputKit';
|
||||
import { Theme } from '../common/Theme';
|
||||
import { GlassCardModifier, PaneModifier, PressEffectModifier } from '../common/Surface';
|
||||
import { AmIcon } from '../common/Icons';
|
||||
@ -205,6 +211,21 @@ export struct CalendarPage {
|
||||
@State editingId: string = '';
|
||||
@State fTitle: string = '';
|
||||
@State fRecipients: string = '';
|
||||
/*
|
||||
* ★★ 2026-09-21 新增:收件人字段的**地址补全**(用户:「webui 存在好几个
|
||||
* 自动填充位置,比如抄送,转发等」)。
|
||||
*
|
||||
* WebUI 的事件编辑器用的是同一个 `AddressInput`
|
||||
* (`CalendarEventEditor.tsx:471`)⇒ 它一直有补全,鸿蒙这根是裸框。
|
||||
* 复用 `model/AddressSuggest.ts` 的同一套纯逻辑。
|
||||
*/
|
||||
@State fRecipSuggestItems: string[] = [];
|
||||
@State fRecipSuggestTitles: string[] = [];
|
||||
@State fRecipSuggestSources: string[] = [];
|
||||
@State fRecipSuggestUnreads: number[] = [];
|
||||
@State fRecipSuggestActive: number = 0;
|
||||
@State fRecipSuggestOpen: boolean = false;
|
||||
private fRecipTimer: number = -1;
|
||||
@State fReminderText: string = '';
|
||||
@State fRemindBefore: number = 0;
|
||||
@State fRecurrence: string = 'none';
|
||||
@ -348,6 +369,104 @@ export struct CalendarPage {
|
||||
* 把它当致命错误会让“农历服务抖一下”变成“整个日历打不开”—— 那是本末倒置。
|
||||
* 但也不静默吞掉:写 hilog 留下痕迹(便于排查为什么格子上没农历)。
|
||||
*/
|
||||
/**
|
||||
* ★★ 2026-09-21 新增:事件编辑器收件人字段的地址补全。
|
||||
*
|
||||
* 与写信页、转发条**同一套纯逻辑**(`model/AddressSuggest.ts`):
|
||||
* 切段 ↓ 解析 ↓ 查询 ↓ 归一化 ↓ 过滤。
|
||||
*
|
||||
* ★ 收件人字段在日历事件里是**逗号分隔多个**
|
||||
* (`fRecipients.split(',')` 就在提交路径上,`saveEvent` 附近)
|
||||
* ⇒ `allowMultiple = true`,只补全最后一段。
|
||||
*/
|
||||
private onRecipChanged(v: string): void {
|
||||
this.fRecipients = v;
|
||||
if (this.fRecipTimer >= 0) {
|
||||
clearTimeout(this.fRecipTimer);
|
||||
}
|
||||
/* 120ms 防抖,与 WebUI `AddressInput.tsx:79` 同值 */
|
||||
this.fRecipTimer = setTimeout(() => {
|
||||
this.fetchRecipSuggestions();
|
||||
}, 120);
|
||||
}
|
||||
|
||||
private async fetchRecipSuggestions(): Promise<void> {
|
||||
const a: CalendarApi | null = this.api;
|
||||
if (a === null) {
|
||||
return;
|
||||
}
|
||||
const editing: string = splitEditing(this.fRecipients, true).editing;
|
||||
const parts: AddressParts = parseParts(editing);
|
||||
const q: SuggestQuery = queryFor(parts);
|
||||
try {
|
||||
const res: AddressSuggestionResponse = await a.suggestAddress(q.name, q.path);
|
||||
const all: string[] = res.suggestions ?? [];
|
||||
const cands = res.candidates ?? [];
|
||||
/*
|
||||
* 显式循环而不是 `cands.map(...)`:ArkTS 对回调的返回类型推断有限
|
||||
* (`arkts-no-implicit-return-types`),且 `undefined` 会撞
|
||||
* `arkts-no-any-unknown`。写信页那边同样是手写循环。
|
||||
*/
|
||||
const titles: (string | undefined)[] = [];
|
||||
const sources: (string | undefined)[] = [];
|
||||
const unreads: (number | undefined)[] = [];
|
||||
for (let i = 0; i < cands.length; i++) {
|
||||
const c = cands[i];
|
||||
titles.push(c === undefined ? undefined : c.title);
|
||||
sources.push(c === undefined ? undefined : c.source);
|
||||
unreads.push(c === undefined ? undefined : c.unread);
|
||||
}
|
||||
const sets: CandidateSets = normalizeCandidates(all, titles, sources, unreads);
|
||||
const frag: string = parts.hasDot ? parts.session : (parts.hasAt ? parts.path : parts.name);
|
||||
const keep: CandidateSets = filterSets(sets, frag);
|
||||
this.fRecipSuggestItems = keep.items;
|
||||
this.fRecipSuggestTitles = keep.titles;
|
||||
this.fRecipSuggestSources = keep.sources;
|
||||
this.fRecipSuggestUnreads = keep.unreads;
|
||||
this.fRecipSuggestActive = 0;
|
||||
this.fRecipSuggestOpen = keep.items.length > 0;
|
||||
} catch {
|
||||
/* 拉不到候选不是错误:静默收起,能照常手打 */
|
||||
this.fRecipSuggestItems = [];
|
||||
this.fRecipSuggestTitles = [];
|
||||
this.fRecipSuggestOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
private applyRecipSuggestion(choice: string): void {
|
||||
const split = splitEditing(this.fRecipients, true);
|
||||
const parts: AddressParts = parseParts(split.editing);
|
||||
const kind: string = parts.hasDot ? 'session' : (parts.hasAt ? 'path' : 'name');
|
||||
const merged: string = mergeCandidate(parts, kind, choice);
|
||||
this.fRecipients = split.head.length > 0 ? split.head + ' ' + merged : merged;
|
||||
if (kind === 'session') {
|
||||
this.fRecipSuggestOpen = false;
|
||||
this.fRecipSuggestItems = [];
|
||||
} else {
|
||||
this.fetchRecipSuggestions();
|
||||
}
|
||||
}
|
||||
|
||||
private onRecipKey(e: KeyEvent): void {
|
||||
if (e.type !== KeyType.Down) {
|
||||
return;
|
||||
}
|
||||
if (!this.fRecipSuggestOpen || this.fRecipSuggestItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (e.keyCode === KeyCode.KEYCODE_DPAD_DOWN) {
|
||||
this.fRecipSuggestActive = nextActiveIndex(this.fRecipSuggestActive, this.fRecipSuggestItems.length, 1);
|
||||
} else if (e.keyCode === KeyCode.KEYCODE_DPAD_UP) {
|
||||
this.fRecipSuggestActive = nextActiveIndex(this.fRecipSuggestActive, this.fRecipSuggestItems.length, -1);
|
||||
} else if (e.keyCode === KeyCode.KEYCODE_ENTER || e.keyCode === KeyCode.KEYCODE_TAB) {
|
||||
if (this.fRecipSuggestActive >= 0 && this.fRecipSuggestActive < this.fRecipSuggestItems.length) {
|
||||
this.applyRecipSuggestion(this.fRecipSuggestItems[this.fRecipSuggestActive]);
|
||||
}
|
||||
} else if (e.keyCode === KeyCode.KEYCODE_ESCAPE) {
|
||||
this.fRecipSuggestOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadLunar(): Promise<void> {
|
||||
const a: CalendarApi | null = this.api;
|
||||
if (a === null) {
|
||||
@ -1301,8 +1420,56 @@ export struct CalendarPage {
|
||||
.margin({ top: 8 })
|
||||
TextInput({ text: this.fRecipients, placeholder: 'name@path.session' })
|
||||
.onChange((v: string) => {
|
||||
this.fRecipients = v;
|
||||
this.onRecipChanged(v);
|
||||
})
|
||||
.onKeyEvent((e: KeyEvent) => { this.onRecipKey(e); })
|
||||
.onBlur(() => { this.fRecipSuggestOpen = false; })
|
||||
|
||||
/*
|
||||
* 候选列表(与写信页/转发条同一套):不用 `bindPopup`/`bindMenu`,
|
||||
* 它们各有自己的焦点体系会吃掉 ↑↓/Enter。
|
||||
*/
|
||||
if (this.fRecipSuggestOpen && this.fRecipSuggestItems.length > 0) {
|
||||
Column() {
|
||||
ForEach(this.fRecipSuggestItems, (item: string, idx: number) => {
|
||||
Row() {
|
||||
Text(item)
|
||||
.fontSize(Theme.fontTiny)
|
||||
.fontFamily('monospace')
|
||||
.fontColor(idx === this.fRecipSuggestActive ? Theme.accentFor() : Theme.textPrimary)
|
||||
.layoutWeight(1)
|
||||
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
if (idx < this.fRecipSuggestSources.length
|
||||
&& this.fRecipSuggestSources[idx] === 'platform') {
|
||||
Text('平台')
|
||||
.fontSize(9).fontColor(Theme.accentFor())
|
||||
.padding({ left: 3, right: 3, top: 1, bottom: 1 })
|
||||
.borderRadius(4)
|
||||
.backgroundColor(Theme.accentSoftFor(this.isDarkNow))
|
||||
}
|
||||
if (idx < this.fRecipSuggestUnreads.length && this.fRecipSuggestUnreads[idx] > 0) {
|
||||
Text(this.fRecipSuggestUnreads[idx].toString())
|
||||
.fontSize(9).fontColor(Theme.accentFg)
|
||||
.padding({ left: 3, right: 3, top: 1, bottom: 1 })
|
||||
.borderRadius(4)
|
||||
.backgroundColor(Theme.danger)
|
||||
}
|
||||
}
|
||||
.width('100%').height(30).padding({ left: 8, right: 8 })
|
||||
.borderRadius(Theme.radiusControl)
|
||||
.backgroundColor(idx === this.fRecipSuggestActive
|
||||
? Theme.accentSoftFor(this.isDarkNow) : Color.Transparent)
|
||||
.onClick(() => { this.applyRecipSuggestion(item); })
|
||||
}, (item: string, idx: number) => item + '#' + idx.toString())
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 4 })
|
||||
.padding(4)
|
||||
.backgroundColor(Theme.surface)
|
||||
.borderRadius(Theme.radiusControl)
|
||||
.border({ width: 1, color: Theme.border })
|
||||
.clip(true)
|
||||
}
|
||||
|
||||
Text('重复')
|
||||
.fontSize(Theme.fontTiny)
|
||||
|
||||
@ -16,7 +16,7 @@ import { Insets, KEY_WINDOW_INSETS, topInset } from '../model/WindowInsets';
|
||||
import { PressEffectModifier } from '../common/Surface';
|
||||
import { Motion } from '../common/Motion';
|
||||
import { KeyCode } from '@kit.InputKit';
|
||||
import { parseParts, mergeCandidate, nextActiveIndex, queryFor, filterIndexes, splitEditing, AddressParts, EditingSplit, SuggestQuery } from '../model/AddressSuggest';
|
||||
import { parseParts, mergeCandidate, nextActiveIndex, queryFor, filterIndexes, splitEditing, normalizeCandidates, filterSets, AddressParts, CandidateSets, EditingSplit, SuggestQuery } from '../model/AddressSuggest';
|
||||
|
||||
/**
|
||||
* 写邮件窗格。
|
||||
@ -204,43 +204,28 @@ export struct ComposeView {
|
||||
* 取标题 —— `title` 带 `omitempty`,缺键时裸 cast 给的是 `undefined`
|
||||
* (**不是** 类里那个 `= ''`)。所以用 `typeof` 守一道再取。
|
||||
*/
|
||||
const titles: string[] = [];
|
||||
const sources: string[] = [];
|
||||
const unreads: number[] = [];
|
||||
for (let i = 0; i < cands.length; i++) {
|
||||
const c = cands[i];
|
||||
const raw: string | undefined = c === undefined ? undefined : c.title;
|
||||
titles.push(typeof raw === 'string' ? raw : '');
|
||||
/*
|
||||
* `source` 与 `unread` 同样带 `omitempty`
|
||||
* (`platform_sessions.go:97/101`)⇒ 缺键时裸 cast 是 `undefined`。
|
||||
* `Unread` 缺键时给 `undefined`,直接比较 `> 0` 在 ArkTS 里类型不过 ⇒ 守一道。
|
||||
*/
|
||||
const src: string | undefined = c === undefined ? undefined : c.source;
|
||||
sources.push(typeof src === 'string' ? src : '');
|
||||
const un: number | undefined = c === undefined ? undefined : c.unread;
|
||||
unreads.push(typeof un === 'number' ? un : 0);
|
||||
}
|
||||
/*
|
||||
* ★★ 2026-09-21 改:归一化 + 过滤抽到 `model/AddressSuggest.ts`
|
||||
* (`normalizeCandidates` / `filterSets`)。
|
||||
*
|
||||
* 原来这 30 行内联在这里。而转发条、日历事件也要同一套 ⇒
|
||||
* 内联三份必然漂移(一份记得守 `omitempty`、另一份忘了)。
|
||||
* 抽出来后一套实现被三个调用点共用,并被 `cross-client-logic` 跑。
|
||||
*/
|
||||
const sets: CandidateSets = normalizeCandidates(
|
||||
all,
|
||||
cands.map((c) => c === undefined ? undefined : c.title),
|
||||
cands.map((c) => c === undefined ? undefined : c.source),
|
||||
cands.map((c) => c === undefined ? undefined : c.unread));
|
||||
/* 片段:没写 @ 时用 name、写了 @ 用 path、写了 . 用 session(与 queryFor 同层) */
|
||||
const frag: string = parts.hasDot ? parts.session : (parts.hasAt ? parts.path : parts.name);
|
||||
const keep: number[] = filterIndexes(all, titles, frag);
|
||||
const items: string[] = [];
|
||||
const keepTitles: string[] = [];
|
||||
const keepSources: string[] = [];
|
||||
const keepUnreads: number[] = [];
|
||||
for (let i = 0; i < keep.length; i++) {
|
||||
const k: number = keep[i];
|
||||
items.push(all[k]);
|
||||
keepTitles.push(titles[k] ?? '');
|
||||
keepSources.push(sources[k] ?? '');
|
||||
keepUnreads.push(unreads[k] ?? 0);
|
||||
}
|
||||
this.suggestItems = items;
|
||||
this.suggestTitles = keepTitles;
|
||||
this.suggestSources = keepSources;
|
||||
this.suggestUnreads = keepUnreads;
|
||||
const keep: CandidateSets = filterSets(sets, frag);
|
||||
this.suggestItems = keep.items;
|
||||
this.suggestTitles = keep.titles;
|
||||
this.suggestSources = keep.sources;
|
||||
this.suggestUnreads = keep.unreads;
|
||||
this.suggestActive = 0;
|
||||
this.suggestOpen = items.length > 0;
|
||||
this.suggestOpen = keep.items.length > 0;
|
||||
} catch {
|
||||
/*
|
||||
* 拉不到候选**不是错误**:静默收起,用户照常手打地址。
|
||||
|
||||
@ -7,12 +7,18 @@ import { ApiClient, ApiError } from '../api/ApiClient';
|
||||
import { Theme } from '../common/Theme';
|
||||
/* Markdown 渲染(第三方库,鸿蒙原生 ArkTS 引擎,不依赖 WebView)—— 正文用它,不再吐原始 Markdown */
|
||||
import { Markdown, MarkdownController } from '@luvi/lv-markdown-in';
|
||||
import { MailApi, SessionBudget, ThreadApiResponse } from '../api/MailApi';
|
||||
import { MailApi, SessionBudget, ThreadApiResponse, AddressSuggestionResponse } from '../api/MailApi';
|
||||
import { ThreadNode } from '../model/Models';
|
||||
import { SessionApi } from '../api/SessionApi';
|
||||
import { RenameProposal } from '../model/SessionRename';
|
||||
import { AccountManager, AccountInfo } from '../api/AccountManager';
|
||||
import { MailDetail, SendMailRequest, ForwardMailRequest, Address, AttachmentInfo } from '../model/Models';
|
||||
import {
|
||||
AddressParts, CandidateSets, EditingSplit, SuggestQuery,
|
||||
parseParts, queryFor, mergeCandidate, nextActiveIndex,
|
||||
splitEditing, normalizeCandidates, filterSets
|
||||
} from '../model/AddressSuggest';
|
||||
import { KeyCode } from '@kit.InputKit';
|
||||
import { MailDetailParams } from '../model/RouteParams';
|
||||
import { AmIcon } from '../common/Icons';
|
||||
import { saveBinaryFile, IcsSaveResult } from '../common/IcsFile';
|
||||
@ -145,6 +151,26 @@ export struct MailDetailView {
|
||||
/** 抄送输入是否展开(WebUI 的 `ccOpen`:默认收起,点「抄送」才出现) */
|
||||
@State forwardCcOpen: boolean = false;
|
||||
@State forwardComment: string = '';
|
||||
/*
|
||||
* ★★ 2026-09-21 新增:转发条的**地址补全**(用户:「webui 存在好几个自动
|
||||
* 填充位置,比如抄送,转发等」)。
|
||||
*
|
||||
* WebUI 的转发条是两个 `AddressInput`(`MailView.tsx:1087` 抄送 +
|
||||
* 收件人那一个),即**两个都有补全**。鸿蒙这边两个都是裸 `TextInput`。
|
||||
*
|
||||
* 复用与写信页**同一套纯逻辑**(`model/AddressSuggest.ts`):
|
||||
* 切段 ↓ 解析 ↓ 查询 ↓ 归一化 ↓ 过滤 ↓ 拼回。
|
||||
* 这里只放界面状态。
|
||||
*/
|
||||
@State fwdSuggestItems: string[] = [];
|
||||
@State fwdSuggestTitles: string[] = [];
|
||||
@State fwdSuggestSources: string[] = [];
|
||||
@State fwdSuggestUnreads: number[] = [];
|
||||
@State fwdSuggestActive: number = 0;
|
||||
@State fwdSuggestOpen: boolean = false;
|
||||
/** 当前在给哪个字段补全(`'to'` | `'cc'`)—— cc 是多地址模式 */
|
||||
private fwdSuggestField: string = 'to';
|
||||
private fwdSuggestTimer: number = -1;
|
||||
@State sessionId: string = '';
|
||||
@State switchingPerm: boolean = false;
|
||||
/**
|
||||
@ -303,6 +329,113 @@ export struct MailDetailView {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ★★ 2026-09-21 新增:转发条的地址补全(字段无关,与写信页同一套)。
|
||||
*
|
||||
* 三个小方法而不是一个带 `field` 参数的 —— ArkTS 不给函数重载,
|
||||
* 而 `field` 参数会让每个调用点多传一个常量(更容易写错)。
|
||||
* 这里只有 to / cc 两个字段,所以显式分流更好读。
|
||||
*/
|
||||
private onFwdToChanged(v: string): void {
|
||||
this.forwardTo = v;
|
||||
this.scheduleFwdSuggest('to');
|
||||
}
|
||||
|
||||
private onFwdCcChanged(v: string): void {
|
||||
this.forwardCc = v;
|
||||
this.scheduleFwdSuggest('cc');
|
||||
}
|
||||
|
||||
private scheduleFwdSuggest(field: string): void {
|
||||
this.fwdSuggestField = field;
|
||||
if (this.fwdSuggestTimer >= 0) {
|
||||
clearTimeout(this.fwdSuggestTimer);
|
||||
}
|
||||
/* 120ms 防抖与 WebUI `AddressInput.tsx:79` 同值 */
|
||||
this.fwdSuggestTimer = setTimeout(() => {
|
||||
this.fetchFwdSuggestions();
|
||||
}, 120);
|
||||
}
|
||||
|
||||
private async fetchFwdSuggestions(): Promise<void> {
|
||||
const api: MailApi | null = this.mailApi;
|
||||
if (api === null) {
|
||||
return;
|
||||
}
|
||||
const multi: boolean = this.fwdSuggestField === 'cc';
|
||||
const value: string = multi ? this.forwardCc : this.forwardTo;
|
||||
/* 多地址字段只拿**最后一段**去解析(否则 `indexOf('@')` 取到第一条的) */
|
||||
const editing: string = splitEditing(value, multi).editing;
|
||||
const parts: AddressParts = parseParts(editing);
|
||||
const q: SuggestQuery = queryFor(parts);
|
||||
try {
|
||||
const res: AddressSuggestionResponse = await api.suggestAddress(q.name, q.path);
|
||||
const all: string[] = res.suggestions ?? [];
|
||||
const cands = res.candidates ?? [];
|
||||
const sets: CandidateSets = normalizeCandidates(
|
||||
all,
|
||||
cands.map((c) => c === undefined ? undefined : c.title),
|
||||
cands.map((c) => c === undefined ? undefined : c.source),
|
||||
cands.map((c) => c === undefined ? undefined : c.unread));
|
||||
const frag: string = parts.hasDot ? parts.session : (parts.hasAt ? parts.path : parts.name);
|
||||
const keep: CandidateSets = filterSets(sets, frag);
|
||||
this.fwdSuggestItems = keep.items;
|
||||
this.fwdSuggestTitles = keep.titles;
|
||||
this.fwdSuggestSources = keep.sources;
|
||||
this.fwdSuggestUnreads = keep.unreads;
|
||||
this.fwdSuggestActive = 0;
|
||||
this.fwdSuggestOpen = keep.items.length > 0;
|
||||
} catch {
|
||||
/* 拉不到候选不是错误:静默收起,用户照常手打(与写信页同口径) */
|
||||
this.fwdSuggestItems = [];
|
||||
this.fwdSuggestTitles = [];
|
||||
this.fwdSuggestOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 选中一个候选 —— 拼回;多地址时保留前缀(与写信页/WebUI 同拼法) */
|
||||
private applyFwdSuggestion(choice: string): void {
|
||||
const multi: boolean = this.fwdSuggestField === 'cc';
|
||||
const value: string = multi ? this.forwardCc : this.forwardTo;
|
||||
const split: EditingSplit = splitEditing(value, multi);
|
||||
const parts: AddressParts = parseParts(split.editing);
|
||||
const kind: string = parts.hasDot ? 'session' : (parts.hasAt ? 'path' : 'name');
|
||||
const merged: string = mergeCandidate(parts, kind, choice);
|
||||
const next: string = multi && split.head.length > 0 ? split.head + ' ' + merged : merged;
|
||||
if (multi) {
|
||||
this.forwardCc = next;
|
||||
} else {
|
||||
this.forwardTo = next;
|
||||
}
|
||||
if (kind === 'session') {
|
||||
this.fwdSuggestOpen = false;
|
||||
this.fwdSuggestItems = [];
|
||||
} else {
|
||||
this.fetchFwdSuggestions();
|
||||
}
|
||||
}
|
||||
|
||||
/** ↑↓ 换候选 / Enter·Tab 选中 / Esc 收起 —— 与写信页同一套按键 */
|
||||
private onFwdKey(e: KeyEvent): void {
|
||||
if (e.type !== KeyType.Down) {
|
||||
return;
|
||||
}
|
||||
if (!this.fwdSuggestOpen || this.fwdSuggestItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (e.keyCode === KeyCode.KEYCODE_DPAD_DOWN) {
|
||||
this.fwdSuggestActive = nextActiveIndex(this.fwdSuggestActive, this.fwdSuggestItems.length, 1);
|
||||
} else if (e.keyCode === KeyCode.KEYCODE_DPAD_UP) {
|
||||
this.fwdSuggestActive = nextActiveIndex(this.fwdSuggestActive, this.fwdSuggestItems.length, -1);
|
||||
} else if (e.keyCode === KeyCode.KEYCODE_ENTER || e.keyCode === KeyCode.KEYCODE_TAB) {
|
||||
if (this.fwdSuggestActive >= 0 && this.fwdSuggestActive < this.fwdSuggestItems.length) {
|
||||
this.applyFwdSuggestion(this.fwdSuggestItems[this.fwdSuggestActive]);
|
||||
}
|
||||
} else if (e.keyCode === KeyCode.KEYCODE_ESCAPE) {
|
||||
this.fwdSuggestOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开回复条 —— **带共享元素转场**。
|
||||
*
|
||||
@ -1421,13 +1554,68 @@ export struct MailDetailView {
|
||||
|
||||
TextInput({ text: this.forwardTo, placeholder: '新收件人:pi@root.new' })
|
||||
.width('100%').height(38).fontSize(13)
|
||||
.onChange((v: string) => { this.forwardTo = v; })
|
||||
.onChange((v: string) => { this.onFwdToChanged(v); })
|
||||
.onKeyEvent((e: KeyEvent) => { this.onFwdKey(e); })
|
||||
.onBlur(() => { this.fwdSuggestOpen = false; })
|
||||
|
||||
if (this.forwardCcOpen) {
|
||||
TextInput({ text: this.forwardCc, placeholder: '抄送:逗号分隔,可多个' })
|
||||
.width('100%').height(38).fontSize(13)
|
||||
.margin({ top: 8 })
|
||||
.onChange((v: string) => { this.forwardCc = v; })
|
||||
.onChange((v: string) => { this.onFwdCcChanged(v); })
|
||||
.onKeyEvent((e: KeyEvent) => { this.onFwdKey(e); })
|
||||
.onBlur(() => { this.fwdSuggestOpen = false; })
|
||||
}
|
||||
|
||||
/*
|
||||
* ★★ 2026-09-21 新增:候选列表 —— 贴在两个地址框**下方**。
|
||||
*
|
||||
* WebUI `AddressInput` 自身带下拉菜单(`MailView.tsx:1087` 那两个
|
||||
* `AddressInput` 各自持有菜单)⇒ 转发条本来就有补全,只有鸿蒙这边是裸框。
|
||||
*
|
||||
* 不用 `bindPopup`/`bindMenu`:那两者有自己的焦点体系,
|
||||
* 会把 ↑↓/Enter 吃掉,键可达就没了(写信页那段注释同一理由)。
|
||||
*/
|
||||
if (this.fwdSuggestOpen && this.fwdSuggestItems.length > 0) {
|
||||
Column() {
|
||||
ForEach(this.fwdSuggestItems, (item: string, idx: number) => {
|
||||
Row() {
|
||||
Text(item)
|
||||
.fontSize(13)
|
||||
.fontFamily('monospace')
|
||||
.fontColor(idx === this.fwdSuggestActive ? Theme.accentFor() : Theme.textPrimary)
|
||||
.layoutWeight(1)
|
||||
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
if (idx < this.fwdSuggestSources.length
|
||||
&& this.fwdSuggestSources[idx] === 'platform') {
|
||||
Text('平台')
|
||||
.fontSize(10).fontColor(Theme.accentFor())
|
||||
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
|
||||
.borderRadius(4)
|
||||
.backgroundColor(Theme.accentSoftFor(this.isDarkNow))
|
||||
}
|
||||
if (idx < this.fwdSuggestUnreads.length && this.fwdSuggestUnreads[idx] > 0) {
|
||||
Text(this.fwdSuggestUnreads[idx].toString())
|
||||
.fontSize(10).fontColor(Theme.accentFg)
|
||||
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
|
||||
.borderRadius(4)
|
||||
.backgroundColor(Theme.danger)
|
||||
}
|
||||
}
|
||||
.width('100%').height(34).padding({ left: 8, right: 8 })
|
||||
.borderRadius(Theme.radiusControl)
|
||||
.backgroundColor(idx === this.fwdSuggestActive
|
||||
? Theme.accentSoftFor(this.isDarkNow) : Color.Transparent)
|
||||
.onClick(() => { this.applyFwdSuggestion(item); })
|
||||
}, (item: string, idx: number) => item + '#' + idx.toString())
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 4 })
|
||||
.padding(4)
|
||||
.backgroundColor(Theme.surface)
|
||||
.borderRadius(Theme.radiusControl)
|
||||
.border({ width: 1, color: Theme.border })
|
||||
.clip(true)
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user