refactor(ohos): SettingsPage 1463→389 行(模型/条目卡/四个面板拆分)

- 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。
This commit is contained in:
root
2026-09-13 22:18:56 +08:00
parent 131c1ff0e9
commit f79e0f82dd
8 changed files with 1617 additions and 1184 deletions

View File

@ -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<string, SettingMetaRaw>;
values: Record<string, string>;
}
/** 分区统计结果 */
export interface SectionStats {
sections: SettingsSection[];
/** plugin.* 配置项总数(编辑入口在插件详情页,这里只用于提示去向) */
pluginKeyCount: number;
/** 涉及的插件个数 */
pluginConfigCount: number;
}
/**
* 解析 GET /settings 响应meta 定义 + 当前值。
* 值统一转成字符串(后端可能给 bool/number/嵌套对象)。
*/
export function parseSettingsPayload(body: string): ParsedSettings {
const obj: Record<string, Object> = JSON.parse(body) as Record<string, Object>;
const metaStore: Record<string, SettingMetaRaw> = {};
const valuesStore: Record<string, string> = {};
const rawMeta: Object | undefined = obj['meta'];
if (rawMeta !== undefined && rawMeta !== null) {
const mObj: Record<string, Object> = rawMeta as Record<string, Object>;
for (const mk of Object.keys(mObj)) {
const item: Record<string, Object> = mObj[mk] as Record<string, Object>;
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<string, Object> = rawVals as Record<string, Object>;
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<string, SettingMetaRaw>, 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<string, string> = {
'agent': '智能体',
'daemon': '守护进程',
'llm': '大模型',
'sources': '数据源',
'input': '输入',
'paths': '路径',
'resources': '资源',
'defaults': '默认值',
'snapshot': '快照',
'rollback': '回滚',
};
const t: string | undefined = map[cat];
return t !== undefined ? t : cat;
}
/**
* 把 key 归到分区:
* - core.* → 'core/<category>',展示在「核心」二级页下
* - plugin.* → 'plugin/<name>',仅用于计数;实际编辑在插件详情页里,
* 不在这里列出(否则同一批 key 会有两个入口)
* - 其余 → 'other'
*/
export function buildSections(
valuesStore: Record<string, string>, metaStore: Record<string, SettingMetaRaw>): SectionStats {
const ids: string[] = [];
const counts: Record<string, number> = {};
const titles: Record<string, string> = {};
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<string, SettingMetaRaw>, 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<string, string>,
metaStore: Record<string, SettingMetaRaw>,
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';
}

View File

@ -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<void> {
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<string>('bgImage', this.bgImage);
AppStorage.set<number>('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)
}
}
}

View File

@ -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)
}
}
}

View File

@ -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);
});
}
/**
* 保存对已有连接的修改。
*
* 修改当前生效的连接后必须重新 setConnectionApiClient 持有的是
* 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/urlForEach 对相同键只更新绑定、不重跑 @Builder 体,
// 只用 id 做键时改完地址这一行还显示旧值。行内没有 TextInput
// 因此把可变字段放进键不会有"编辑时焦点被销毁"的副作用。
}, (conn: ConnectionConfig) => conn.id + '|' + conn.name + '|' + conn.url)
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

File diff suppressed because it is too large Load Diff