mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-23 02:18:06 +00:00
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:
313
cmd/ohos/HomeAgent/entry/src/main/ets/common/SettingsModel.ets
Normal file
313
cmd/ohos/HomeAgent/entry/src/main/ets/common/SettingsModel.ets
Normal 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';
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
Reference in New Issue
Block a user