img

📖 引言

"奇妙科学乐园"面向6-12岁儿童群体,家长控制是儿童类应用的核心安全防线。本文将完整拆解 ParentControl.ets 页面的实现细节,包括密码验证与设置、每日使用时长限制、护眼模式切换、就寝模式管理、5次错误锁定机制以及今日使用统计。这是一个典型的"安全+交互"复合型页面——既要保证家长设置的绝对安全性,又要为儿童提供清晰直观的使用时长可视化反馈。

通过本文,你将掌握HarmonyOS应用中如何利用 @ohos.data.preferences 实现独立的家长控制数据持久化、如何通过 Toggle 组件实现护眼模式切换、如何用 ForEach + Flex 构建时长选择器,以及如何设计安全的密码验证流程。


🎯 学习目标

完成本文后,你将能够:

  • ✅ 实现独立的Preferences数据存储,将家长控制数据与用户数据隔离
  • ✅ 设计安全的密码验证流程,包含首次设置、二次确认和5次错误锁定
  • ✅ 使用Toggle组件实现护眼模式和就寝模式的开关切换
  • ✅ 通过Flex+ForEach构建胶囊标签式时长选择器
  • ✅ 利用Progress组件实现使用时长可视化进度条

💡 需求分析

功能模块设计

模块 功能描述 技术要点
密码验证 首次设置4位以上密码,二次确认,后续输入验证 Preferences持久化、TextInput密码模式、Stack弹窗覆盖
5次错误锁定 连续5次密码错误后提示等待,防止暴力破解 wrongCount计数器、递增等待时间提示
时长限制 6档可选时长(30分钟~不限制),实时保存 Flex布局、ForEach渲染、选中态高亮
护眼模式 Toggle开关切换,降低蓝光保护视力 Toggle组件、boolean状态管理、即时持久化
就寝模式 设置就寝/起床时间,到点自动锁定 时间格式化、条件渲染、Toggle联动
使用统计 Progress进度条展示今日已用时长/限制 Progress组件、动态计算total值
锁定态控制 未验证前所有设置项不可操作 isUnlocked状态控制enabled/opacity

状态流转设计

页面进入
  │
  ├─ 首次进入(无密码)
  │    └─ 显示"设置密码"弹窗 → 输入+确认 → 设置成功 → 解锁
  │
  ├─ 非首次进入(有密码)
  │    ├─ 显示"验证密码"弹窗
  │    │    ├─ 密码正确 → 解锁 → 显示设置界面
  │    │    └─ 密码错误 → wrongCount++ → 提示剩余次数/锁定
  │    └─ 取消 → RouterUtil.back()
  │
  └─ 设置界面
       ├─ 修改时长限制 → 即时保存+Toast
       ├─ 切换护眼模式 → 即时保存+Toast
       ├─ 切换就寝模式 → 即时保存+Toast
       └─ 查看使用统计 → 只读展示

🛠️ 核心实现

步骤1: 数据模型与常量设计

功能说明

家长控制页面需要独立的数据存储空间,与用户偏好数据(收藏、历史、答题记录)隔离。我们使用独立的 parent_control_prefs Preferences实例,通过 AppConstants 中预定义的Key来管理数据读写。

完整代码

// pages/ParentControl.ets

import { promptAction } from '@kit.ArkUI';
import preferences from '@ohos.data.preferences';
import common from '@ohos.app.ability.common';
import { Logger } from '../utils/Logger';
import { RouterUtil } from '../utils/RouterUtil';
import { AppConstants, ThemeColors } from '../constants/AppConstants';
import { AppBar } from '../components/base/AppBar';

const TAG = 'ParentControl';

// 独立的Preferences名称,与用户数据存储隔离
const PREFS_NAME = 'parent_control_prefs';

// 家长设置数据接口定义
interface ParentSettings {
    password: string;       // 家长密码
    dailyLimit: number;     // 每日使用时长限制(分钟)
    eyeProtection: boolean; // 护眼模式开关
    bedtimeMode: boolean;   // 就寝模式开关
    bedtimeStart: string;   // 就寝时间(HH:mm格式)
    bedtimeEnd: string;     // 起床时间(HH:mm格式)
    usageToday: number;     // 今日已用时长(分钟)
    lastUsageDate: string;  // 上次使用日期(用于跨日重置)
}

// 时长选项数据结构
interface LimitOption {
    label: string;  // 显示文案
    value: number;  // 时长值(分钟),0表示不限制
}

代码解析

1. 独立存储空间设计

// ✅ 正确:家长控制使用独立的Preferences实例
const PREFS_NAME = 'parent_control_prefs';

// ❌ 错误:将家长密码与用户收藏数据混在同一Preferences中
// 若用户清除缓存,可能误删家长密码;且密码数据应与业务数据隔离

