From f79e0f82dd90df7613c3fd5809749a6fbdcbc763 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 13 Sep 2026 22:18:56 +0800 Subject: [PATCH] =?UTF-8?q?refactor(ohos):=20SettingsPage=201463=E2=86=923?= =?UTF-8?q?89=20=E8=A1=8C=EF=BC=88=E6=A8=A1=E5=9E=8B/=E6=9D=A1=E7=9B=AE?= =?UTF-8?q?=E5=8D=A1/=E5=9B=9B=E4=B8=AA=E9=9D=A2=E6=9D=BF=E6=8B=86?= =?UTF-8?q?=E5=88=86=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - common/SettingsModel.ets(313):设置载荷解析、分类归并、分页切片、 路由常量,纯逻辑无 UI - components/SettingsEntryCard.ets(215):单条设置卡(值编辑/保存) - components/SettingsRootEntries.ets(100):一级入口行 + 状态汇总卡 - components/SettingsHome.ets(95):一级页壳(顶栏/浮层/提示条) - components/ConnectionsPane.ets(347):后端连接 CRUD(自带二级页壳) - components/AppearancePane.ets(265):主题/背景/透明度(自带二级页壳) - components/BackendSettingsPane.ets(172):分类与条目两个二级面板 页面保留 @State 集合、加载/保存编排与 SubDestination 分发;数组仍走 @Link 直传(未引入 AppStorage 数组)。相册选择、重启前台桥等既有行为 与提示文案逐字保留。 验证:hvigorw assembleHap BUILD SUCCESSFUL。 --- .../src/main/ets/common/SettingsModel.ets | 313 ++++ .../main/ets/components/AppearancePane.ets | 265 ++++ .../ets/components/BackendSettingsPane.ets | 172 +++ .../main/ets/components/ConnectionsPane.ets | 347 +++++ .../main/ets/components/SettingsEntryCard.ets | 215 +++ .../src/main/ets/components/SettingsHome.ets | 95 ++ .../ets/components/SettingsRootEntries.ets | 100 ++ .../entry/src/main/ets/pages/SettingsPage.ets | 1294 ++--------------- 8 files changed, 1617 insertions(+), 1184 deletions(-) create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/common/SettingsModel.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/components/AppearancePane.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/components/BackendSettingsPane.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/components/ConnectionsPane.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/components/SettingsEntryCard.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/components/SettingsHome.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/components/SettingsRootEntries.ets diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/SettingsModel.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/SettingsModel.ets new file mode 100644 index 0000000..5c8e903 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/SettingsModel.ets @@ -0,0 +1,313 @@ +/** + * 设置数据模型与纯解析逻辑(无 UI 依赖)。 + * + * 从 pages/SettingsPage.ets 抽出:/settings 的响应解析、key 分区归类、 + * 分页取项都是纯函数,页面只负责把结果落到 @State。 + */ + +/** One settings key card rendered in the editor list. */ +export interface SettingEntry { + key: string; + displayName: string; + description: string; + type: string; // bool | int | duration | select | password | text | string + options: string[]; + value: string; // raw string value as stored by backend + dirty: boolean; +} + +export interface SettingsSection { + id: string; + title: string; + count: number; +} + +export interface SettingMetaRaw { + key: string; + type: string; + displayName: string; + description: string; + category: string; + options: string[]; +} + +export const SETTINGS_PAGE_SIZE: number = 40; + +/** + * 二级页面标识。 + * 一级入口列表(components/SettingsRootEntries.ets)与页面路由表分开成文件后, + * 这些 id 必须只有一个来源 —— 否则改一处就会"点了没反应"。 + */ +export const SUB_NONE: string = ''; +export const SUB_STATUS: string = 'status'; +export const SUB_CONNECTIONS: string = 'connections'; +export const SUB_APPEARANCE: string = 'appearance'; +export const SUB_BACKEND: string = 'backend'; +export const SUB_SECTION: string = 'section'; + +/** /settings 响应解析结果 */ +export interface ParsedSettings { + meta: Record; + values: Record; +} + +/** 分区统计结果 */ +export interface SectionStats { + sections: SettingsSection[]; + /** plugin.* 配置项总数(编辑入口在插件详情页,这里只用于提示去向) */ + pluginKeyCount: number; + /** 涉及的插件个数 */ + pluginConfigCount: number; +} + +/** + * 解析 GET /settings 响应:meta 定义 + 当前值。 + * 值统一转成字符串(后端可能给 bool/number/嵌套对象)。 + */ +export function parseSettingsPayload(body: string): ParsedSettings { + const obj: Record = JSON.parse(body) as Record; + const metaStore: Record = {}; + const valuesStore: Record = {}; + + const rawMeta: Object | undefined = obj['meta']; + if (rawMeta !== undefined && rawMeta !== null) { + const mObj: Record = rawMeta as Record; + for (const mk of Object.keys(mObj)) { + const item: Record = mObj[mk] as Record; + const optsArr: Object | undefined = item['options']; + const opts: string[] = []; + if (optsArr !== undefined && optsArr !== null) { + const oa: Object[] = optsArr as Object[]; + for (let i = 0; i < oa.length; i++) { + opts.push(oa[i] as string); + } + } + const meta: SettingMetaRaw = { + key: item['key'] as string ?? mk, + type: item['type'] as string ?? 'string', + displayName: item['display_name'] as string ?? '', + description: item['description'] as string ?? '', + category: item['category'] as string ?? '', + options: opts, + }; + metaStore[mk] = meta; + } + } + + const rawVals: Object | undefined = obj['settings']; + if (rawVals !== undefined && rawVals !== null) { + const vObj: Record = rawVals as Record; + for (const vk of Object.keys(vObj)) { + if (vk.length === 0) { + continue; + } + const val: Object = vObj[vk]; + let strVal: string; + if (typeof val === 'string') { + strVal = val as string; + } else if (typeof val === 'boolean' || typeof val === 'number') { + strVal = String(val); + } else { + strVal = JSON.stringify(val); + } + valuesStore[vk] = strVal; + } + } + + const parsed: ParsedSettings = { meta: metaStore, values: valuesStore }; + return parsed; +} + +export function metaCategory(metaStore: Record, key: string): string { + const m: SettingMetaRaw | undefined = metaStore[key]; + return m !== undefined && m.category.length > 0 ? m.category : ''; +} + +export function categoryTitle(cat: string): string { + const map: Record = { + 'agent': '智能体', + 'daemon': '守护进程', + 'llm': '大模型', + 'sources': '数据源', + 'input': '输入', + 'paths': '路径', + 'resources': '资源', + 'defaults': '默认值', + 'snapshot': '快照', + 'rollback': '回滚', + }; + const t: string | undefined = map[cat]; + return t !== undefined ? t : cat; +} + +/** + * 把 key 归到分区: + * - core.* → 'core/',展示在「核心」二级页下 + * - plugin.* → 'plugin/',仅用于计数;实际编辑在插件详情页里, + * 不在这里列出(否则同一批 key 会有两个入口) + * - 其余 → 'other' + */ +export function buildSections( + valuesStore: Record, metaStore: Record): SectionStats { + const ids: string[] = []; + const counts: Record = {}; + const titles: Record = {}; + let pluginKeys: number = 0; + const plugNames: string[] = []; + for (const key of Object.keys(valuesStore)) { + if (key.startsWith('plugin.')) { + // 插件配置不在这里列:它属于插件本身,入口在「插件 → 详情 → 插件配置」。 + // 这里只统计,用于提示有多少项在那边。 + pluginKeys = pluginKeys + 1; + const rest: string = key.substring('plugin.'.length); + const dot: number = rest.indexOf('.'); + const plugName: string = dot > 0 ? rest.substring(0, dot) : rest; + if (plugName.length > 0 && plugNames.indexOf(plugName) < 0) { + plugNames.push(plugName); + } + continue; + } + let secId: string; + if (key.startsWith('core.')) { + const cat: string = metaCategory(metaStore, key); + secId = cat.length > 0 ? 'core/' + cat : 'core/misc'; + if (titles[secId] === undefined) { + titles[secId] = cat.length > 0 ? categoryTitle(cat) : '未分类'; + } + } else { + secId = 'other'; + if (titles[secId] === undefined) { + titles[secId] = '其他'; + } + } + if (counts[secId] === undefined) { + counts[secId] = 0; + ids.push(secId); + } + counts[secId] = counts[secId] + 1; + } + ids.sort((a: string, b: string): number => a.localeCompare(b)); + const secs: SettingsSection[] = []; + for (const id of ids) { + secs.push({ id: id, title: titles[id] ?? id, count: counts[id] ?? 0 }); + } + const stats: SectionStats = { + sections: secs, + pluginKeyCount: pluginKeys, + pluginConfigCount: plugNames.length, + }; + return stats; +} + +export function pickInitialSection(sections: SettingsSection[]): string { + for (let i = 0; i < sections.length; i++) { + if (sections[i].id === 'core/agent') { + return 'core/agent'; + } + } + return sections.length > 0 ? sections[0].id : 'core'; +} + +export function keyInSection( + metaStore: Record, key: string, secId: string): boolean { + if (secId === 'other') { + return !key.startsWith('core.') && !key.startsWith('plugin.'); + } + if (secId.startsWith('core/')) { + if (!key.startsWith('core.')) { + return false; + } + const cat: string = secId.substring('core/'.length); + return cat === 'misc' + ? metaCategory(metaStore, key).length === 0 + : metaCategory(metaStore, key) === cat; + } + if (secId.startsWith('plugin/')) { + const p: string = secId.substring('plugin/'.length); + return key.startsWith('plugin.' + p + '.'); + } + return false; +} + +export function activeSectionTitle(sections: SettingsSection[], secId: string): string { + for (let i = 0; i < sections.length; i++) { + if (sections[i].id === secId) { + return sections[i].title; + } + } + return '配置项'; +} + +/** 某个分区的配置项(按 key 字典序,最多 PAGE_SIZE*4 项) */ +export function buildEntries( + valuesStore: Record, + metaStore: Record, + secId: string): SettingEntry[] { + const entries: SettingEntry[] = []; + const keys: string[] = Object.keys(valuesStore).filter((k: string): boolean => { + return keyInSection(metaStore, k, secId); + }); + keys.sort((a: string, b: string): number => a.localeCompare(b)); + const limit: number = Math.min(keys.length, SETTINGS_PAGE_SIZE * 4); + for (let i = 0; i < limit; i++) { + const key: string = keys[i]; + const meta: SettingMetaRaw | undefined = metaStore[key]; + const entry: SettingEntry = { + key: key, + displayName: meta !== undefined && meta.displayName.length > 0 ? meta.displayName : key, + description: meta !== undefined ? meta.description : '', + type: meta !== undefined ? meta.type : 'string', + options: meta !== undefined ? meta.options : [], + value: valuesStore[key] ?? '', + dirty: false, + }; + entries.push(entry); + } + return entries; +} + +/** 覆盖某个 entry 的 value/dirty,返回新数组(保持 @State 数组替换语义) */ +export function withEntryMarked( + entries: SettingEntry[], key: string, value: string, dirty: boolean): SettingEntry[] { + const next: SettingEntry[] = []; + for (let i = 0; i < entries.length; i++) { + const e: SettingEntry = entries[i]; + if (e.key === key) { + const copy: SettingEntry = { + key: e.key, + displayName: e.displayName, + description: e.description, + type: e.type, + options: e.options, + value: value, + dirty: dirty, + }; + next.push(copy); + } else { + next.push(e); + } + } + return next; +} + +/** + * 从 picker 返回的 URI 里取图片后缀(带点)。 + * Image 组件依赖后缀选择解码器;沙箱里存成无后缀文件会静默解码失败, + * 用户看到的就是"背景图设置了却不生效"。取不到后缀时兜底 .jpg。 + */ +export function imageExt(uri: string): string { + let s: string = uri; + const q: number = s.indexOf('?'); + if (q >= 0) { + s = s.substring(0, q); + } + const dot: number = s.lastIndexOf('.'); + const slash: number = s.lastIndexOf('/'); + if (dot > slash && dot < s.length - 1) { + const ext: string = s.substring(dot).toLowerCase(); + if (ext.length <= 5) { + return ext; + } + } + return '.jpg'; +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/AppearancePane.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/AppearancePane.ets new file mode 100644 index 0000000..f15ef4c --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/AppearancePane.ets @@ -0,0 +1,265 @@ +/** + * 「外观」二级页面:主题三选一 + 自定义背景图。 + * + * 从 pages/SettingsPage.ets 抽出。 + * themeMode / bgImage / bgOpacity 用 @Link 与一级页面共享(一级页的 + * 外观行要显示当前主题名,两边必须是同一份数据)。 + */ + +import { connStore } from '../common/ConnStore'; +import { AppSettings, emptySettings } from '../model/Model'; +import { applyThemeMode } from '../common/Constants'; +import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, + ANIM_NORMAL } from '../common/Constants'; +import { imageExt } from '../common/SettingsModel'; +import { userMessage } from '../common/UserError'; +import { SubPageLayer, PlainCard } from './SubPage'; +import { MotionBase } from './MotionBase'; +import { picker, fileIo } from '@kit.CoreFileKit'; +import { common } from '@kit.AbilityKit'; + +@Component +export struct AppearancePane { + @StorageProp('themeIsDark') private isDark: boolean = true; + @Link themeMode: string; + @Link bgImage: string; + @Link bgOpacity: number; + onBack?: () => void; + onToast?: (msg: string, isError: boolean) => void; + @State pickingBg: boolean = false; + + private palette(): ThemePalette { + return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; + } + + private toast(msg: string, isError: boolean): void { + const cb: ((m: string, e: boolean) => void) | undefined = this.onToast; + if (cb !== undefined) { + cb(msg, isError); + } + } + + private applyTheme(mode: string): void { + this.themeMode = mode; + this.persistSettings(); + // 立即翻转全局主题标志,整个 UI 随之切换 + applyThemeMode(mode); + } + + private async pickBackgroundImage(): Promise { + if (this.pickingBg) { + return; + } + this.pickingBg = true; + try { + const options = new picker.PhotoSelectOptions(); + options.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE; + options.maxSelectNumber = 1; + const photoPicker = new picker.PhotoViewPicker(); + const result = await photoPicker.select(options); + if (result.photoUris.length === 0) { + return; + } + const srcUri: string = result.photoUris[0]; + const ctx = getContext(this) as common.UIAbilityContext; + // 文件名带时间戳:Image 组件按 src 字符串做内存缓存, + // 每次都写同一个 bg_image 会让第二次换图看起来"没生效"。 + // 后缀必须保留:Image 组件按扩展名挑选解码器,无后缀的沙箱文件会解码失败, + // 表现就是"设置了背景图但没生效"(onError 里能看到 decode 失败)。 + const destPath: string = ctx.filesDir + '/bg_' + Date.now().toString(36) + imageExt(srcUri); + const srcFile = fileIo.openSync(srcUri, fileIo.OpenMode.READ_ONLY); + const destFile = fileIo.openSync(destPath, + fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC); + fileIo.copyFileSync(srcFile.fd, destFile.fd); + fileIo.closeSync(srcFile); + fileIo.closeSync(destFile); + this.removeOldBgFile(); + // Image 只认带协议头的沙箱 URI,裸路径会被当成资源名而静默失败 + this.bgImage = 'file://' + destPath; + this.persistSettings(); + this.toast('背景图已设置', false); + } catch (e) { + this.toast(userMessage('settings.pickBg', e), true); + } + this.pickingBg = false; + } + + /** 删除上一张背景图文件,避免沙箱里越攒越多 */ + private removeOldBgFile(): void { + const old: string = this.bgImage; + if (old.length === 0) { + return; + } + const path: string = old.startsWith('file://') ? old.substring(7) : old; + try { + fileIo.unlinkSync(path); + } catch (e) { + // 文件可能已不存在,忽略 + } + } + + private clearBackgroundImage(): void { + this.removeOldBgFile(); + this.bgImage = ''; + this.persistSettings(); + this.toast('已清除背景图', false); + } + + private onBgOpacityChange(value: number): void { + this.bgOpacity = value / 100; + this.persistSettings(); + } + + private persistSettings(): void { + const s: AppSettings = emptySettings(); + const old: AppSettings = connStore.getSettings(); + s.lang = old.lang; + s.theme = this.themeMode.length > 0 ? this.themeMode : (old.theme.length > 0 ? old.theme : 'system'); + s.currentConnId = old.currentConnId; + s.bgImage = this.bgImage; + s.bgOpacity = this.bgOpacity; + connStore.saveSettings(s); + AppStorage.set('bgImage', this.bgImage); + AppStorage.set('bgOpacity', this.bgOpacity); + } + + @Builder + ThemeOption(label: string, mode: string) { + // 按压缩放由 MotionBase 统一;flexWeight: 1 让三枚选项在 Row 里继续等分。 + MotionBase({ pressEnabled: true, flexWeight: 1 }) { + Column() { + Text(label) + .fontSize(12) + .fontColor(this.themeMode === mode ? Color.White : this.palette().textSecondary) + // 子节点的颜色迁移要自己声明:父容器的 .animation() 不下传 + .animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }) + } + .justifyContent(FlexAlign.Center) + .width('100%') + .height(34) + .borderRadius(RADIUS_MD) + .backgroundColor(this.themeMode === mode ? this.palette().accent : this.palette().bgHover) + // 三选一的选中态迁移:底色与描边一起过渡。写在 .border 之后、 + // 覆盖它上面的所有状态驱动属性。 + .border({ + width: 1, + color: this.themeMode === mode ? this.palette().accent : this.palette().btnGhostBorder, + }) + .animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }) + .onClick(() => { + this.applyTheme(mode); + }) + } + } + + build() { + SubPageLayer({ + title: '外观', + tab: 3, + onBack: () => { + const cb: (() => void) | undefined = this.onBack; + if (cb !== undefined) { + cb(); + } + }, + }) { + Column() { + PlainCard({ caption: '主题' }) { + Row({ space: 8 }) { + this.ThemeOption('跟随系统', 'system') + this.ThemeOption('浅色', 'light') + this.ThemeOption('深色', 'dark') + } + .width('100%') + + Text(this.themeMode === 'system' + ? '当前跟随系统,系统切换深浅色时自动跟随' + : (this.themeMode === 'dark' ? '当前强制深色主题' : '当前强制浅色主题')) + .fontSize(11) + .fontColor(this.palette().textMuted) + .margin({ top: 10 }) + } + + PlainCard({ caption: '背景图' }) { + Row() { + Column() { + Text('自定义背景图') + .fontSize(14) + .fontColor(this.palette().textPrimary) + Text(this.bgImage.length > 0 ? '已设置背景图' : '未设置背景图') + .fontSize(11) + .fontColor(this.palette().textMuted) + .margin({ top: 2 }) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + + if (this.bgImage.length > 0) { + Button('更换') + .height(28) + .fontSize(12) + .backgroundColor(Color.Transparent) + .border({ width: 1, color: this.palette().btnGhostBorder }) + .fontColor(this.palette().textSecondary) + .margin({ right: 6 }) + .onClick(() => { + this.pickBackgroundImage(); + }) + Button('清除') + .height(28) + .fontSize(12) + .backgroundColor(Color.Transparent) + .border({ width: 1, color: 'rgba(232, 64, 38, 0.45)' }) + .fontColor('#E84026') + .onClick(() => { + this.clearBackgroundImage(); + }) + } else { + Button(this.pickingBg ? '选择中...' : '选择图片') + .height(28) + .fontSize(12) + .backgroundColor(this.palette().accent) + .fontColor(Color.White) + .onClick(() => { + this.pickBackgroundImage(); + }) + } + } + .width('100%') + + if (this.bgImage.length > 0) { + Row() { + Text('透明度') + .fontSize(11) + .fontColor(this.palette().textMuted) + Slider({ + value: Math.round(this.bgOpacity * 100), + min: 5, + max: 60, + step: 1, + }) + .layoutWeight(1) + .selectedColor(this.palette().accent) + .trackColor(this.palette().bgHover) + .margin({ left: 8, right: 8 }) + .onChange((v: number, mode: SliderChangeMode) => { + if (mode === SliderChangeMode.Moving || mode === SliderChangeMode.Click) { + this.onBgOpacityChange(v); + } + }) + Text(Math.round(this.bgOpacity * 100).toString() + '%') + .fontSize(11) + .fontColor(this.palette().textSecondary) + .width(32) + } + .width('100%') + .alignItems(VerticalAlign.Center) + .margin({ top: 12 }) + } + } + } + .width('100%') + .alignItems(HorizontalAlign.Start) + } + } +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/BackendSettingsPane.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/BackendSettingsPane.ets new file mode 100644 index 0000000..6e6be14 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/BackendSettingsPane.ets @@ -0,0 +1,172 @@ +/** + * 「核心配置」相关的两个二级页面:分类列表 + 某个分类的配置项。 + * + * 从 pages/SettingsPage.ets 抽出。 + * 取数/分区/落库都留在页面(它同时要显示"几个分类 · 几项"和错误态), + * 这里只负责渲染与把用户动作转成回调。 + */ + +import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants'; +import { SettingsSection, SettingEntry, activeSectionTitle } from '../common/SettingsModel'; +import { SubPageLayer, NavGroup, NavRow, PlainCard } from './SubPage'; +import { SettingsEntryCard } from './SettingsEntryCard'; + +@Component +export struct BackendSectionsPane { + @StorageProp('themeIsDark') private isDark: boolean = true; + @Prop sections: SettingsSection[] = []; + @Prop pluginKeyCount: number = 0; + @Prop pluginConfigCount: number = 0; + @Prop busy: boolean = false; + @Prop errorText: string = ''; + @Prop activeSection: string = ''; + /** 宽屏右栏正显示分区明细时高亮左侧对应行 */ + @Prop highlightRows: boolean = false; + onBack?: () => void; + onRefresh?: () => void; + onOpenSection?: (id: string) => void; + + private palette(): ThemePalette { + return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; + } + + build() { + SubPageLayer({ + title: '核心配置', + tab: 3, + onBack: () => { + const cb: (() => void) | undefined = this.onBack; + if (cb !== undefined) { + cb(); + } + }, + showRefresh: true, + onRefresh: () => { + const cb: (() => void) | undefined = this.onRefresh; + if (cb !== undefined) { + cb(); + } + }, + }) { + Column() { + if (this.errorText.length > 0) { + PlainCard({ caption: '状态' }) { + Text(this.errorText) + .fontSize(12) + .fontColor('#E84026') + } + } + + NavGroup({ caption: '核心' }) { + ForEach(this.sections, (sec: SettingsSection, idx: number) => { + NavRow({ + icon: $r('app.media.ic_tune'), + title: sec.title, + subtitle: sec.count.toString() + ' 项配置', + showDivider: idx < this.sections.length - 1, + selected: this.highlightRows && this.activeSection === sec.id, + onTap: () => { + const cb: ((id: string) => void) | undefined = this.onOpenSection; + if (cb !== undefined) { + cb(sec.id); + } + }, + }) + }, (sec: SettingsSection) => sec.id + sec.count.toString()) + } + + // 插件配置不在这里编辑:入口在插件页的详情里,这里只指路,避免两处重复入口 + if (this.pluginKeyCount > 0) { + Text('插件的 ' + this.pluginKeyCount.toString() + ' 项配置(' + + this.pluginConfigCount.toString() + ' 个插件)在「插件 → 选择插件 → 插件配置」中修改。') + .fontSize(12) + .fontColor(this.palette().textMuted) + .width('100%') + .padding({ left: 4, right: 4 }) + } + + if (this.sections.length === 0 && !this.busy) { + Text('未获取到配置分类。检查后端连接后点击刷新。') + .fontSize(12) + .fontColor(this.palette().textMuted) + .padding({ left: 4 }) + } + } + .width('100%') + .alignItems(HorizontalAlign.Start) + } + } +} + +@Component +export struct SectionEntriesPane { + @StorageProp('themeIsDark') private isDark: boolean = true; + @Prop sections: SettingsSection[] = []; + @Prop activeSection: string = ''; + @Prop entries: SettingEntry[] = []; + @Prop busy: boolean = false; + onBack?: () => void; + onRefresh?: () => void; + onSaveValue?: (key: string, value: string) => void; + onSaveCurrent?: (key: string) => void; + onEdit?: (key: string, value: string) => void; + + private palette(): ThemePalette { + return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; + } + + build() { + SubPageLayer({ + title: activeSectionTitle(this.sections, this.activeSection), + tab: 3, + onBack: () => { + const cb: (() => void) | undefined = this.onBack; + if (cb !== undefined) { + cb(); + } + }, + showRefresh: true, + onRefresh: () => { + const cb: (() => void) | undefined = this.onRefresh; + if (cb !== undefined) { + cb(); + } + }, + }) { + Column() { + ForEach(this.entries, (entry: SettingEntry) => { + SettingsEntryCard({ + entry: entry, + onSaveValue: (key: string, value: string) => { + const cb: ((k: string, v: string) => void) | undefined = this.onSaveValue; + if (cb !== undefined) { + cb(key, value); + } + }, + onSaveCurrent: (key: string) => { + const cb: ((k: string) => void) | undefined = this.onSaveCurrent; + if (cb !== undefined) { + cb(key); + } + }, + onEdit: (key: string, value: string) => { + const cb: ((k: string, v: string) => void) | undefined = this.onEdit; + if (cb !== undefined) { + cb(key, value); + } + }, + }) + }, (entry: SettingEntry) => entry.key + '|' + entry.value + '|' + (entry.dirty ? 'd' : 'c')) + + if (!this.busy && this.entries.length === 0) { + Text('该分区暂无配置项') + .fontSize(12) + .fontColor(this.palette().textMuted) + .padding(16) + } + } + .width('100%') + .alignItems(HorizontalAlign.Start) + } + } +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/ConnectionsPane.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/ConnectionsPane.ets new file mode 100644 index 0000000..4d0279f --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/ConnectionsPane.ets @@ -0,0 +1,347 @@ +/** + * 「后端连接」二级页面。 + * + * 从 pages/SettingsPage.ets 抽出:连接列表 UI、增删改表单与其状态、 + * 以及切换连接后必须做的连带动作(刷新 ApiClient / 重启前台桥) + * 都属于这一个功能域,收在一个组件里。 + * + * connections / currentId 用 @Link 与一级页面共享:一级页的入口行 + * 要显示"几个连接配置"和当前连接名,两边必须是同一份数据。 + */ + +import { apiClient } from '../common/ApiClient'; +import { connStore } from '../common/ConnStore'; +import { restartForegroundBridge } from '../common/DeviceBridgeSession'; +import { ConnectionConfig } from '../model/Model'; +import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_SM } from '../common/Constants'; +import { SubPageLayer, PlainCard } from './SubPage'; +import { common } from '@kit.AbilityKit'; + +@Component +export struct ConnectionsPane { + @StorageProp('themeIsDark') private isDark: boolean = true; + @Link connections: ConnectionConfig[]; + @Link currentId: string; + onBack?: () => void; + onToast?: (msg: string, isError: boolean) => void; + @State showAddForm: boolean = false; + @State addFormVisible: boolean = false; + /** + * 表单当前在编辑哪条连接:空串表示新建。 + * + * 之前只有"添加"入口,ConnStore.updateConnection 写好了却没有任何调用者, + * 于是地址填错的连接只能删掉重建(API Key 也得重敲)。同一套表单 + * 靠这个 id 区分保存走 add 还是 update。 + */ + @State editingId: string = ''; + @State editUrl: string = ''; + @State editApiKey: string = ''; + @State editName: string = ''; + + private palette(): ThemePalette { + return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; + } + + private toast(msg: string, isError: boolean): void { + const cb: ((m: string, e: boolean) => void) | undefined = this.onToast; + if (cb !== undefined) { + cb(msg, isError); + } + } + + private currentConnName(): string { + for (let i = 0; i < this.connections.length; i++) { + if (this.connections[i].id === this.currentId) { + return this.connections[i].name; + } + } + return '未配置'; + } + + private selectConnection(id: string): void { + connStore.setCurrent(id).then(() => { + const cur: ConnectionConfig | null = connStore.getCurrentConnection(); + if (cur !== null) { + apiClient.setConnection(cur); + } + this.connections = connStore.getConnections(); + restartForegroundBridge(getContext(this) as common.UIAbilityContext); + this.toast('已切换连接', false); + }); + } + + private addConnection(): void { + const name: string = this.editName.trim(); + const url: string = this.editUrl.trim(); + const apiKey: string = this.editApiKey.trim(); + if (name.length === 0 || url.length === 0) { + this.toast('名称和地址不能为空', true); + return; + } + if (this.editingId.length > 0) { + this.updateConnection(this.editingId, name, url, apiKey); + return; + } + connStore.addConnection(name, url, apiKey).then(() => { + this.closeConnForm(); + const cur = connStore.getCurrentConnection(); + if (cur !== null) { + apiClient.setConnection(cur); + } + this.connections = connStore.getConnections(); + restartForegroundBridge(getContext(this) as common.UIAbilityContext); + this.toast('连接已添加', false); + }); + } + + /** + * 保存对已有连接的修改。 + * + * 修改当前生效的连接后必须重新 setConnection:ApiClient 持有的是 + * ConnectionConfig 的引用快照,不刷新的话后续请求还会打到旧地址。 + */ + private updateConnection(id: string, name: string, url: string, apiKey: string): void { + connStore.updateConnection(id, name, url, apiKey).then(() => { + this.closeConnForm(); + const cur = connStore.getCurrentConnection(); + if (cur !== null) { + apiClient.setConnection(cur); + } + this.connections = connStore.getConnections(); + restartForegroundBridge(getContext(this) as common.UIAbilityContext); + this.toast('连接已更新', false); + }); + } + + /** 打开表单:id 为空是新建,非空是编辑并回填原值(API Key 一并带出,避免用户重敲)。 */ + private openConnForm(conn: ConnectionConfig | null): void { + this.showAddForm = true; + this.addFormVisible = false; + if (conn === null) { + this.editingId = ''; + this.editName = ''; + this.editUrl = ''; + this.editApiKey = ''; + } else { + this.editingId = conn.id; + this.editName = conn.name; + this.editUrl = conn.url; + this.editApiKey = conn.apiKey; + } + setTimeout(() => { + this.addFormVisible = true; + }, 30); + } + + private closeConnForm(): void { + this.showAddForm = false; + this.addFormVisible = false; + this.editingId = ''; + this.editName = ''; + this.editUrl = ''; + this.editApiKey = ''; + } + + private deleteConnection(id: string): void { + connStore.deleteConnection(id).then(() => { + this.connections = connStore.getConnections(); + const cur: ConnectionConfig | null = connStore.getCurrentConnection(); + if (cur !== null) { + apiClient.setConnection(cur); + } else { + apiClient.clearConnection(); + } + restartForegroundBridge(getContext(this) as common.UIAbilityContext); + this.toast('连接已删除', false); + }); + } + + build() { + SubPageLayer({ + title: '后端连接', + tab: 3, + onBack: () => { + const cb: (() => void) | undefined = this.onBack; + if (cb !== undefined) { + cb(); + } + }, + }) { + Column() { + PlainCard({ caption: '当前连接' }) { + Row() { + Column({ space: 3 }) { + Text(this.currentConnName()) + .fontSize(15) + .fontColor(this.palette().textPrimary) + Text(this.currentId.length > 0 ? '已激活,用于所有请求' : '尚未选择连接') + .fontSize(11) + .fontColor(this.palette().textMuted) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + + Button('+ 添加') + .height(30) + .fontSize(12) + .backgroundColor(this.palette().accent) + .fontColor(Color.White) + .onClick(() => { + this.openConnForm(null); + }) + } + .width('100%') + .alignItems(VerticalAlign.Center) + + if (this.showAddForm) { + Column() { + Text(this.editingId.length > 0 ? '编辑连接' : '新建连接') + .fontSize(12) + .fontColor(this.palette().textSecondary) + .margin({ bottom: 10 }) + TextInput({ placeholder: '名称 (如 HomeAgent)', text: this.editName }) + .height(36).fontSize(13).fontColor(this.palette().textPrimary) + .placeholderColor(this.palette().textMuted).backgroundColor(this.palette().bgInput) + .borderRadius(RADIUS_SM).border({ width: 1, color: this.palette().border }) + .margin({ bottom: 10 }) + .onChange((v: string) => { + this.editName = v; + }) + TextInput({ placeholder: '地址 (域名或 http://192.168.1.100:8080)', text: this.editUrl }) + .height(36).fontSize(13).fontColor(this.palette().textPrimary) + .placeholderColor(this.palette().textMuted).backgroundColor(this.palette().bgInput) + .borderRadius(RADIUS_SM).border({ width: 1, color: this.palette().border }) + .margin({ bottom: 10 }) + .onChange((v: string) => { + this.editUrl = v; + }) + TextInput({ placeholder: 'API Key (可选)', text: this.editApiKey }) + .height(36).fontSize(13).fontColor(this.palette().textPrimary) + .placeholderColor(this.palette().textMuted).backgroundColor(this.palette().bgInput) + .borderRadius(RADIUS_SM).border({ width: 1, color: this.palette().border }) + .type(InputType.Password).margin({ bottom: 12 }) + .onChange((v: string) => { + this.editApiKey = v; + }) + + Row() { + Button('取消') + .height(30) + .fontSize(12) + .backgroundColor(Color.Transparent) + .border({ width: 1, color: this.palette().btnGhostBorder }) + .fontColor(this.palette().textSecondary) + .onClick(() => { + this.closeConnForm(); + }) + Blank() + Button('保存') + .height(30) + .fontSize(12) + .backgroundColor(this.palette().accent) + .fontColor(Color.White) + .onClick(() => { + this.addConnection(); + }) + } + .width('100%') + } + .width('100%') + .padding(12) + .borderRadius(RADIUS_MD) + .backgroundColor(this.palette().bgHover) + .border({ width: 1, color: this.palette().kvBorder }) + .margin({ top: 12 }) + .alignItems(HorizontalAlign.Start) + .opacity(this.addFormVisible ? 1 : 0) + .translate({ y: this.addFormVisible ? 0 : 12 }) + .animation({ duration: 220, curve: Curve.EaseOut }) + } + } + + PlainCard({ caption: '全部连接' }) { + if (this.connections.length === 0) { + Text('暂无连接。点击上方“添加”配置后端地址。') + .fontSize(12) + .fontColor(this.palette().textMuted) + } + ForEach(this.connections, (conn: ConnectionConfig) => { + Row() { + Circle({ width: 8, height: 8 }) + .fill(conn.id === this.currentId ? this.palette().accent : '#77809A') + .margin({ right: 10 }) + Column() { + Text(conn.name) + .fontSize(13) + .fontColor(this.palette().textPrimary) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(conn.url) + .fontSize(11) + .fontColor(this.palette().textMuted) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + + if (conn.id !== this.currentId) { + Button('切换') + .height(26) + .fontSize(11) + .backgroundColor(Color.Transparent) + .border({ width: 1, color: this.palette().btnGhostBorder }) + .fontColor(this.palette().textSecondary) + .margin({ right: 6 }) + .onClick(() => { + this.selectConnection(conn.id); + }) + } else { + Text('使用中') + .fontSize(11) + .fontColor(this.palette().accent) + .padding({ left: 8, right: 8, top: 2, bottom: 2 }) + .borderRadius(RADIUS_SM) + .backgroundColor(this.palette().accentBg) + .margin({ right: 6 }) + } + Button('编辑') + .height(26) + .fontSize(11) + .backgroundColor(Color.Transparent) + .border({ width: 1, color: this.palette().btnGhostBorder }) + .fontColor(this.palette().textSecondary) + .margin({ right: 6 }) + .onClick(() => { + this.openConnForm(conn); + }) + Button('删除') + .height(26) + .fontSize(11) + .backgroundColor(Color.Transparent) + .border({ width: 1, color: 'rgba(232, 64, 38, 0.45)' }) + .fontColor('#E84026') + .onClick(() => { + this.deleteConnection(conn.id); + }) + } + .width('100%') + .padding(10) + .borderRadius(RADIUS_SM) + .backgroundColor(this.palette().bgHover) + .border({ + width: { left: 3 }, + color: conn.id === this.currentId ? this.palette().accent : Color.Transparent, + }) + .margin({ bottom: 6 }) + // 键里带上 name/url:ForEach 对相同键只更新绑定、不重跑 @Builder 体, + // 只用 id 做键时改完地址这一行还显示旧值。行内没有 TextInput, + // 因此把可变字段放进键不会有"编辑时焦点被销毁"的副作用。 + }, (conn: ConnectionConfig) => conn.id + '|' + conn.name + '|' + conn.url) + } + } + .width('100%') + .alignItems(HorizontalAlign.Start) + } + } +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/SettingsEntryCard.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/SettingsEntryCard.ets new file mode 100644 index 0000000..df23060 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/SettingsEntryCard.ets @@ -0,0 +1,215 @@ +/** + * 单个配置项卡片。 + * + * 从 pages/SettingsPage.ets 抽出:按 type 分发控件(bool/select/password/text/其他), + * 值的保存与「未保存」标记交回页面(页面上持有 valuesStore 与 entries)。 + */ + +import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_SM, + ANIM_FAST, ANIM_NORMAL } from '../common/Constants'; +import { SettingEntry } from '../common/SettingsModel'; + +@Component +export struct SettingsEntryCard { + @StorageProp('themeIsDark') private isDark: boolean = true; + @Prop entry: SettingEntry; + /** bool 开关 / select 选项:值已知,直接落库 */ + onSaveValue?: (key: string, value: string) => void; + /** 输入类控件的保存:由页面取该 key 的最新编辑值再落库 */ + onSaveCurrent?: (key: string) => void; + /** 输入框内容变化:只更新本地标记,不请求 */ + onEdit?: (key: string, value: string) => void; + + private palette(): ThemePalette { + return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; + } + + private saveValue(value: string): void { + const cb: ((key: string, value: string) => void) | undefined = this.onSaveValue; + if (cb !== undefined) { + cb(this.entry.key, value); + } + } + + private saveCurrent(): void { + const cb: ((key: string) => void) | undefined = this.onSaveCurrent; + if (cb !== undefined) { + cb(this.entry.key); + } + } + + private editValue(value: string): void { + const cb: ((key: string, value: string) => void) | undefined = this.onEdit; + if (cb !== undefined) { + cb(this.entry.key, value); + } + } + + build() { + Column() { + Row() { + Text(this.entry.displayName) + .fontSize(13) + .fontWeight(FontWeight.Medium) + .fontColor(this.palette().textPrimary) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + Text(this.entry.dirty ? '未保存' : this.entry.type) + .fontSize(10) + .fontColor(this.entry.dirty ? '#D99A2B' : this.palette().textMuted) + .padding({ left: 6, right: 6, top: 1, bottom: 1 }) + .borderRadius(RADIUS_SM) + .backgroundColor(this.entry.dirty ? 'rgba(217, 154, 43, 0.16)' : this.palette().bgHover) + } + .width('100%') + + Text(this.entry.key) + .fontSize(10) + .fontColor(this.palette().textMuted) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .margin({ top: 1 }) + + if (this.entry.description.length > 0) { + Text(this.entry.description) + .fontSize(11) + .fontColor(this.palette().textSecondary) + .maxLines(3) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .margin({ top: 3 }) + } + + // control row per type + if (this.entry.type === 'bool') { + Row() { + Text(this.entry.value === 'true' ? 'true' : 'false') + .fontSize(12) + .fontColor(this.entry.value === 'true' ? '#17A964' : this.palette().textMuted) + .animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }) + Blank() + Toggle({ type: ToggleType.Switch, isOn: this.entry.value === 'true' }) + .selectedColor(this.palette().accent) + .onChange((on: boolean) => { + this.saveValue(on ? 'true' : 'false'); + }) + } + .width('100%') + .margin({ top: 8 }) + } else if (this.entry.type === 'select' && this.entry.options.length > 0) { + Flex({ + direction: FlexDirection.Row, + justifyContent: FlexAlign.Start, + alignItems: ItemAlign.Center, + wrap: FlexWrap.Wrap, + }) { + ForEach(this.entry.options, (opt: string) => { + Button(opt) + .height(26) + .fontSize(11) + .margin({ right: 6, bottom: 6 }) + .backgroundColor(this.entry.value === opt ? this.palette().accent : this.palette().bgHover) + .fontColor(this.entry.value === opt ? Color.White : this.palette().textSecondary) + // 选中项迁移:底色与字色一起过渡,避免整排选项同时硬切 + .animation({ duration: ANIM_FAST, curve: Curve.EaseOut }) + .onClick(() => { + this.saveValue(opt); + }) + }, (opt: string) => opt) + } + .width('100%') + .margin({ top: 8 }) + } else if (this.entry.type === 'password') { + Row() { + TextInput({ text: this.entry.value }) + .height(36) + .fontSize(13) + .fontColor(this.palette().textPrimary) + .placeholderColor(this.palette().textMuted) + .backgroundColor(this.palette().bgInput) + .borderRadius(RADIUS_SM) + .border({ width: 1, color: this.palette().border }) + .type(InputType.Password) + .layoutWeight(1) + .onChange((v: string) => { + this.editValue(v); + }) + Button('保存') + .height(30) + .fontSize(12) + .backgroundColor(this.entry.dirty ? '#D99A2B' : this.palette().accent) + .fontColor(Color.White) + .margin({ left: 8 }) + .onClick(() => { + this.saveCurrent(); + }) + } + .width('100%') + .margin({ top: 8 }) + } else if (this.entry.type === 'text') { + TextArea({ text: this.entry.value }) + .width('100%') + .fontSize(13) + .fontColor(this.palette().textPrimary) + .backgroundColor(this.palette().bgInput) + .borderRadius(RADIUS_SM) + .border({ width: 1, color: this.palette().border }) + .constraintSize({ minHeight: 60, maxHeight: 200 }) + .margin({ top: 8 }) + .onChange((v: string) => { + this.editValue(v); + }) + Row() { + Blank() + Button('保存') + .height(30) + .fontSize(12) + .backgroundColor(this.entry.dirty ? '#D99A2B' : this.palette().accent) + .fontColor(Color.White) + .onClick(() => { + this.saveCurrent(); + }) + } + .width('100%') + .margin({ top: 6 }) + } else { + // string / int / duration + Row() { + TextInput({ text: this.entry.value }) + .height(36) + .fontSize(13) + .fontColor(this.palette().textPrimary) + .placeholderColor(this.palette().textMuted) + .backgroundColor(this.palette().bgInput) + .borderRadius(RADIUS_SM) + .border({ width: 1, color: this.palette().border }) + .layoutWeight(1) + .onChange((v: string) => { + this.editValue(v); + }) + Button('保存') + .height(30) + .fontSize(12) + .backgroundColor(this.entry.dirty ? '#D99A2B' : this.palette().accent) + .fontColor(Color.White) + .margin({ left: 8 }) + .onClick(() => { + this.saveCurrent(); + }) + } + .width('100%') + .margin({ top: 8 }) + } + } + .width('100%') + .padding(14) + .borderRadius(RADIUS_MD) + .backgroundColor(this.palette().bgCard) + .border({ + width: 1, + color: this.entry.dirty ? '#D99A2B' : this.palette().glassBorder, + }) + .margin({ bottom: 10 }) + .alignItems(HorizontalAlign.Start) + } +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/SettingsHome.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/SettingsHome.ets new file mode 100644 index 0000000..848a941 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/SettingsHome.ets @@ -0,0 +1,95 @@ +/** + * 设置一级页的页面骨架:滚动区 + 顶栏 + 悬浮区 + 轻提示。 + * + * 从 pages/SettingsPage.ets 抽出:页面本身只剩数据流与导航表, + * 这一层是纯布局 —— 所有计数/文案都由页面算好传进来。 + */ + +import { handleNavOnScroll } from '../common/NavBarController'; +import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants'; +import { PageTopBar, NavFloatOverlay, NavFloatRow } from './PageTopBar'; +import { SettingsRootEntries } from './SettingsRootEntries'; +import { ToastBar } from './ToastBar'; + +@Component +export struct SettingsHome { + @StorageProp('themeIsDark') private isDark: boolean = true; + /** 一级入口列表所需的计数/文案(见 SettingsRootEntries) */ + @Prop connCount: number = 0; + @Prop connName: string = ''; + @Prop sectionCount: number = 0; + @Prop coreKeys: number = 0; + @Prop busy: boolean = false; + @Prop errorText: string = ''; + @Prop themeLabel: string = ''; + @Prop activeSub: string = ''; + @Prop isWide: boolean = false; + /** 保存进行中:悬浮区显示转圈 */ + @Prop savingCount: number = 0; + @Prop toastMsg: string = ''; + @Prop toastIsError: boolean = false; + onOpen?: (id: string) => void; + + private palette(): ThemePalette { + return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; + } + + build() { + Stack({ alignContent: Alignment.Bottom }) { + Column() { + Scroll() { + Column() { + SettingsRootEntries({ + connCount: this.connCount, + connName: this.connName, + sectionCount: this.sectionCount, + coreKeys: this.coreKeys, + busy: this.busy, + errorText: this.errorText, + themeLabel: this.themeLabel, + activeSub: this.activeSub, + isWide: this.isWide, + onOpen: (id: string) => { + const cb: ((id: string) => void) | undefined = this.onOpen; + if (cb !== undefined) { + cb(id); + } + }, + }) + } + .width('100%') + .padding({ left: 16, right: 16, top: 76, bottom: 174 }) + } + .width('100%') + .height('100%') + .scrollBar(BarState.Off) + .align(Alignment.Top) + .onDidScroll((xOffset: number, yOffset: number, state: ScrollState) => { + handleNavOnScroll(state); + }) + } + .width('100%') + .height('100%') + + PageTopBar({ title: '设置' }) + + // 一级悬浮区:仅在保存进行中显示一个转圈; + // 已保存/未保存的常驻徽标按用户要求去掉(保存本来就是即时的,不需要状态吊牌) + NavFloatOverlay({ tab: 3 }) { + NavFloatRow() { + if (this.savingCount > 0) { + LoadingProgress() + .width(14) + .height(14) + .color(this.palette().accent) + } + } + } + + ToastBar({ msg: this.toastMsg, isError: this.toastIsError }) + } + .width('100%') + .height('100%') + .backgroundColor(Color.Transparent) + } +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/SettingsRootEntries.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/SettingsRootEntries.ets new file mode 100644 index 0000000..f99b0a8 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/SettingsRootEntries.ets @@ -0,0 +1,100 @@ +/** + * 设置一级页的入口列表。 + * + * 从 pages/SettingsPage.ets 抽出:纯展示 + 跳转回调, + * 所有计数/文案由页面算好传进来(页面才是这些状态的持有者)。 + */ + +import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants'; +import { SUB_STATUS, SUB_CONNECTIONS, SUB_BACKEND, SUB_SECTION, SUB_APPEARANCE } from '../common/SettingsModel'; +import { StatusSummaryCard } from './StatusCards'; +import { NavGroup, NavRow } from './SubPage'; + +@Component +export struct SettingsRootEntries { + @StorageProp('themeIsDark') private isDark: boolean = true; + /** 连接配置条数 */ + @Prop connCount: number = 0; + /** 当前生效连接的展示名 */ + @Prop connName: string = ''; + /** 后端配置:分类数 / 核心项数 / 是否加载中 / 错误文案 */ + @Prop sectionCount: number = 0; + @Prop coreKeys: number = 0; + @Prop busy: boolean = false; + @Prop errorText: string = ''; + /** 当前主题的中文名(一级行右侧摘要值) */ + @Prop themeLabel: string = ''; + /** 宽屏分栏时用来高亮右栏对应的入口行 */ + @Prop activeSub: string = ''; + @Prop isWide: boolean = false; + onOpen?: (id: string) => void; + + private palette(): ThemePalette { + return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; + } + + private open(id: string): void { + const cb: ((id: string) => void) | undefined = this.onOpen; + if (cb !== undefined) { + cb(id); + } + } + + build() { + Column() { + // 运行状态摘要(原「状态」Tab):整卡可点,进入明细二级页 + StatusSummaryCard({ + onTap: () => { + this.open(SUB_STATUS); + }, + }) + + NavGroup({ caption: '连接' }) { + NavRow({ + icon: $r('app.media.ic_link'), + title: '后端连接', + subtitle: this.connCount.toString() + ' 个连接配置', + value: this.connName, + selected: this.isWide && this.activeSub === SUB_CONNECTIONS, + onTap: () => { + this.open(SUB_CONNECTIONS); + }, + }) + NavRow({ + icon: $r('app.media.ic_tune'), + title: '核心配置', + subtitle: this.sectionCount.toString() + ' 个分类 · ' + this.coreKeys.toString() + ' 项', + value: this.busy ? '加载中' : (this.errorText.length > 0 ? '不可用' : ''), + showDivider: false, + selected: this.isWide && (this.activeSub === SUB_BACKEND || this.activeSub === SUB_SECTION), + onTap: () => { + this.open(SUB_BACKEND); + }, + }) + } + + NavGroup({ caption: '个性化' }) { + NavRow({ + icon: $r('app.media.ic_theme'), + title: '外观', + subtitle: '主题与背景图', + value: this.themeLabel, + showDivider: false, + selected: this.isWide && this.activeSub === SUB_APPEARANCE, + onTap: () => { + this.open(SUB_APPEARANCE); + }, + }) + } + + if (this.errorText.length > 0) { + Text(this.errorText) + .fontSize(12) + .fontColor('#E84026') + .padding({ left: 4, right: 4 }) + } + } + .width('100%') + .alignItems(HorizontalAlign.Start) + } +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/pages/SettingsPage.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/SettingsPage.ets index e4a9ecd..a6bafdf 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/pages/SettingsPage.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/SettingsPage.ets @@ -1,52 +1,28 @@ import { apiClient } from '../common/ApiClient'; import { userMessage, noConnectionMessage } from '../common/UserError'; import { connStore } from '../common/ConnStore'; -import { handleNavOnScroll } from '../common/NavBarController'; import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry'; -import { ConnectionConfig, AppSettings, emptySettings } from '../model/Model'; -import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, applyThemeMode, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants'; -import { ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants'; -import { MotionBase } from '../components/MotionBase'; -import { picker, fileIo } from '@kit.CoreFileKit'; -import { common } from '@kit.AbilityKit'; -import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar'; -import { SubPageLayer, NavGroup, NavRow, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage'; -import { StatusSummaryCard, StatusDetailContent } from '../components/StatusCards'; +import { ConnectionConfig, AppSettings } from '../model/Model'; +import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants'; +import { RADIUS_MD, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants'; +import { SubPageLayer, markSubPageOpen, subPageParam } from '../components/SubPage'; +import { StatusDetailContent } from '../components/StatusCards'; +import { SettingsHome } from '../components/SettingsHome'; +import { ConnectionsPane } from '../components/ConnectionsPane'; +import { AppearancePane } from '../components/AppearancePane'; +import { BackendSectionsPane, SectionEntriesPane } from '../components/BackendSettingsPane'; +import { + SettingEntry, SettingsSection, SettingMetaRaw, ParsedSettings, SectionStats, + parseSettingsPayload, buildSections, pickInitialSection, buildEntries, withEntryMarked, + SUB_NONE, SUB_STATUS, SUB_CONNECTIONS, SUB_APPEARANCE, SUB_BACKEND, SUB_SECTION, +} from '../common/SettingsModel'; import { statusStore } from '../common/StatusStore'; -import { restartForegroundBridge } from '../common/DeviceBridgeSession'; - -/** One settings key card rendered in the editor list. */ -interface SettingEntry { - key: string; - displayName: string; - description: string; - type: string; // bool | int | duration | select | password | text | string - options: string[]; - value: string; // raw string value as stored by backend - dirty: boolean; -} - -interface SettingsSection { - id: string; - title: string; - count: number; -} interface SaveSettingBody { key: string; value: string; } -const PAGE_SIZE: number = 40; - -/** 二级页面标识 */ -const SUB_NONE: string = ''; -const SUB_STATUS: string = 'status'; -const SUB_CONNECTIONS: string = 'connections'; -const SUB_APPEARANCE: string = 'appearance'; -const SUB_BACKEND: string = 'backend'; -const SUB_SECTION: string = 'section'; - @Component export struct SettingsPage { @StorageProp('themeIsDark') private isDark: boolean = true; @@ -58,24 +34,10 @@ export struct SettingsPage { @State activeSub: string = SUB_NONE; @State connections: ConnectionConfig[] = []; @State currentId: string = ''; - @State editUrl: string = ''; - @State editApiKey: string = ''; - @State editName: string = ''; - @State showAddForm: boolean = false; - @State addFormVisible: boolean = false; - /** - * 表单当前在编辑哪条连接:空串表示新建。 - * - * 之前只有"添加"入口,ConnStore.updateConnection 写好了却没有任何调用者, - * 于是地址填错的连接只能删掉重建(API Key 也得重敲)。同一套表单 - * 靠这个 id 区分保存走 add 还是 update。 - */ - @State editingId: string = ''; @State themeMode: string = 'system'; @State lang: string = 'zh'; @State bgImage: string = ''; @State bgOpacity: number = 0.25; - @State pickingBg: boolean = false; /** 二级页面导航栈:系统返回手势/三键返回直接作用于它 */ private navStack: NavPathStack = new NavPathStack(); @@ -171,10 +133,6 @@ export struct SettingsPage { return '跟随系统'; } - private totalKeyCount(): number { - return Object.keys(this.valuesStore).length; - } - /** 核心 + 其他(不含 plugin.*,那些在插件详情页里) */ private coreKeyCount(): number { let n: number = 0; @@ -184,203 +142,6 @@ export struct SettingsPage { return n; } - private activeSectionTitle(): string { - for (let i = 0; i < this.sections.length; i++) { - if (this.sections[i].id === this.activeSection) { - return this.sections[i].title; - } - } - return '配置项'; - } - - // ===================== connections ===================== - - private selectConnection(id: string): void { - connStore.setCurrent(id).then(() => { - const cur: ConnectionConfig | null = connStore.getCurrentConnection(); - if (cur !== null) { - apiClient.setConnection(cur); - } - this.loadConnections(); - restartForegroundBridge(getContext(this) as common.UIAbilityContext); - this.showToast('已切换连接', false); - }); - } - - private addConnection(): void { - const name: string = this.editName.trim(); - const url: string = this.editUrl.trim(); - const apiKey: string = this.editApiKey.trim(); - if (name.length === 0 || url.length === 0) { - this.showToast('名称和地址不能为空', true); - return; - } - if (this.editingId.length > 0) { - this.updateConnection(this.editingId, name, url, apiKey); - return; - } - connStore.addConnection(name, url, apiKey).then(() => { - this.closeConnForm(); - const cur = connStore.getCurrentConnection(); - if (cur !== null) { - apiClient.setConnection(cur); - } - this.loadConnections(); - restartForegroundBridge(getContext(this) as common.UIAbilityContext); - this.showToast('连接已添加', false); - }); - } - - /** - * 保存对已有连接的修改。 - * - * 修改当前生效的连接后必须重新 setConnection:ApiClient 持有的是 - * ConnectionConfig 的引用快照,不刷新的话后续请求还会打到旧地址。 - */ - private updateConnection(id: string, name: string, url: string, apiKey: string): void { - connStore.updateConnection(id, name, url, apiKey).then(() => { - this.closeConnForm(); - const cur = connStore.getCurrentConnection(); - if (cur !== null) { - apiClient.setConnection(cur); - } - this.loadConnections(); - restartForegroundBridge(getContext(this) as common.UIAbilityContext); - this.showToast('连接已更新', false); - }); - } - - /** 打开表单:id 为空是新建,非空是编辑并回填原值(API Key 一并带出,避免用户重敲)。 */ - private openConnForm(conn: ConnectionConfig | null): void { - this.showAddForm = true; - this.addFormVisible = false; - if (conn === null) { - this.editingId = ''; - this.editName = ''; - this.editUrl = ''; - this.editApiKey = ''; - } else { - this.editingId = conn.id; - this.editName = conn.name; - this.editUrl = conn.url; - this.editApiKey = conn.apiKey; - } - setTimeout(() => { - this.addFormVisible = true; - }, 30); - } - - private closeConnForm(): void { - this.showAddForm = false; - this.addFormVisible = false; - this.editingId = ''; - this.editName = ''; - this.editUrl = ''; - this.editApiKey = ''; - } - - private deleteConnection(id: string): void { - connStore.deleteConnection(id).then(() => { - this.loadConnections(); - const cur: ConnectionConfig | null = connStore.getCurrentConnection(); - if (cur !== null) { - apiClient.setConnection(cur); - } else { - apiClient.clearConnection(); - } - restartForegroundBridge(getContext(this) as common.UIAbilityContext); - this.showToast('连接已删除', false); - }); - } - - // ===================== appearance ===================== - - private applyTheme(mode: string): void { - this.themeMode = mode; - this.persistSettings(); - // 立即翻转全局主题标志,整个 UI 随之切换 - applyThemeMode(mode); - } - - // ===================== custom background ===================== - - private async pickBackgroundImage(): Promise { - if (this.pickingBg) { - return; - } - this.pickingBg = true; - try { - const options = new picker.PhotoSelectOptions(); - options.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE; - options.maxSelectNumber = 1; - const photoPicker = new picker.PhotoViewPicker(); - const result = await photoPicker.select(options); - if (result.photoUris.length === 0) { - return; - } - const srcUri: string = result.photoUris[0]; - const ctx = getContext(this) as common.UIAbilityContext; - // 文件名带时间戳:Image 组件按 src 字符串做内存缓存, - // 每次都写同一个 bg_image 会让第二次换图看起来"没生效"。 - // 后缀必须保留:Image 组件按扩展名挑选解码器,无后缀的沙箱文件会解码失败, - // 表现就是"设置了背景图但没生效"(onError 里能看到 decode 失败)。 - const destPath: string = ctx.filesDir + '/bg_' + Date.now().toString(36) + imageExt(srcUri); - const srcFile = fileIo.openSync(srcUri, fileIo.OpenMode.READ_ONLY); - const destFile = fileIo.openSync(destPath, - fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC); - fileIo.copyFileSync(srcFile.fd, destFile.fd); - fileIo.closeSync(srcFile); - fileIo.closeSync(destFile); - this.removeOldBgFile(); - // Image 只认带协议头的沙箱 URI,裸路径会被当成资源名而静默失败 - this.bgImage = 'file://' + destPath; - this.persistSettings(); - this.showToast('背景图已设置', false); - } catch (e) { - this.showToast(userMessage('settings.pickBg', e), true); - } - this.pickingBg = false; - } - - /** 删除上一张背景图文件,避免沙箱里越攒越多 */ - private removeOldBgFile(): void { - const old: string = this.bgImage; - if (old.length === 0) { - return; - } - const path: string = old.startsWith('file://') ? old.substring(7) : old; - try { - fileIo.unlinkSync(path); - } catch (e) { - // 文件可能已不存在,忽略 - } - } - - private clearBackgroundImage(): void { - this.removeOldBgFile(); - this.bgImage = ''; - this.persistSettings(); - this.showToast('已清除背景图', false); - } - - private onBgOpacityChange(value: number): void { - this.bgOpacity = value / 100; - this.persistSettings(); - } - - private persistSettings(): void { - const s: AppSettings = emptySettings(); - const old: AppSettings = connStore.getSettings(); - s.lang = old.lang; - s.theme = this.themeMode.length > 0 ? this.themeMode : (old.theme.length > 0 ? old.theme : 'system'); - s.currentConnId = old.currentConnId; - s.bgImage = this.bgImage; - s.bgOpacity = this.bgOpacity; - connStore.saveSettings(s); - AppStorage.set('bgImage', this.bgImage); - AppStorage.set('bgOpacity', this.bgOpacity); - } - // ===================== backend kv editor ===================== private async loadBackendSettings(): Promise { @@ -392,206 +153,40 @@ export struct SettingsPage { this.settingsError = ''; try { const resp = await apiClient.getWithTimeout('/settings', 15000); - const obj: Record = JSON.parse(resp.body) as Record; - - // meta definitions - this.metaStore = {}; - const rawMeta: Object | undefined = obj['meta']; - if (rawMeta !== undefined && rawMeta !== null) { - const mObj: Record = rawMeta as Record; - for (const mk of Object.keys(mObj)) { - const item: Record = mObj[mk] as Record; - const optsArr: Object | undefined = item['options']; - const opts: string[] = []; - if (optsArr !== undefined && optsArr !== null) { - const oa: Object[] = optsArr as Object[]; - for (let i = 0; i < oa.length; i++) { - opts.push(oa[i] as string); - } - } - const meta: SettingMetaRaw = { - key: item['key'] as string ?? mk, - type: item['type'] as string ?? 'string', - displayName: item['display_name'] as string ?? '', - description: item['description'] as string ?? '', - category: item['category'] as string ?? '', - options: opts, - }; - this.metaStore[mk] = meta; - } - } - - // current values - this.valuesStore = {}; - const rawVals: Object | undefined = obj['settings']; - if (rawVals !== undefined && rawVals !== null) { - const vObj: Record = rawVals as Record; - for (const vk of Object.keys(vObj)) { - if (vk.length === 0) { - continue; - } - const val: Object = vObj[vk]; - let strVal: string; - if (typeof val === 'string') { - strVal = val as string; - } else if (typeof val === 'boolean' || typeof val === 'number') { - strVal = String(val); - } else { - strVal = JSON.stringify(val); - } - this.valuesStore[vk] = strVal; - } - } - - this.buildSections(); - this.selectSection(this.pickInitialSection()); + const parsed: ParsedSettings = parseSettingsPayload(resp.body); + this.metaStore = parsed.meta; + this.valuesStore = parsed.values; + const stats: SectionStats = buildSections(this.valuesStore, this.metaStore); + this.sections = stats.sections; + this.pluginKeyCount = stats.pluginKeyCount; + this.pluginConfigCount = stats.pluginConfigCount; + this.selectSection(pickInitialSection(this.sections)); } catch (e) { this.settingsError = userMessage('settings.load', e); } this.loadingSettings = false; } - /** - * 把 key 归到分区: - * - core.* → 'core/',展示在「核心」二级页下 - * - plugin.* → 'plugin/',仅用于计数;实际编辑在插件详情页里, - * 不在这里列出(否则同一批 key 会有两个入口) - * - 其余 → 'other' - */ - private buildSections(): void { - const ids: string[] = []; - const counts: Record = {}; - const titles: Record = {}; - let pluginKeys: number = 0; - const plugNames: string[] = []; - for (const key of Object.keys(this.valuesStore)) { - if (key.startsWith('plugin.')) { - // 插件配置不在这里列:它属于插件本身,入口在「插件 → 详情 → 插件配置」。 - // 这里只统计,用于提示有多少项在那边。 - pluginKeys = pluginKeys + 1; - const rest: string = key.substring('plugin.'.length); - const dot: number = rest.indexOf('.'); - const plugName: string = dot > 0 ? rest.substring(0, dot) : rest; - if (plugName.length > 0 && plugNames.indexOf(plugName) < 0) { - plugNames.push(plugName); - } - continue; - } - let secId: string; - if (key.startsWith('core.')) { - const cat: string = this.metaCategory(key); - secId = cat.length > 0 ? 'core/' + cat : 'core/misc'; - if (titles[secId] === undefined) { - titles[secId] = cat.length > 0 ? this.categoryTitle(cat) : '未分类'; - } - } else { - secId = 'other'; - if (titles[secId] === undefined) { - titles[secId] = '其他'; - } - } - if (counts[secId] === undefined) { - counts[secId] = 0; - ids.push(secId); - } - counts[secId] = counts[secId] + 1; - } - ids.sort((a: string, b: string): number => a.localeCompare(b)); - const secs: SettingsSection[] = []; - for (const id of ids) { - secs.push({ id: id, title: titles[id] ?? id, count: counts[id] ?? 0 }); - } - this.sections = secs; - this.pluginKeyCount = pluginKeys; - this.pluginConfigCount = plugNames.length; - } - - private metaCategory(key: string): string { - const m: SettingMetaRaw | undefined = this.metaStore[key]; - return m !== undefined && m.category.length > 0 ? m.category : ''; - } - - private categoryTitle(cat: string): string { - const map: Record = { - 'agent': '智能体', - 'daemon': '守护进程', - 'llm': '大模型', - 'sources': '数据源', - 'input': '输入', - 'paths': '路径', - 'resources': '资源', - 'defaults': '默认值', - 'snapshot': '快照', - 'rollback': '回滚', - }; - const t: string | undefined = map[cat]; - return t !== undefined ? t : cat; - } - - private pickInitialSection(): string { - for (let i = 0; i < this.sections.length; i++) { - if (this.sections[i].id === 'core/agent') { - return 'core/agent'; - } - } - return this.sections.length > 0 ? this.sections[0].id : 'core'; - } - private selectSection(secId: string): void { this.activeSection = secId; - const entries: SettingEntry[] = []; - // deterministic order: alphabetical by key - const keys: string[] = Object.keys(this.valuesStore).filter((k: string): boolean => { - return this.keyInSection(k, secId); - }); - keys.sort((a: string, b: string): number => a.localeCompare(b)); - const limit: number = Math.min(keys.length, PAGE_SIZE * 4); - for (let i = 0; i < limit; i++) { - const key: string = keys[i]; - const meta: SettingMetaRaw | undefined = this.metaStore[key]; - const entry: SettingEntry = { - key: key, - displayName: meta !== undefined && meta.displayName.length > 0 ? meta.displayName : key, - description: meta !== undefined ? meta.description : '', - type: meta !== undefined ? meta.type : 'string', - options: meta !== undefined ? meta.options : [], - value: this.valuesStore[key] ?? '', - dirty: false, - }; - entries.push(entry); - } - this.entries = entries; + this.entries = buildEntries(this.valuesStore, this.metaStore, secId); } - private keyInSection(key: string, secId: string): boolean { - if (secId === 'other') { - return !key.startsWith('core.') && !key.startsWith('plugin.'); - } - if (secId.startsWith('core/')) { - if (!key.startsWith('core.')) { - return false; - } - const cat: string = secId.substring('core/'.length); - return cat === 'misc' ? this.metaCategory(key).length === 0 : this.metaCategory(key) === cat; - } - if (secId.startsWith('plugin/')) { - const p: string = secId.substring('plugin/'.length); - return key.startsWith('plugin.' + p + '.'); - } - return false; - } - - private hasDirty(): boolean { - for (let i: number = 0; i < this.entries.length; i++) { - if (this.entries[i].dirty === true) { - return true; + private findEntry(key: string): SettingEntry | undefined { + for (let i = 0; i < this.entries.length; i++) { + if (this.entries[i].key === key) { + return this.entries[i]; } } - return false; + return undefined; } /** Persist one key via PUT /settings. bool is sent as "true"/"false" strings like the GUI. */ - private async saveSetting(entry: SettingEntry, newValue: string): Promise { + private async saveSetting(key: string, newValue: string): Promise { + const entry: SettingEntry | undefined = this.findEntry(key); + if (entry === undefined) { + return; + } let payload: string = newValue.trim(); if (entry.type === 'bool') { payload = newValue === 'true' ? 'true' : 'false'; @@ -609,26 +204,13 @@ export struct SettingsPage { this.savingCount = this.savingCount - 1; } + /** 已保存:读当前编辑值再落库(输入类控件的"保存"按钮) */ + private saveCurrentValue(key: string): void { + this.saveSetting(key, this.currentValueOf(key)); + } + private markEntry(key: string, value: string, dirty: boolean): void { - const next: SettingEntry[] = []; - for (let i = 0; i < this.entries.length; i++) { - const e: SettingEntry = this.entries[i]; - if (e.key === key) { - const copy: SettingEntry = { - key: e.key, - displayName: e.displayName, - description: e.description, - type: e.type, - options: e.options, - value: value, - dirty: dirty, - }; - next.push(copy); - } else { - next.push(e); - } - } - this.entries = next; + this.entries = withEntryMarked(this.entries, key, value, dirty); } private updateEntryValue(key: string, value: string): void { @@ -636,6 +218,12 @@ export struct SettingsPage { this.markEntry(key, value, value !== original); } + private currentValueOf(key: string): string { + // read latest edited value from entries store (markEntry keeps copies) + const e: SettingEntry | undefined = this.findEntry(key); + return e !== undefined ? e.value : ''; + } + private showToast(msg: string, isError: boolean): void { // 颜色标记必须在 animateTo 之外先定好,否则第一帧会用上一条 toast 的配色 this.toastIsError = isError; @@ -656,47 +244,23 @@ export struct SettingsPage { // 宽屏(>=600vp)改用 Split 模式:navBar(这里的一级入口列表,含底部悬浮导航栏) // 常驻左栏,NavDestination(二级页面)渲染在右栏,两栏同时可见。 Navigation(this.navStack) { - // ===== 一级:入口列表(分组卡 + 右尖角行) ===== - Stack({ alignContent: Alignment.Bottom }) { - Column() { - Scroll() { - Column() { - this.RootEntries() - } - .width('100%') - .padding({ left: 16, right: 16, top: 76, bottom: 174 }) - } - .width('100%') - .height('100%') - .scrollBar(BarState.Off) - .align(Alignment.Top) - .onDidScroll((xOffset: number, yOffset: number, state: ScrollState) => { - handleNavOnScroll(state); - }) - } - .width('100%') - .height('100%') - - PageTopBar({ title: '设置' }) - - // 一级悬浮区:仅在保存进行中显示一个转圈; - // 已保存/未保存的常驻徽标按用户要求去掉(保存本来就是即时的,不需要状态吊牌) - NavFloatOverlay({ tab: 3 }) { - NavFloatRow() { - if (this.savingCount > 0) { - LoadingProgress() - .width(14) - .height(14) - .color(this.palette().accent) - } - } - } - - this.Toast() - } - .width('100%') - .height('100%') - .backgroundColor(Color.Transparent) + SettingsHome({ + connCount: this.connections.length, + connName: this.currentConnName(), + sectionCount: this.sections.length, + coreKeys: this.coreKeyCount(), + busy: this.loadingSettings, + errorText: this.settingsError, + themeLabel: this.themeLabel(), + activeSub: this.activeSub, + isWide: this.isWide, + savingCount: this.savingCount, + toastMsg: this.toastMsg, + toastIsError: this.toastIsError, + onOpen: (id: string) => { + this.openSub(id); + }, + }) } .navDestination(this.SubDestination) .mode(this.isWide ? NavigationMode.Split : NavigationMode.Stack) @@ -730,7 +294,11 @@ export struct SettingsPage { }) } - /** 二级页面路由表:name 由 pushPathByName 传入 */ + /** + * 二级页面路由表:name 由 pushPathByName 传入。 + * 各页面的外壳(SubPageLayer)与其内容一起放在对应组件里, + * 这里只做 id → 组件的分发。 + */ @Builder SubDestination(name: string, param: object) { NavDestination() { @@ -749,715 +317,73 @@ export struct SettingsPage { StatusDetailContent() } } else if (name === SUB_CONNECTIONS) { - SubPageLayer({ - title: '后端连接', - tab: 3, + ConnectionsPane({ + connections: $connections, + currentId: $currentId, onBack: () => { this.closeSub(); }, - }) { - this.ConnectionsContent() - } + onToast: (msg: string, isError: boolean) => { + this.showToast(msg, isError); + }, + }) } else if (name === SUB_APPEARANCE) { - SubPageLayer({ - title: '外观', - tab: 3, + AppearancePane({ + themeMode: $themeMode, + bgImage: $bgImage, + bgOpacity: $bgOpacity, onBack: () => { this.closeSub(); }, - }) { - this.AppearanceContent() - } + onToast: (msg: string, isError: boolean) => { + this.showToast(msg, isError); + }, + }) } else if (name === SUB_BACKEND) { - SubPageLayer({ - title: '核心配置', - tab: 3, + BackendSectionsPane({ + sections: this.sections, + pluginKeyCount: this.pluginKeyCount, + pluginConfigCount: this.pluginConfigCount, + busy: this.loadingSettings, + errorText: this.settingsError, + activeSection: this.activeSection, + highlightRows: this.isWide && this.activeSub === SUB_SECTION, onBack: () => { this.closeSub(); }, - showRefresh: true, onRefresh: () => { this.loadBackendSettings(); }, - }) { - this.BackendSectionsContent() - } + onOpenSection: (id: string) => { + this.selectSection(id); + this.openSub(SUB_SECTION); + }, + }) } else if (name === SUB_SECTION) { - SubPageLayer({ - title: this.activeSectionTitle(), - tab: 3, + SectionEntriesPane({ + sections: this.sections, + activeSection: this.activeSection, + entries: this.entries, + busy: this.loadingSettings, onBack: () => { this.closeSub(); }, - showRefresh: true, onRefresh: () => { this.loadBackendSettings(); }, - }) { - this.SectionEntriesContent() - } + onSaveValue: (key: string, value: string) => { + this.saveSetting(key, value); + }, + onSaveCurrent: (key: string) => { + this.saveCurrentValue(key); + }, + onEdit: (key: string, value: string) => { + this.updateEntryValue(key, value); + }, + }) } } .hideTitleBar(true) .backgroundColor(Color.Transparent) } - - - @Builder - Toast() { - if (this.toastMsg.length > 0) { - Row() { - Text(this.toastMsg) - .fontSize(13) - .fontColor(this.toastIsError ? this.palette().toastErrorText : this.palette().toastText) - .padding({ left: 20, right: 20, top: 10, bottom: 10 }) - .borderRadius(RADIUS_MD) - .backgroundColor(this.toastIsError ? this.palette().toastErrorBg : this.palette().toastBg) - } - .width('100%') - .justifyContent(FlexAlign.End) - .padding({ right: 20 }) - .margin({ bottom: 166 }) - // if 控制的节点靠 transition 做进出场,配合 showToast 里的 animateTo - .transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })) - } - } - - // ===================== 一级入口列表 ===================== - - @Builder - RootEntries() { - // 运行状态摘要(原「状态」Tab):整卡可点,进入明细二级页 - StatusSummaryCard({ - onTap: () => { - this.openSub(SUB_STATUS); - }, - }) - - NavGroup({ caption: '连接' }) { - NavRow({ - icon: $r('app.media.ic_link'), - title: '后端连接', - subtitle: this.connections.length.toString() + ' 个连接配置', - value: this.currentConnName(), - selected: this.isWide && this.activeSub === SUB_CONNECTIONS, - onTap: () => { - this.openSub(SUB_CONNECTIONS); - }, - }) - NavRow({ - icon: $r('app.media.ic_tune'), - title: '核心配置', - subtitle: this.sections.length.toString() + ' 个分类 · ' + this.coreKeyCount().toString() + ' 项', - value: this.loadingSettings ? '加载中' : (this.settingsError.length > 0 ? '不可用' : ''), - showDivider: false, - selected: this.isWide && (this.activeSub === SUB_BACKEND || this.activeSub === SUB_SECTION), - onTap: () => { - this.openSub(SUB_BACKEND); - }, - }) - } - - NavGroup({ caption: '个性化' }) { - NavRow({ - icon: $r('app.media.ic_theme'), - title: '外观', - subtitle: '主题与背景图', - value: this.themeLabel(), - showDivider: false, - selected: this.isWide && this.activeSub === SUB_APPEARANCE, - onTap: () => { - this.openSub(SUB_APPEARANCE); - }, - }) - } - - if (this.settingsError.length > 0) { - Text(this.settingsError) - .fontSize(12) - .fontColor('#E84026') - .padding({ left: 4, right: 4 }) - } - } - - // ===================== 二级:后端连接 ===================== - - @Builder - ConnectionsContent() { - PlainCard({ caption: '当前连接' }) { - Row() { - Column({ space: 3 }) { - Text(this.currentConnName()) - .fontSize(15) - .fontColor(this.palette().textPrimary) - Text(this.currentId.length > 0 ? '已激活,用于所有请求' : '尚未选择连接') - .fontSize(11) - .fontColor(this.palette().textMuted) - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - - Button('+ 添加') - .height(30) - .fontSize(12) - .backgroundColor(this.palette().accent) - .fontColor(Color.White) - .onClick(() => { - this.openConnForm(null); - }) - } - .width('100%') - .alignItems(VerticalAlign.Center) - - if (this.showAddForm) { - Column() { - Text(this.editingId.length > 0 ? '编辑连接' : '新建连接') - .fontSize(12) - .fontColor(this.palette().textSecondary) - .margin({ bottom: 10 }) - TextInput({ placeholder: '名称 (如 HomeAgent)', text: this.editName }) - .height(36).fontSize(13).fontColor(this.palette().textPrimary) - .placeholderColor(this.palette().textMuted).backgroundColor(this.palette().bgInput) - .borderRadius(RADIUS_SM).border({ width: 1, color: this.palette().border }) - .margin({ bottom: 10 }) - .onChange((v: string) => { - this.editName = v; - }) - TextInput({ placeholder: '地址 (域名或 http://192.168.1.100:8080)', text: this.editUrl }) - .height(36).fontSize(13).fontColor(this.palette().textPrimary) - .placeholderColor(this.palette().textMuted).backgroundColor(this.palette().bgInput) - .borderRadius(RADIUS_SM).border({ width: 1, color: this.palette().border }) - .margin({ bottom: 10 }) - .onChange((v: string) => { - this.editUrl = v; - }) - TextInput({ placeholder: 'API Key (可选)', text: this.editApiKey }) - .height(36).fontSize(13).fontColor(this.palette().textPrimary) - .placeholderColor(this.palette().textMuted).backgroundColor(this.palette().bgInput) - .borderRadius(RADIUS_SM).border({ width: 1, color: this.palette().border }) - .type(InputType.Password).margin({ bottom: 12 }) - .onChange((v: string) => { - this.editApiKey = v; - }) - - Row() { - Button('取消') - .height(30) - .fontSize(12) - .backgroundColor(Color.Transparent) - .border({ width: 1, color: this.palette().btnGhostBorder }) - .fontColor(this.palette().textSecondary) - .onClick(() => { - this.closeConnForm(); - }) - Blank() - Button('保存') - .height(30) - .fontSize(12) - .backgroundColor(this.palette().accent) - .fontColor(Color.White) - .onClick(() => { - this.addConnection(); - }) - } - .width('100%') - } - .width('100%') - .padding(12) - .borderRadius(RADIUS_MD) - .backgroundColor(this.palette().bgHover) - .border({ width: 1, color: this.palette().kvBorder }) - .margin({ top: 12 }) - .alignItems(HorizontalAlign.Start) - .opacity(this.addFormVisible ? 1 : 0) - .translate({ y: this.addFormVisible ? 0 : 12 }) - .animation({ duration: 220, curve: Curve.EaseOut }) - } - } - - PlainCard({ caption: '全部连接' }) { - if (this.connections.length === 0) { - Text('暂无连接。点击上方“添加”配置后端地址。') - .fontSize(12) - .fontColor(this.palette().textMuted) - } - ForEach(this.connections, (conn: ConnectionConfig) => { - Row() { - Circle({ width: 8, height: 8 }) - .fill(conn.id === this.currentId ? this.palette().accent : '#77809A') - .margin({ right: 10 }) - Column() { - Text(conn.name) - .fontSize(13) - .fontColor(this.palette().textPrimary) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - Text(conn.url) - .fontSize(11) - .fontColor(this.palette().textMuted) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - - if (conn.id !== this.currentId) { - Button('切换') - .height(26) - .fontSize(11) - .backgroundColor(Color.Transparent) - .border({ width: 1, color: this.palette().btnGhostBorder }) - .fontColor(this.palette().textSecondary) - .margin({ right: 6 }) - .onClick(() => { - this.selectConnection(conn.id); - }) - } else { - Text('使用中') - .fontSize(11) - .fontColor(this.palette().accent) - .padding({ left: 8, right: 8, top: 2, bottom: 2 }) - .borderRadius(RADIUS_SM) - .backgroundColor(this.palette().accentBg) - .margin({ right: 6 }) - } - Button('编辑') - .height(26) - .fontSize(11) - .backgroundColor(Color.Transparent) - .border({ width: 1, color: this.palette().btnGhostBorder }) - .fontColor(this.palette().textSecondary) - .margin({ right: 6 }) - .onClick(() => { - this.openConnForm(conn); - }) - Button('删除') - .height(26) - .fontSize(11) - .backgroundColor(Color.Transparent) - .border({ width: 1, color: 'rgba(232, 64, 38, 0.45)' }) - .fontColor('#E84026') - .onClick(() => { - this.deleteConnection(conn.id); - }) - } - .width('100%') - .padding(10) - .borderRadius(RADIUS_SM) - .backgroundColor(this.palette().bgHover) - .border({ - width: { left: 3 }, - color: conn.id === this.currentId ? this.palette().accent : Color.Transparent, - }) - .margin({ bottom: 6 }) - // 键里带上 name/url:ForEach 对相同键只更新绑定、不重跑 @Builder 体, - // 只用 id 做键时改完地址这一行还显示旧值。行内没有 TextInput, - // 因此把可变字段放进键不会有"编辑时焦点被销毁"的副作用。 - }, (conn: ConnectionConfig) => conn.id + '|' + conn.name + '|' + conn.url) - } - } - - // ===================== 二级:外观 ===================== - - @Builder - AppearanceContent() { - PlainCard({ caption: '主题' }) { - Row({ space: 8 }) { - this.ThemeOption('跟随系统', 'system') - this.ThemeOption('浅色', 'light') - this.ThemeOption('深色', 'dark') - } - .width('100%') - - Text(this.themeMode === 'system' - ? '当前跟随系统,系统切换深浅色时自动跟随' - : (this.themeMode === 'dark' ? '当前强制深色主题' : '当前强制浅色主题')) - .fontSize(11) - .fontColor(this.palette().textMuted) - .margin({ top: 10 }) - } - - PlainCard({ caption: '背景图' }) { - Row() { - Column() { - Text('自定义背景图') - .fontSize(14) - .fontColor(this.palette().textPrimary) - Text(this.bgImage.length > 0 ? '已设置背景图' : '未设置背景图') - .fontSize(11) - .fontColor(this.palette().textMuted) - .margin({ top: 2 }) - } - .alignItems(HorizontalAlign.Start) - .layoutWeight(1) - - if (this.bgImage.length > 0) { - Button('更换') - .height(28) - .fontSize(12) - .backgroundColor(Color.Transparent) - .border({ width: 1, color: this.palette().btnGhostBorder }) - .fontColor(this.palette().textSecondary) - .margin({ right: 6 }) - .onClick(() => { - this.pickBackgroundImage(); - }) - Button('清除') - .height(28) - .fontSize(12) - .backgroundColor(Color.Transparent) - .border({ width: 1, color: 'rgba(232, 64, 38, 0.45)' }) - .fontColor('#E84026') - .onClick(() => { - this.clearBackgroundImage(); - }) - } else { - Button(this.pickingBg ? '选择中...' : '选择图片') - .height(28) - .fontSize(12) - .backgroundColor(this.palette().accent) - .fontColor(Color.White) - .onClick(() => { - this.pickBackgroundImage(); - }) - } - } - .width('100%') - - if (this.bgImage.length > 0) { - Row() { - Text('透明度') - .fontSize(11) - .fontColor(this.palette().textMuted) - Slider({ - value: Math.round(this.bgOpacity * 100), - min: 5, - max: 60, - step: 1, - }) - .layoutWeight(1) - .selectedColor(this.palette().accent) - .trackColor(this.palette().bgHover) - .margin({ left: 8, right: 8 }) - .onChange((v: number, mode: SliderChangeMode) => { - if (mode === SliderChangeMode.Moving || mode === SliderChangeMode.Click) { - this.onBgOpacityChange(v); - } - }) - Text(Math.round(this.bgOpacity * 100).toString() + '%') - .fontSize(11) - .fontColor(this.palette().textSecondary) - .width(32) - } - .width('100%') - .alignItems(VerticalAlign.Center) - .margin({ top: 12 }) - } - } - } - - // ===================== 二级:核心配置分类列表 ===================== - - @Builder - BackendSectionsContent() { - if (this.settingsError.length > 0) { - PlainCard({ caption: '状态' }) { - Text(this.settingsError) - .fontSize(12) - .fontColor('#E84026') - } - } - - NavGroup({ caption: '核心' }) { - ForEach(this.sections, (sec: SettingsSection, idx: number) => { - NavRow({ - icon: $r('app.media.ic_tune'), - title: sec.title, - subtitle: sec.count.toString() + ' 项配置', - showDivider: idx < this.sections.length - 1, - selected: this.isWide && this.activeSub === SUB_SECTION - && this.activeSection === sec.id, - onTap: () => { - this.selectSection(sec.id); - this.openSub(SUB_SECTION); - }, - }) - }, (sec: SettingsSection) => sec.id + sec.count.toString()) - } - - // 插件配置不在这里编辑:入口在插件页的详情里,这里只指路,避免两处重复入口 - if (this.pluginKeyCount > 0) { - Text('插件的 ' + this.pluginKeyCount.toString() + ' 项配置(' + - this.pluginConfigCount.toString() + ' 个插件)在「插件 → 选择插件 → 插件配置」中修改。') - .fontSize(12) - .fontColor(this.palette().textMuted) - .width('100%') - .padding({ left: 4, right: 4 }) - } - - if (this.sections.length === 0 && !this.loadingSettings) { - Text('未获取到配置分类。检查后端连接后点击刷新。') - .fontSize(12) - .fontColor(this.palette().textMuted) - .padding({ left: 4 }) - } - } - - // ===================== 三级:某个分区的配置项 ===================== - - @Builder - SectionEntriesContent() { - ForEach(this.entries, (entry: SettingEntry) => { - this.SettingCard(entry) - }, (entry: SettingEntry) => entry.key + '|' + entry.value + '|' + (entry.dirty ? 'd' : 'c')) - - if (!this.loadingSettings && this.entries.length === 0) { - Text('该分区暂无配置项') - .fontSize(12) - .fontColor(this.palette().textMuted) - .padding(16) - } - } - - @Builder - ThemeOption(label: string, mode: string) { - // 按压缩放由 MotionBase 统一;flexWeight: 1 让三枚选项在 Row 里继续等分。 - MotionBase({ pressEnabled: true, flexWeight: 1 }) { - Column() { - Text(label) - .fontSize(12) - .fontColor(this.themeMode === mode ? Color.White : this.palette().textSecondary) - // 子节点的颜色迁移要自己声明:父容器的 .animation() 不下传 - .animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }) - } - .justifyContent(FlexAlign.Center) - .width('100%') - .height(34) - .borderRadius(RADIUS_MD) - .backgroundColor(this.themeMode === mode ? this.palette().accent : this.palette().bgHover) - // 三选一的选中态迁移:底色与描边一起过渡。写在 .border 之后、 - // 覆盖它上面的所有状态驱动属性。 - .border({ - width: 1, - color: this.themeMode === mode ? this.palette().accent : this.palette().btnGhostBorder, - }) - .animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }) - .onClick(() => { - this.applyTheme(mode); - }) - } - } - - @Builder - SettingCard(entry: SettingEntry) { - Column() { - Row() { - Text(entry.displayName) - .fontSize(13) - .fontWeight(FontWeight.Medium) - .fontColor(this.palette().textPrimary) - .maxLines(2) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .layoutWeight(1) - Text(entry.dirty ? '未保存' : entry.type) - .fontSize(10) - .fontColor(entry.dirty ? '#D99A2B' : this.palette().textMuted) - .padding({ left: 6, right: 6, top: 1, bottom: 1 }) - .borderRadius(RADIUS_SM) - .backgroundColor(entry.dirty ? 'rgba(217, 154, 43, 0.16)' : this.palette().bgHover) - } - .width('100%') - - Text(entry.key) - .fontSize(10) - .fontColor(this.palette().textMuted) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .margin({ top: 1 }) - - if (entry.description.length > 0) { - Text(entry.description) - .fontSize(11) - .fontColor(this.palette().textSecondary) - .maxLines(3) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .margin({ top: 3 }) - } - - // control row per type - if (entry.type === 'bool') { - Row() { - Text(entry.value === 'true' ? 'true' : 'false') - .fontSize(12) - .fontColor(entry.value === 'true' ? '#17A964' : this.palette().textMuted) - .animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }) - Blank() - Toggle({ type: ToggleType.Switch, isOn: entry.value === 'true' }) - .selectedColor(this.palette().accent) - .onChange((on: boolean) => { - this.saveSetting(entry, on ? 'true' : 'false'); - }) - } - .width('100%') - .margin({ top: 8 }) - } else if (entry.type === 'select' && entry.options.length > 0) { - Flex({ - direction: FlexDirection.Row, - justifyContent: FlexAlign.Start, - alignItems: ItemAlign.Center, - wrap: FlexWrap.Wrap, - }) { - ForEach(entry.options, (opt: string) => { - Button(opt) - .height(26) - .fontSize(11) - .margin({ right: 6, bottom: 6 }) - .backgroundColor(entry.value === opt ? this.palette().accent : this.palette().bgHover) - .fontColor(entry.value === opt ? Color.White : this.palette().textSecondary) - // 选中项迁移:底色与字色一起过渡,避免整排选项同时硬切 - .animation({ duration: ANIM_FAST, curve: Curve.EaseOut }) - .onClick(() => { - this.saveSetting(entry, opt); - }) - }, (opt: string) => opt) - } - .width('100%') - .margin({ top: 8 }) - } else if (entry.type === 'password') { - Row() { - TextInput({ text: entry.value }) - .height(36) - .fontSize(13) - .fontColor(this.palette().textPrimary) - .placeholderColor(this.palette().textMuted) - .backgroundColor(this.palette().bgInput) - .borderRadius(RADIUS_SM) - .border({ width: 1, color: this.palette().border }) - .type(InputType.Password) - .layoutWeight(1) - .onChange((v: string) => { - this.updateEntryValue(entry.key, v); - }) - Button('保存') - .height(30) - .fontSize(12) - .backgroundColor(entry.dirty ? '#D99A2B' : this.palette().accent) - .fontColor(Color.White) - .margin({ left: 8 }) - .onClick(() => { - this.saveSetting(entry, this.currentValueOf(entry)); - }) - } - .width('100%') - .margin({ top: 8 }) - } else if (entry.type === 'text') { - TextArea({ text: entry.value }) - .width('100%') - .fontSize(13) - .fontColor(this.palette().textPrimary) - .backgroundColor(this.palette().bgInput) - .borderRadius(RADIUS_SM) - .border({ width: 1, color: this.palette().border }) - .constraintSize({ minHeight: 60, maxHeight: 200 }) - .margin({ top: 8 }) - .onChange((v: string) => { - this.updateEntryValue(entry.key, v); - }) - Row() { - Blank() - Button('保存') - .height(30) - .fontSize(12) - .backgroundColor(entry.dirty ? '#D99A2B' : this.palette().accent) - .fontColor(Color.White) - .onClick(() => { - this.saveSetting(entry, this.currentValueOf(entry)); - }) - } - .width('100%') - .margin({ top: 6 }) - } else { - // string / int / duration - Row() { - TextInput({ text: entry.value }) - .height(36) - .fontSize(13) - .fontColor(this.palette().textPrimary) - .placeholderColor(this.palette().textMuted) - .backgroundColor(this.palette().bgInput) - .borderRadius(RADIUS_SM) - .border({ width: 1, color: this.palette().border }) - .layoutWeight(1) - .onChange((v: string) => { - this.updateEntryValue(entry.key, v); - }) - Button('保存') - .height(30) - .fontSize(12) - .backgroundColor(entry.dirty ? '#D99A2B' : this.palette().accent) - .fontColor(Color.White) - .margin({ left: 8 }) - .onClick(() => { - this.saveSetting(entry, this.currentValueOf(entry)); - }) - } - .width('100%') - .margin({ top: 8 }) - } - } - .width('100%') - .padding(14) - .borderRadius(RADIUS_MD) - .backgroundColor(this.palette().bgCard) - .border({ - width: 1, - color: entry.dirty ? '#D99A2B' : this.palette().glassBorder, - }) - .margin({ bottom: 10 }) - .alignItems(HorizontalAlign.Start) - } - - private currentValueOf(entry: SettingEntry): string { - // read latest edited value from entries store (markEntry keeps copies) - for (let i = 0; i < this.entries.length; i++) { - if (this.entries[i].key === entry.key) { - return this.entries[i].value; - } - } - return entry.value; - } -} - -const RADIUS_SM: number = 6; -const RADIUS_MD: number = 10; - -interface SettingMetaRaw { - key: string; - type: string; - displayName: string; - description: string; - category: string; - options: string[]; -} - -/** - * 从 picker 返回的 URI 里取图片后缀(带点)。 - * Image 组件依赖后缀选择解码器;沙箱里存成无后缀文件会静默解码失败, - * 用户看到的就是"背景图设置了却不生效"。取不到后缀时兜底 .jpg。 - */ -function imageExt(uri: string): string { - let s: string = uri; - const q: number = s.indexOf('?'); - if (q >= 0) { - s = s.substring(0, q); - } - const dot: number = s.lastIndexOf('.'); - const slash: number = s.lastIndexOf('/'); - if (dot > slash && dot < s.length - 1) { - const ext: string = s.substring(dot).toLowerCase(); - if (ext.length <= 5) { - return ext; - } - } - return '.jpg'; }