原理/说明:

  • 家长控制数据与用户偏好数据使用不同的Preferences名称(parent_control_prefs vs science_app_prefs
  • 这种隔离确保了家长设置不会被用户的常规操作(如清除缓存)意外影响
  • AppConstants 中定义了8个家长控制专用的Key常量(KEY_PARENT_PWDKEY_TIME_LIMITKEY_EYE_PROTECTION 等)

2. 时长选项数据结构

private limitOptions: LimitOption[] = [
    { label: '30分钟', value: 30 },
    { label: '1小时', value: 60 },
    { label: '1.5小时', value: 90 },
    { label: '2小时', value: 120 },
    { label: '3小时', value: 180 },
    { label: '不限制', value: 0 }
] as Array<LimitOption>;

原理/说明:

  • 使用 interface LimitOption 定义选项结构,确保类型安全
  • as Array<LimitOption> 显式类型标注,满足ArkTS严格模式要求
  • value: 0 表示不限制,这是业务层的约定

步骤2: 页面状态管理与初始化

功能说明

页面使用 @State 装饰器管理13个响应式状态变量,涵盖密码输入、验证状态、设置项数值等所有UI需要响应的数据。aboutToAppear 生命周期中加载已保存的设置。

完整代码

@Entry
@Component
struct ParentControl {
    // ===== 设置项状态 =====
    @State dailyLimit: number = 60;           // 每日时长限制(分钟)
    @State eyeProtection: boolean = false;    // 护眼模式开关
    @State bedtimeMode: boolean = false;      // 就寝模式开关
    @State bedtimeHour: number = 21;          // 就寝时间-小时
    @State bedtimeMinute: number = 0;         // 就寝时间-分钟
    @State wakeUpHour: number = 7;            // 起床时间-小时
    @State wakeUpMinute: number = 0;          // 起床时间-分钟

    // ===== 密码验证状态 =====
    @State showPasswordDialog: boolean = true;  // 是否显示密码弹窗
    @State isUnlocked: boolean = false;         // 是否已解锁(验证通过)
    @State isFirstSetup: boolean = false;       // 是否首次设置
    @State passwordInput: string = '';          // 密码输入
    @State confirmPassword: string = '';        // 确认密码输入
    @State passwordError: string = '';          // 密码错误提示
    @State wrongCount: number = 0;             // 错误次数计数器

    // Preferences实例引用
    private prefs: preferences.Preferences | null = null;

    aboutToAppear() {
        this.loadSettings();
    }

代码解析

1. 状态变量分类管理

状态变量按职责分为三类:

┌─────────────────────────────────────────────────────┐
│  设置项状态(6个)                                    │
│  dailyLimit / eyeProtection / bedtimeMode            │
│  bedtimeHour / bedtimeMinute / wakeUpHour / ...      │
├─────────────────────────────────────────────────────┤
│  密码验证状态(6个)                                   │
│  showPasswordDialog / isUnlocked / isFirstSetup       │
│  passwordInput / confirmPassword / passwordError      │
├─────────────────────────────────────────────────────┤
│  安全控制(1个)                                      │
│  wrongCount                                         │
└─────────────────────────────────────────────────────┘

2. 初始化加载流程

private async loadSettings(): Promise<void> {
    try {
        const ctx = getContext() as common.UIAbilityContext;
        this.prefs = await preferences.getPreferences(ctx, PREFS_NAME);

        // 判断是否首次设置:密码为空则表示首次
        const pwd = await this.prefs.get(AppConstants.KEY_PARENT_PWD, '') as string;
        this.isFirstSetup = pwd.length === 0;

        // 加载已有设置,使用默认值兜底
        this.dailyLimit = await this.prefs.get(AppConstants.KEY_TIME_LIMIT, 60) as number;
        this.eyeProtection = await this.prefs.get(AppConstants.KEY_EYE_PROTECTION, false) as boolean;
        this.bedtimeMode = await this.prefs.get('bedtime_mode', false) as boolean;

        // 解析时间字符串为小时和分钟
        const startStr = await this.prefs.get(AppConstants.KEY_BEDTIME_START, '21:00') as string;
        const endStr = await this.prefs.get(AppConstants.KEY_BEDTIME_END, '07:00') as string;
        const startParts = startStr.split(':');
        const endParts = endStr.split(':');
        this.bedtimeHour = parseInt(startParts[0]);
        this.bedtimeMinute = parseInt(startParts[1]);
        this.wakeUpHour = parseInt(endParts[0]);
        this.wakeUpMinute = parseInt(endParts[1]);

        // 非首次进入时显示密码验证弹窗
        this.showPasswordDialog = !this.isFirstSetup;
        Logger.info(TAG, `家长控制设置加载完成, 首次设置=${this.isFirstSetup}`);
    } catch (e) {
        Logger.error(TAG, '加载设置失败', e as Error);
        // 异常时降级为首次设置模式
        this.isFirstSetup = true;
        this.showPasswordDialog = false;
    }
}

原理/说明:

  • 使用 getContext() as common.UIAbilityContext 获取UIAbility上下文
  • preferences.getPreferences() 是异步方法,需要 await 等待
  • 每个字段都提供默认值(如 60false'21:00'),确保Preferences为空时页面正常显示
  • 异常捕获后降级为首次设置模式,避免用户无法进入页面

步骤3: 密码设置与验证机制

功能说明

密码模块是家长控制页面的安全核心,包含首次设置流程(输入+确认)和后续验证流程(输入+比对),以及5次错误锁定机制。

完整代码

// 首次设置密码
setupPassword() {
    if (this.passwordInput.length < 4) {
        this.passwordError = '密码至少4位';
        return;
    }
    if (this.passwordInput !== this.confirmPassword) {
        this.passwordError = '两次输入的密码不一致';
        return;
    }
    this.doSetPassword(this.passwordInput);
}

// 执行密码设置(异步写入Preferences)
private async doSetPassword(pwd: string): Promise<void> {
    try {
        if (!this.prefs) return;
        await this.prefs.put(AppConstants.KEY_PARENT_PWD, pwd);
        await this.prefs.flush();
        // 设置成功后切换状态
        this.isFirstSetup = false;
        this.isUnlocked = true;
        this.showPasswordDialog = false;
        this.passwordInput = '';
        this.confirmPassword = '';
        this.passwordError = '';
        Logger.info(TAG, '家长密码设置成功');
        promptAction.showToast({
            message: '密码设置成功',
            duration: 1500
        });
    } catch (e) {
        Logger.error(TAG, '设置密码失败', e as Error);
        this.passwordError = '设置失败,请重试';
    }
}

// 验证密码入口
verifyPassword() {
    if (this.passwordInput.length === 0) {
        this.passwordError = '请输入密码';
        return;
    }
    this.doVerifyPassword(this.passwordInput);
}

// 执行密码验证(含5次错误锁定)
private async doVerifyPassword(input: string): Promise<void> {
    try {
        if (!this.prefs) {
            this.passwordError = '系统未就绪,请稍后再试';
            return;
        }
        const savedPwd = await this.prefs.get(AppConstants.KEY_PARENT_PWD, '') as string;

        if (input === savedPwd) {
            // 验证通过
            this.isUnlocked = true;
            this.showPasswordDialog = false;
            this.wrongCount = 0;        // 重置错误计数
            this.passwordInput = '';
            this.passwordError = '';
            Logger.info(TAG, '家长验证通过');
        } else {
            // 验证失败,累加错误次数
            this.wrongCount++;
            if (this.wrongCount >= 5) {
                // 5次错误后锁定提示
                this.passwordError = `错误次数过多,请${this.wrongCount * 10}秒后重试`;
                this.passwordInput = '';
            } else {
                this.passwordError = `密码错误,还剩${5 - this.wrongCount}次机会`;
                this.passwordInput = '';
            }
            Logger.warn(TAG, `密码错误,已错误${this.wrongCount}次`);
        }
    } catch (e) {
        Logger.error(TAG, '验证密码失败', e as Error);
        this.passwordError = '验证失败,请重试';
    }
}

代码解析

1. 首次设置的前置校验

// ✅ 正确:逐项校验,给出明确的中文错误提示
setupPassword() {
    if (this.passwordInput.length < 4) {
        this.passwordError = '密码至少4位';
        return;
    }
    if (this.passwordInput !== this.confirmPassword) {
        this.passwordError = '两次输入的密码不一致';
        return;
    }
    this.doSetPassword(this.passwordInput);
}

// ❌ 错误:缺少前置校验,直接写入
setupPassword() {
    this.doSetPassword(this.passwordInput);  // 可能为空或两次不一致
}

2. 5次错误锁定机制

错误次数递增逻辑:

第1次错误 → "密码错误,还剩4次机会"2次错误 → "密码错误,还剩3次机会"3次错误 → "密码错误,还剩2次机会"4次错误 → "密码错误,还剩1次机会"5次错误 → "错误次数过多,请50秒后重试"

关键点:
- wrongCount 在每次验证通过时重置为0
- 锁定时间 = wrongCount * 10(递增等待)
- 实际项目中应结合时间戳实现真正的倒计时锁定

3. 密码存储的安全性考量

// 当前实现:明文存储密码
await this.prefs.put(AppConstants.KEY_PARENT_PWD, pwd);

// 改进方向:使用加密哈希
// HarmonyOS SDK 提供 @ohos.security.crypto 模块
// 推荐使用 SHA-256 + 盐值哈希存储,而非明文
// 但对于儿童应用的本地密码,明文存储在Preferences中
// (Preferences本身属于应用沙箱,外部无法直接读取)
// 是可接受的折中方案

步骤4: 时长限制选择器

功能说明

时长限制选择器使用 Flex + ForEach 实现6个胶囊标签,选中态为主题色填充+白色文字,未选中为灰色背景+深色文字。点击即时保存并显示Toast反馈。

完整代码

// 时长选项数据
private limitOptions: LimitOption[] = [
    { label: '30分钟', value: 30 },
    { label: '1小时', value: 60 },
    { label: '1.5小时', value: 90 },
    { label: '2小时', value: 120 },
    { label: '3小时', value: 180 },
    { label: '不限制', value: 0 }
] as Array<LimitOption>;

// 时长变更处理
onLimitChange(value: number) {
    this.dailyLimit = value;
    this.saveSettings().catch(() => {});
    promptAction.showToast({
        message: value === 0 ? '已关闭时长限制' : `每日限用${value}分钟`,
        duration: 1500
    });
}

UI构建代码

// 时长选择器UI
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
    ForEach(this.limitOptions, (option: LimitOption) => {
        Text(option.label)
            .fontSize(13)
            .fontColor(this.dailyLimit === option.value
                ? ThemeColors.TEXT_WHITE
                : ThemeColors.TEXT_PRIMARY)
            .padding({ left: 14, right: 14, top: 8, bottom: 8 })
            .backgroundColor(this.dailyLimit === option.value
                ? ThemeColors.PRIMARY
                : '#f5f5f5')
            .borderRadius(20)
            .margin({ right: 8, bottom: 8 })
            // 未解锁时禁用交互,并降低透明度
            .enabled(this.isUnlocked)
            .opacity(this.isUnlocked ? 1 : 0.5)
            .onClick(() => {
                if (this.isUnlocked) {
                    this.onLimitChange(option.value);
                }
            });
    }, (option: LimitOption) => option.value.toString());
}
.width('100%');

代码解析

1. Flex自动换行布局

// ✅ 正确:Flex + FlexWrap.Wrap 实现自动换行
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start })

// ❌ 错误:使用Row无法自动换行,超出屏幕被截断
Row() {
    ForEach(this.limitOptions, ...)
}

原理/说明:

  • FlexWrap.Wrap 允许子元素在容器宽度不足时自动换行
  • FlexAlign.Start 保证左对齐排列
  • margin({ right: 8, bottom: 8 }) 控制标签间距

2. 选中态与禁用态的双重控制

// 选中态:通过三元表达式切换颜色
.fontColor(this.dailyLimit === option.value
    ? ThemeColors.TEXT_WHITE     // 选中:白色文字
    : ThemeColors.TEXT_PRIMARY)  // 未选中:深色文字
.backgroundColor(this.dailyLimit === option.value
    ? ThemeColors.PRIMARY        // 选中:主题色背景
    : '#f5f5f5')                 // 未选中:浅灰背景

// 禁用态:未解锁时降低透明度+禁用点击
.enabled(this.isUnlocked)
.opacity(this.isUnlocked ? 1 : 0.5)

步骤5: 护眼模式与就寝模式

功能说明

护眼模式和就寝模式使用 Toggle 组件实现开关切换。Toggle的 isOn 属性绑定状态变量,onChange 回调中执行状态切换和持久化保存。就寝模式开启后,下方动态显示时间设置区域。

完整代码

// 护眼模式切换
toggleEyeProtection() {
    this.eyeProtection = !this.eyeProtection;
    this.saveSettings().catch(() => {});
    promptAction.showToast({
        message: this.eyeProtection ? '护眼模式已开启' : '护眼模式已关闭',
        duration: 1500
    });
}

// 就寝模式切换
toggleBedtimeMode() {
    this.bedtimeMode = !this.bedtimeMode;
    this.saveSettings().catch(() => {});
    promptAction.showToast({
        message: this.bedtimeMode ? '就寝模式已开启' : '就寝模式已关闭',
        duration: 1500
    });
}

护眼模式UI构建

Column() {
    Row() {
        Row() {
            Image($r('app.media.icon_eye_care'))
                .width(22)
                .height(22)
                .objectFit(ImageFit.Contain)
                .margin({ right: 10 });
            Column() {
                Text('护眼模式')
                    .fontSize(15)
                    .fontWeight(FontWeight.Medium)
                    .fontColor(ThemeColors.TEXT_PRIMARY)
                    .width('100%')
                    .margin({ bottom: 2 });
                Text('降低蓝光,保护视力')
                    .fontSize(12)
                    .fontColor(ThemeColors.TEXT_TERTIARY)
                    .width('100%');
            }
            .layoutWeight(1);
        }
        .layoutWeight(1);

        // Toggle开关组件
        Toggle({ type: ToggleType.Switch, isOn: this.eyeProtection })
            .enabled(this.isUnlocked)
            .onChange((isOn: boolean) => {
                if (this.isUnlocked) {
                    this.toggleEyeProtection();
                }
            });
    }
    .width('100%');
}

就寝模式条件渲染

// 就寝模式开启时,动态显示时间设置区域
if (this.bedtimeMode) {
    Row() {
        Column() {
            Text('就寝时间')
                .fontSize(13)
                .fontColor(ThemeColors.TEXT_TERTIARY)
                .margin({ bottom: 4 });
            Text(this.formatTime(this.bedtimeHour, this.bedtimeMinute))
                .fontSize(18)
                .fontWeight(FontWeight.Medium)
                .fontColor(ThemeColors.TEXT_PRIMARY);
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center);

        Text('→')
            .fontSize(18)
            .fontColor('#cccccc');

        Column() {
            Text('起床时间')
                .fontSize(13)
                .fontColor(ThemeColors.TEXT_TERTIARY)
                .margin({ bottom: 4 });
            Text(this.formatTime(this.wakeUpHour, this.wakeUpMinute))
                .fontSize(18)
                .fontWeight(FontWeight.Medium)
                .fontColor(ThemeColors.TEXT_PRIMARY);
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center);
    }
    .width('100%')
    .padding(14)
    .backgroundColor('#f9f9f9')
    .borderRadius(12);
}

代码解析

1. Toggle组件的enabled与onChange配合

// ✅ 正确:enabled控制禁用态 + onChange中二次检查
Toggle({ type: ToggleType.Switch, isOn: this.eyeProtection })
    .enabled(this.isUnlocked)        // UI层禁用
    .onChange((isOn: boolean) => {
        if (this.isUnlocked) {       // 逻辑层二次检查
            this.toggleEyeProtection();
        }
    });

// ❌ 错误:仅用enabled,未在onChange中检查
// 某些设备上Toggle可能绕过enabled触发onChange
Toggle({ type: ToggleType.Switch, isOn: this.eyeProtection })
    .enabled(this.isUnlocked)
    .onChange((isOn: boolean) => {
        this.toggleEyeProtection();  // 未检查isUnlocked
    });

2. 条件渲染控制区域显隐

// ✅ 正确:使用if条件渲染,组件销毁/创建
if (this.bedtimeMode) {
    Row() { /* 就寝时间设置 */ }
}

// ❌ 错误:使用visibility或opacity隐藏
// 组件仍然存在,占用布局空间
Row() {
    /* 就寝时间设置 */
}
.visibility(this.bedtimeMode ? Visibility.Visible : Visibility.Hidden)

步骤6: 密码弹窗与Stack覆盖层

功能说明

密码验证/设置弹窗使用 Stack 组件实现全屏覆盖。当 showPasswordDialogisFirstSetuptrue 时,在设置界面之上渲染一个半透明遮罩层,中间放置密码输入表单。

完整代码

build() {
    Stack() {
        // 底层:设置内容(始终存在)
        Column() {
            AppBar({
                barTitle: '家长控制',
                showBack: true,
                onBack: () => this.goBack()
            });

            Scroll() {
                Column() {
                    // 时长限制卡片
                    // 护眼模式卡片
                    // 就寝模式卡片
                    // 使用统计卡片
                }
                .width('100%')
                .padding({ left: 16, right: 16, top: 14 });
            }
            .scrollBar(BarState.Off)
            .backgroundColor(ThemeColors.BG_SECONDARY);
        }
        .width('100%')
        .height('100%');

        // 顶层:密码弹窗(条件渲染)
        if (this.showPasswordDialog || this.isFirstSetup) {
            Column() {
                Column() {
                    // 标题
                    Text(this.isFirstSetup ? '🔐 设置家长密码' : '🔐 家长验证')
                        .fontSize(18)
                        .fontWeight(FontWeight.Bold)
                        .fontColor(ThemeColors.TEXT_PRIMARY)
                        .margin({ bottom: 8 });

                    // 说明文案
                    Text(this.isFirstSetup
                        ? '请设置4位以上密码,用于验证家长身份'
                        : '请输入家长密码以继续操作')
                        .fontSize(13)
                        .fontColor(ThemeColors.TEXT_SECONDARY)
                        .margin({ bottom: 20 });

                    // 密码输入框
                    TextInput({ placeholder: '请输入密码', text: this.passwordInput })
                        .type(InputType.Password)
                        .width('100%')
                        .height(44)
                        .backgroundColor('#f5f5f5')
                        .borderRadius(8)
                        .padding({ left: 14, right: 14 })
                        .margin({ bottom: 12 })
                        .onChange((value: string) => {
                            this.passwordInput = value;
                            this.passwordError = '';
                        });

                    // 首次设置时显示确认密码输入框
                    if (this.isFirstSetup) {
                        TextInput({ placeholder: '请再次输入密码', text: this.confirmPassword })
                            .type(InputType.Password)
                            .width('100%')
                            .height(44)
                            .backgroundColor('#f5f5f5')
                            .borderRadius(8)
                            .padding({ left: 14, right: 14 })
                            .margin({ bottom: 12 })
                            .onChange((value: string) => {
                                this.confirmPassword = value;
                                this.passwordError = '';
                            });
                    }

                    // 错误提示(条件渲染)
                    if (this.passwordError) {
                        Text(this.passwordError)
                            .fontSize(12)
                            .fontColor(ThemeColors.DANGER)
                            .width('100%')
                            .margin({ bottom: 12 });
                    }

                    // 操作按钮
                    Row() {
                        Button('取消')
                            .layoutWeight(1)
                            .height(44)
                            .fontSize(15)
                            .type(ButtonType.Capsule)
                            .backgroundColor('#f5f5f5')
                            .fontColor(ThemeColors.TEXT_SECONDARY)
                            .margin({ right: 8 })
                            .onClick(() => {
                                RouterUtil.back('ParentControl');
                            });

                        Button(this.isFirstSetup ? '设置密码' : '验证')
                            .layoutWeight(1)
                            .height(44)
                            .fontSize(15)
                            .fontWeight(FontWeight.Medium)
                            .type(ButtonType.Capsule)
                            .backgroundColor(ThemeColors.PRIMARY)
                            .onClick(() => {
                                if (this.isFirstSetup) {
                                    this.setupPassword();
                                } else {
                                    this.verifyPassword();
                                }
                            });
                    }
                    .width('100%');
                }
                .width('100%')
                .padding(24)
                .backgroundColor(ThemeColors.BG_PRIMARY)
                .borderRadius(20)
                .margin({ left: 24, right: 24 });
            }
            .width('100%')
            .height('100%')
            .justifyContent(FlexAlign.Center)
            .backgroundColor(ThemeColors.OVERLAY);  // 半透明遮罩 rgba(0,0,0,0.5)
        }
    }
    .width('100%')
    .height('100%');
}

代码解析

1. Stack覆盖层设计模式

Stack结构示意:

┌─────────────────────────────────┐
│  Column(设置内容,z-index: 0)   │
│  ┌───────────────────────────┐  │
│  │  AppBar                   │  │
│  │  Scroll { 时长/护眼/就寝 } │  │
│  └───────────────────────────┘  │
│                                  │
│  ┌───────────────────────────┐  │ ← 条件渲染
│  │  Column(弹窗,z-index: 1)  │  │
│  │  .backgroundColor(OVERLAY) │  │ ← rgba(0,0,0,0.5)遮罩
│  │  ┌─────────────────────┐  │  │
│  │  │  密码输入表单        │  │  │
│  │  │  .bgColor(#ffffff)  │  │  │
│  │  │  .borderRadius(20)  │  │  │
│  │  └─────────────────────┘  │  │
│  └───────────────────────────┘  │
└─────────────────────────────────┘

2. 弹窗内取消按钮的行为

// ✅ 正确:取消时返回上一页
.onClick(() => {
    RouterUtil.back('ParentControl');
});

// ❌ 错误:仅关闭弹窗,用户仍停留在家长控制页面
.onClick(() => {
    this.showPasswordDialog = false;  // 未验证即可看到设置
});

原理/说明:

  • 未验证密码的用户不应看到任何设置内容
  • 取消操作必须返回上一页,而非仅关闭弹窗
  • 这保证了家长控制的安全性——只有通过验证的家长才能查看和修改设置

步骤7: 使用统计与Progress进度条

功能说明

使用统计区域使用 Progress 组件可视化展示今日已用时长与限制时长的比例。当限制为0(不限制)时,进度条的total值设为100,避免除零错误。

完整代码

// 使用统计卡片
Column() {
    Row() {
        Image($r('app.media.icon_stats'))
            .width(22)
            .height(22)
            .objectFit(ImageFit.Contain)
            .margin({ right: 10 });
        Column() {
            Text('今日使用统计')
                .fontSize(15)
                .fontWeight(FontWeight.Medium)
                .fontColor(ThemeColors.TEXT_PRIMARY)
                .width('100%')
                .margin({ bottom: 2 });
            Text('今日已使用 45 分钟')
                .fontSize(12)
                .fontColor(ThemeColors.TEXT_TERTIARY)
                .width('100%');
        }
        .layoutWeight(1);
    }
    .width('100%')
    .margin({ bottom: 14 });

    Column() {
        // 进度条:value为已用时长,total为限制时长
        Progress({ value: 45, total: this.dailyLimit > 0 ? this.dailyLimit : 100 })
            .width('100%')
            .color(ThemeColors.PRIMARY)
            .backgroundColor(ThemeColors.BG_TERTIARY)
            .margin({ bottom: 8 });

        Row() {
            Text('已用 45 分钟')
                .fontSize(12)
                .fontColor(ThemeColors.TEXT_SECONDARY);
            Text(this.dailyLimit > 0
                ? `/ ${this.dailyLimit} 分钟`
                : '/ 不限时')
                .fontSize(12)
                .fontColor(ThemeColors.TEXT_TERTIARY);
        }
        .width('100%');
    }
    .width('100%');
}

代码解析

1. Progress组件的防除零处理

// ✅ 正确:dailyLimit为0时用100作为total
Progress({ value: 45, total: this.dailyLimit > 0 ? this.dailyLimit : 100 })

// ❌ 错误:dailyLimit为0时total为0,进度条计算异常
Progress({ value: 45, total: this.dailyLimit })

2. 文案与进度条联动

// 限制60分钟时:显示 "/ 60 分钟"
// 不限制时:显示 "/ 不限时"
Text(this.dailyLimit > 0
    ? `/ ${this.dailyLimit} 分钟`
    : '/ 不限时')

⚠️ 常见问题与解决方案

问题1: Preferences初始化失败导致页面白屏

现象:
进入家长控制页面后白屏,控制台报错"getPreferences failed"。

原因:
getContext() 在Previewer环境中返回的对象类型不是 UIAbilityContext,导致 preferences.getPreferences() 调用失败。

错误代码:

// ❌ 错误:未处理Preferences初始化异常
private async loadSettings(): Promise<void> {
    const ctx = getContext() as common.UIAbilityContext;
    this.prefs = await preferences.getPreferences(ctx, PREFS_NAME);
    // 如果这里抛出异常,后续代码都不执行
}

正确代码:

// ✅ 正确:try-catch包裹 + 降级处理
private async loadSettings(): Promise<void> {
    try {
        const ctx = getContext() as common.UIAbilityContext;
        this.prefs = await preferences.getPreferences(ctx, PREFS_NAME);
        // ... 正常加载逻辑
    } catch (e) {
        Logger.error(TAG, '加载设置失败', e as Error);
        // 降级为首次设置模式,确保页面可用
        this.isFirstSetup = true;
        this.showPasswordDialog = false;
    }
}

规则/建议:

  • 所有IO操作(Preferences、文件、网络)必须try-catch
  • 异常降级方案要保证页面可用,不能白屏

问题2: Toggle在未解锁时仍能触发onChange

现象:
虽然Toggle的 enabled(false) 使其变灰,但某些设备版本上快速点击仍会触发 onChange 回调,导致护眼模式被意外切换。

原因:
enabled 只是UI层面的禁用,部分设备存在事件穿透的可能。

错误代码:

// ❌ 错误:仅依赖enabled,未在onChange中二次检查
Toggle({ type: ToggleType.Switch, isOn: this.eyeProtection })
    .enabled(this.isUnlocked)
    .onChange((isOn: boolean) => {
        this.toggleEyeProtection();  // 可能在未解锁时执行
    });

正确代码:

// ✅ 正确:UI禁用 + 逻辑层二次检查
Toggle({ type: ToggleType.Switch, isOn: this.eyeProtection })
    .enabled(this.isUnlocked)
    .onChange((isOn: boolean) => {
        if (this.isUnlocked) {  // 二次检查
            this.toggleEyeProtection();
        }
    });

规则/建议:

  • 关键操作必须有UI禁用 + 逻辑检查的双重保护
  • 这是"防御性编程"的典型实践

问题3: 5次错误锁定没有真正的倒计时

现象:
密码错误5次后提示"请50秒后重试",但用户关闭页面再进入,错误计数已重置,可以继续尝试。

原因:
wrongCount 是组件内部状态,页面销毁后即丢失,没有持久化到Preferences。

错误代码:

// ❌ 错误:wrongCount仅存在内存中
@State wrongCount: number = 0;
// 页面销毁后重置为0

正确代码:

// ✅ 正确:将锁定状态持久化
// 1. 在AppConstants中添加锁定相关Key
KEY_LOCKED_UNTIL: 'parent_locked_until'

// 2. 验证前检查是否在锁定期内
private async checkLockStatus(): Promise<boolean> {
    const lockedUntil = await this.prefs?.get(
        AppConstants.KEY_LOCKED_UNTIL, 0
    ) as number;
    const now = Date.now();
    if (now < lockedUntil) {
        const remainSec = Math.ceil((lockedUntil - now) / 1000);
        this.passwordError = `已锁定,请${remainSec}秒后重试`;
        return true;
    }
    return false;
}

// 3. 5次错误后写入锁定时间戳
if (this.wrongCount >= 5) {
    const lockUntil = Date.now() + this.wrongCount * 10 * 1000;
    await this.prefs?.put('parent_locked_until', lockUntil);
    await this.prefs?.flush();
}

规则/建议:

  • 安全机制不能仅依赖内存状态,必须持久化
  • 锁定时间使用时间戳而非固定秒数,确保跨页面生效

问题4: 密码弹窗的TextInput输入时Error提示不消失

现象:
密码错误提示"密码错误,还剩3次机会"显示后,用户开始输入新密码,但错误提示文字始终不消失。

原因:
密码输入框的 onChange 中虽然清空了 passwordError,但如果是首次设置的"确认密码"输入框触发,passwordInput 的onChange不会被调用。

错误代码:

// ❌ 错误:只在passwordInput的onChange中清空错误
TextInput({ placeholder: '请输入密码', text: this.passwordInput })
    .onChange((value: string) => {
        this.passwordInput = value;
        this.passwordError = '';  // 只在这里清空
    });

正确代码:

// ✅ 正确:两个输入框的onChange都清空错误
TextInput({ placeholder: '请输入密码', text: this.passwordInput })
    .onChange((value: string) => {
        this.passwordInput = value;
        this.passwordError = '';  // 清空错误
    });

if (this.isFirstSetup) {
    TextInput({ placeholder: '请再次输入密码', text: this.confirmPassword })
        .onChange((value: string) => {
            this.confirmPassword = value;
            this.passwordError = '';  // 同样清空错误
        });
}

规则/建议:

  • 表单中所有输入控件的onChange都应清空表单级错误提示
  • 确保用户在任何输入框开始输入时,都能看到错误提示消失的即时反馈

问题5: saveSettings的catch为空导致静默失败

现象:
用户切换护眼模式后,退出页面再进入,发现设置没有保存,但没有任何错误提示。

原因:
saveSettings().catch(() => {}) 中的空catch吞掉了异常。

错误代码:

// ❌ 错误:空catch,异常被静默吞掉
onLimitChange(value: number) {
    this.dailyLimit = value;
    this.saveSettings().catch(() => {});  // 异常被吞掉
}

正确代码:

// ✅ 正确:catch中记录日志
onLimitChange(value: number) {
    this.dailyLimit = value;
    this.saveSettings().catch((err: Error) => {
        Logger.error(TAG, '保存时长限制失败', err);
        promptAction.showToast({
            message: '保存失败,请重试',
            duration: 1500
        });
    });
}

规则/建议:

  • 禁止空catch,至少要记录日志
  • 影响用户数据的操作失败时应给出友好提示
  • 但在当前项目中,saveSettings 内部已有完整的try-catch和日志记录,外层空catch是为防止Promise未处理的rejection警告,属于可接受的折中

📝 本章小结

核心知识点

本文详细讲解了家长控制页面的完整实现,主要包括:

1. 独立数据存储设计

  • 家长控制使用独立的 parent_control_prefs Preferences实例
  • 与用户数据(收藏、历史、答题)完全隔离
  • 8个专用Key常量集中管理在 AppConstants

2. 安全的密码验证流程

  • 首次设置:输入+确认+最小长度校验
  • 后续验证:输入+比对+错误计数+锁定提示
  • Stack覆盖层确保未验证用户无法看到设置内容

3. 设置项的即时保存与反馈

  • Toggle开关 + enabled双重保护
  • Flex+ForEach构建胶囊选择器
  • Progress进度条可视化使用时长
  • 每次操作即时保存 + Toast反馈

最佳实践总结

Preferences独立存储

const PREFS_NAME = 'parent_control_prefs';
// 与用户数据隔离,安全性更高

Toggle双重保护

Toggle({ type: ToggleType.Switch, isOn: this.eyeProtection })
    .enabled(this.isUnlocked)
    .onChange((isOn: boolean) => {
        if (this.isUnlocked) {
            this.toggleEyeProtection();
        }
    });

Progress防除零

Progress({ value: 45, total: this.dailyLimit > 0 ? this.dailyLimit : 100 })

下一步预告

在下一篇文章中,我们将:

  • 🎨 详细讲解全量Icon系统从emoji到SVG的替换工程
  • 📚 分析45处emoji替换的具体方案和28个SVG图标的设计思路
  • 🏷️ 解决fillColor动态着色和资源冲突等实战问题

🔗 相关链接


💡 提示: 建议结合项目源码中 entry/src/main/ets/pages/ParentControl.ets 文件阅读,对照本文的代码解析理解每个功能模块的实现细节。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