一、项目内容

1.1 项目概述

本项目是基于 HarmonyOS NEXT(API 6.1.1)开发的智慧充电桩移动端应用,使用 ArkTS 声明式 UI 构建,打通 "端 — 云 — 桩" 全链路:

  • 云端:接入华为云 IoTDA,通过设备影子轮询获取充电桩实时状态(功率、时长、温度、湿度、超声波距离、星闪连接),并下发充电 / 停止指令与车牌绑定指令;

  • 端侧:ArkUI 实现四 Tab 主框架(首页 / 地图 / AI 识别 / 我的),配合登录注册、充电记录、AI 助手等二级页面;

  • AI 能力:百度 OCR 车牌识别(拍照 / 相册 → 识别 → 比对 → 联动充电)+ DeepSeek 大模型充电助手(设备实时状态注入 system prompt);

  • 地图服务:WebView 加载高德地图 JS API,支持内置 / 自定义桩位、系统定位(WGS84→GCJ-02 坐标转换)、长按新增桩位、搜索定位联动。

1.2 项目目录结构

SmartChargingPile/

├── build-profile.json5 # 工程构建配置(targetSdkVersion 6.1.1(24))

├── oh-package.json5 # 工程依赖声明(hypium / hamock 测试库)

├── AppScope/ # 应用级配置与资源

└── entry/src/main/

├── module.json5 # 模块配置:权限申请(网络/相机/相册/定位)

├── resources/

│ ├── base/

│ │ ├── element/ # 颜色、字符串资源

│ │ ├── media/ # 应用图标资源

│ │ └── profile/main_pages.json # 页面路由表

│ └── rawfile/map.html # 高德地图 Web 页面(WebView 加载)

└── ets/

├── entryability/

│ └── EntryAbility.ets # Ability 入口:全局初始化 + 设备轮询 + 会话恢复

├── entrybackupability/

│ └── EntryBackupAbility.ets # 系统备份/恢复扩展

├── pages/ # 页面级组件

│ ├── LoginPage.ets # 登录/注册页

│ ├── HomePage.ets # 主页面(四 Tab + 顶栏 + AI 悬浮按钮)

│ ├── LLMAgent.ets # AI 助手页(DeepSeek 对话)

│ ├── SearchPage.ets # 充电站搜索页

│ └── ChargeRecordsPage.ets # 充电记录列表页

├── views/ # Tab 视图组件

│ ├── Dashboard.ets # 首页仪表盘(功率曲线/传感器/充电控制)

│ ├── MapPage.ets # 地图页(WebView + 定位)

│ ├── AICamera.ets # AI 车牌识别页

│ └── ProfilePage.ets # 我的页

├── components/ # 通用弹窗组件

│ ├── BindPlateDialog.ets # 绑定车牌弹窗(手动输入/拍照识别)

│ └── OcrConfigDialog.ets # 百度 OCR 密钥配置弹窗

└── utils/ # 工具与业务逻辑

├── GlobalState.ets # 全局状态 + 用户/车牌本地存储

├── IoTDAService.ets # 华为云 IoTDA 服务(核心)

├── DeviceState.ets # 设备状态轮询与状态仲裁

├── ChargeSimulator.ets # 本地充电功率模拟器

├── ChargeRecordStore.ets # 充电记录会话录制器

└── PlateRecognizer.ets # 车牌识别核心(图片压缩 + OCR)

1.3 开发环境与技术栈

1.4 功能清单

  1. 登录注册:本地用户管理(preferences 持久化),注册校验(用户名非空 / 密码≥4 位 / 两次一致 / 用户名查重),密码明文切换;
  2. 首页仪表盘:充电状态大卡片(充电中 / 空闲 / 故障三态)、温度 / 湿度 / 超声波距离 / 车位状态四张传感器卡片、Canvas 实时功率折线图、充电 / 停止控制(乐观更新 + 云端下发 + 回滚)、星闪连接状态徽章、车牌绑定入口;
  3. 地图找桩:WebView 加载高德地图,内置 4 个西安充电桩,长按地图新增自定义桩位(localStorage 持久化)、详情卡查看、自定义桩位删除、系统定位(WGS84→GCJ-02)显示 "我的位置";
  4. 充电站搜索:按桩位编号 / 地址模糊搜索(内置 + 自定义合并),点击结果跳回地图并定位到该桩位;
  5. AI 车牌识别:拍照 / 相册选择图片,百度 OCR 识别车牌,与绑定车牌比对;一致则联动启动本地模拟充电;OCR 密钥支持应用内动态配置;
  6. AI 智能助手:DeepSeek 对话页面,将设备实时状态(充电状态 / 星闪 / 温度 / 湿度)注入 system prompt,支持快捷提问;
  7. 充电记录:会话录制器记录每次充电(开始时间 / 时长 / 峰值功率 / 车牌 / 是否正常结束),列表页展示汇总(充电次数 / 累计时长)与 "充电中" 实时卡片,支持清空;
  8. 我的页:车牌展示与绑定、最近 3 条充电记录、充电记录入口、退出登录。

二、项目亮点

2.1 亮点一:端云协同的充电远程控制

核心服务 IoTDAService 封装了完整的华为云 IoTDA 调用链路:

  • IAM Token 认证:用 AK/SK 换取 x-subject-token,带 Token 访问设备影子与命令接口;
  • Token 缓存与并发去重:cachedToken 复用 + tokenFetching 标志防止并发重复获取;
  • 设备影子轮询:GET /v5/iot/{project_id}/devices/{device_id}/shadow 拉取设备上报属性;
  • 命令下发:start_charge / stop_charge / plateNumber 三条命令,JSON 结构(service_id + command_name + paras)与设备端对齐;
  • 错误码友好化:常见 IoTDA 错误码映射为中文提示(如 IOTDA.014016 → "设备离线:请保持设备在线后再操作");
  • 网络抖动重试:请求超时 / 断连时自动重试一次,仍失败则给出明确中文原因。
// 下发充电指令:OK 则返回成功;失败时提取 error_code 映射为中文提示
const parseResult = (code: number, bodyStr: string): CommandResult => {
    if (code === 201) return { ok: true, reason: '' };
    ...
    const friendly = COMMAND_ERROR_MAP[errorCode];
    return {
        ok: false,
        reason: friendly ?? (errorMsg ? `${errorMsg}(${errorCode})` : `HTTP ${code}`)
    };
};
// 网络层错误自动重试一次
let result = await requestOnce();
if (result === null) { result = await requestOnce(); }

2.2 亮点二:乐观更新 + 回滚的即时交互

用户点击 "充电 / 停止" 后,界面立即切换状态并启动功率动画,不等云端响应;指令下发失败时按场景回滚或保持本地状态,保证交互零等待:

// 乐观更新:点击后立即切换顶栏「充电/停止」状态并实时驱动功率显示,
// 指令下发失败时回滚
AppStorage.set('pileStatus', targetStatus);
if (targetStatus === '1') {
    startChargeSimulation();
    await beginChargeSession(plate);
} else {
    stopChargeSimulation();
    AppStorage.set('powerKw', 0.0);
}
const res = await sendChargeCommand(...);

2.3 亮点三:三层机制保障本地 / 云端状态一致性

项目用三个 "状态标记" 解决本地乐观状态与云端设备影子的竞态问题(对应三个真实 bug 的修复):

// 设备轮询中的状态仲裁核心逻辑
const mockCharging = AppStorage.get<boolean>('mockCharging') ?? false;
const syncHoldUntil = AppStorage.get<number>('commandSyncUntil') ?? 0;
const inSyncHold = new Date().getTime() < syncHoldUntil;
if (!mockCharging && !inSyncHold) {
    AppStorage.set('pileStatus', String(props.pile_status));
    ...
}

2.4 亮点四:充电会话录制器 —— 防丢失的完整数据链路

ChargeRecordStore 把一次充电建模为 "会话",从开始到结束全链路落盘:

  • 开始:记录开始时间、车牌,并把活跃会话标记写入 preferences(防 App 被杀丢失);
  • 进行中:trackChargePower 实时跟踪峰值功率;
  • 结束:生成记录(时长 / 峰值 / 车牌 / 是否正常结束)追加到历史列表,上限 200 条;
  • 启动恢复:restoreActiveSessionOnLaunch 恢复上次未闭合会话;
  • 设备对账:轮询发现设备已空闲时,reconcileSessionWithDevice 自动把未闭合会话记为 "异常结束",保证数据闭环。

2.5 亮点五:AI 能力双集成

车牌识别(PlateRecognizer)

  • 图片读取 → 解码压缩(长边 1280、JPEG 质量 90,满足 OCR 接口 4MB 限制)→ base64 → 百度 OCR 车牌识别接口;
  • 结果归一化(去掉 ·、-、空格等分隔符并统一大写),用于与绑定车牌精确比对;
  • 密钥动态配置:OCR 密钥存入 preferences,应用内右上角 ⚙ 即可配置,免重新编译;未配置时给出明确引导。
// 车牌归一化:如 "鲁H·S6S88" → "鲁HS6S88"
export function normalizePlate(input: string): string {
    ...
    if (ch === ' ' || ch === '·' || ch === '•' || ch === '-' || ch === '_' || ch === '.' || ch === ',') {
        continue;
    }
    out += ch.toUpperCase();
}
const requestMessages: ChatRequestMessage[] = [
    { role: 'system', content: getDeviceStatePrompt() } as ChatRequestMessage,
    ...this.messages.map(...)
];
// getDeviceStatePrompt 输出示例:
// 你是智慧充电桩AI助手,回答简洁友好,不超过80字。当前设备实时状态:
// - 充电状态:充电中,已充 23 分钟,当前功率 6.5 kW
// - 星闪连接:已连接
// - 温度:26.5℃ / 湿度:48%

2.6 亮点六:WebView 地图的双向通信与坐标转换

MapPage 通过 WebView 加载 map.html(高德 JS API),实现原生 ArkTS 与 Web 页面双向通信

  • 原生 → Web:controller.runJavaScript('focusPile(lng, lat)') 定位桩位、locateUser(lng, lat) 显示 "我的位置";
  • Web → 原生:runJavaScript 回调读取 localStorage 中自定义桩位,同步到 AppStorage('customPiles') 供搜索页查询;
  • 坐标转换:系统定位返回 WGS84 坐标,页面内实现 WGS84→GCJ-02 转换后渲染到高德地图;
  • 页面联动:搜索页通过 eventHub.emit('mapFocus', lng, lat) 通知地图页定位,EventHub 事件总线贯穿多个页面。

2.7 亮点七:ArkUI 响应式工程化设计

  • 全局状态:GlobalContext.init() 统一初始化 AppStorage 键值,@StorageLink 实现多组件响应式订阅(顶栏徽章、仪表盘、AI 页共享充电状态);
  • 组件化:四个 Tab 视图独立封装为 Dashboard / MapPage / AICamera / ProfilePage,弹窗独立为 BindPlateDialog / OcrConfigDialog;
  • Canvas 可视化:PowerLineChart 用 CanvasRenderingContext2D 每 1 秒重绘一次实时功率折线图(带虚线网格);
  • 页面通信:EventHub 注册 / 注销成对使用,aboutToAppear / aboutToDisappear 生命周期管理规范。

三、项目难点

3.1 难点一:本地乐观状态与云端设备影子的竞态

设备轮询每 5 秒把云端影子同步到本地,而用户点击充电后本地立即乐观更新。若不加保护,轮询会拿 "云端旧状态" 覆盖 "本地新状态"—— 这正是代码注释中记录的 "充电 2 秒自动停" 现象的根因。

解决思路:引入 "接管窗口 + 模拟标记" 双层机制(见 2.3),在窗口期和模拟期内轮询跳过充电状态同步,窗口结束后再以设备上报为准自动收敛。

3.2 难点二:IAM Token 生命周期管理

IoTDA 接口全部需要 Token 认证,而 Token 有过期时间、获取耗时(可能 60 秒超时)、多个页面会并发发起请求。难点在于:

  • Token 未过期时不能重复获取(浪费且慢);
  • 多个并发请求不能各自去拿 Token(风暴);
  • Token 失效(HTTP 401)后必须清除缓存、下次自动重取。

解决思路:cachedToken 缓存 + tokenFetching 标志去重(并发请求轮询等待第一个请求完成)+ 所有接口 401 时清缓存。

3.3 难点三:原生 ArkTS 与 Web 地图页的数据互通

WebView 中的 map.html 与原生页面是两套运行环境,但业务上需要:地图数据(自定义桩位)流向原生搜索页、搜索页选中结果反向定位地图。难点在于:

  • runJavaScript 的返回值类型与 JSON 编码行为特殊(字符串会被多包一层引号,见问题 4.5);
  • 页面加载完成时机(onPageEnd)与原生调用时机需对齐;
  • 页面销毁后定时器与事件监听必须清理,避免泄漏。

3.4 难点四:真实数据与模拟数据的优先级仲裁

演示场景下设备可能离线,但界面仍需 "充电中 + 实时功率"。项目用本地模拟器补位,又不能让模拟值覆盖设备真实上报值。难点在于设计一套明确的优先级:

设备真实功率(10 秒新鲜窗口内) > 本地模拟功率 > 归零

同时状态联动:未处于充电状态时立即停止模拟并归零,避免残留功率数据。

3.6 难点六:多页面共享状态的响应式同步

充电状态、功率、车牌、星闪状态被顶栏、仪表盘、AI 页、我的页多处展示,且需要实时联动。难点在于选择正确的状态机制:

  • 跨页面共享用 AppStorage + @StorageLink(一处修改,多处刷新);
  • 一次性事件(跳 Tab、地图定位)用 EventHub(发布 / 订阅);
  • 退出登录时不能 delete AppStorage 键(会导致 @StorageLink 订阅者报错),只能 setOrCreate 重置。

四、问题与解决

4.1 问题一:AI 识别成功后状态被轮询覆盖

现象:AI 识别车牌匹配成功、启动本地模拟充电后,几秒内又被云端空闲状态覆盖。

原因:本地模拟充电没有真实设备上报,轮询无条件用云端状态覆盖本地。

解决:引入 mockCharging 标记,AI 识别成功时置为 true,轮询检测到该标记后跳过充电状态同步;用户手动下发充电指令时清除标记,交还云端接管:

// AICamera:识别成功驱动本地状态
AppStorage.set('mockCharging', true);
AppStorage.set('pileStatus', '1');
startChargeSimulation();

// 用户手动控制充电后,退出 AI 识别的本地 mock 状态,由云端状态接管
AppStorage.set('mockCharging', false);

4.2 问题二:本地模拟功率覆盖设备真实功率

现象:设备真实上报功率(如 6.2kW)被本地模拟器的随机波动值覆盖,显示不准确。

原因:模拟器每秒更新功率,未区分数据来源。

解决:记录设备真实功率最近上报时间戳,10 秒新鲜窗口内模拟器让位:

// 模拟器:真实功率新鲜窗口内不覆盖真实值
const lastRealTs = AppStorage.get<number>('lastRealPowerTs') ?? 0;
if (new Date().getTime() - lastRealTs < REAL_FRESH_MS) return;
const p = nextPower();
AppStorage.set('powerKw', p);

4.3 问题三:停止充电后功率残留

现象:停止充电后,界面仍短暂显示非零功率。

原因:模拟器定时器未停止或真实功率未归零。

解决:模拟器每个 tick 检查状态,非充电状态立即停止模拟并归零;轮询发现设备空闲时也强制功率归零:

// 状态联动:未处于充电状态时停止模拟并归零,避免残留功率
const status = AppStorage.get<string>('pileStatus') ?? '0';
if (status !== '1') {
    stopChargeSimulation();
    AppStorage.set('powerKw', 0.0);
    return;
}

4.4 问题四:runJavaScript 返回字符串被 "多包一层引号"

现象:从 map.html 读取自定义桩位同步到 AppStorage 后,解析得到的是字符串而非数组,导致搜索页 customPiles 一直为空、map 报 TypeError 被静默吞掉。

原因:runJavaScript 对字符串返回值会再做一次 JSON 编码(外层多包一对引号),原生端 JSON.parse 后得到的仍是字符串。

解决:在 JS 端先把 localStorage 的 JSON 字符串 parse 成数组再返回;原生端再做 "双重 parse 兜底":

const script = "(function(){ try { var v = localStorage.getItem('customPiles') || '[]'; return JSON.parse(v); } catch(e) { return []; } })()"
...
let parsed: object = JSON.parse(result);
if (typeof parsed === 'string') {   // 兜底:兼容 result 被多包一层引号的情况
    parsed = JSON.parse(parsed);
}

4.5 问题五:自定义桩位在地图上可见、搜索却搜不到

现象:地图上长按新增的桩位显示正常,但搜索页搜不到。

原因:自定义桩位只存在 map.html 的 localStorage 中,原生搜索页无法直接访问;且同步逻辑存在 4.5 的解析缺陷。

解决:地图页 onPageEnd 后启动 2 秒定时同步(syncCustomPiles),跳转搜索页前再同步一次;配合 4.5 的解析兜底,保证搜索页拿到最新桩位:

// 页面就绪后:定位用户位置 + 同步自定义桩位
this.syncCustomPiles();
if (this.syncTimer === -1) {
    this.syncTimer = setInterval(() => { this.syncCustomPiles() }, 2000);
}

4.6 问题六:Token 失效(401)导致接口持续失败

现象:Token 过期后,设备查询 / 命令下发全部返回 401。

原因:缓存的 Token 未失效处理。

解决:所有接口检测到 401 时清除 cachedToken,下次调用自动重新获取:

if (data.responseCode === 401) { cachedToken = ''; resolve(null); return; }

五、代码文件、

5.1  module.json5

{
  "module": {
    "name": "entry",
    "type": "entry",
    "description": "$string:module_desc",
    "mainElement": "EntryAbility",
    "deviceTypes": [
      "phone"
    ],
    "deliveryWithInstall": true,
    "installationFree": false,
    "pages": "$profile:main_pages",
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "description": "$string:EntryAbility_desc",
        "icon": "$media:layered_image",
        "label": "$string:EntryAbility_label",
        "startWindowIcon": "$media:startIcon",
        "startWindowBackground": "$color:start_window_background",
        "exported": true,
        "skills": [
          {
            "entities": [
              "entity.system.home"
            ],
            "actions": [
              "ohos.want.action.home"
            ]
          }
        ]
      }
    ],
    "extensionAbilities": [
      {
        "name": "EntryBackupAbility",
        "srcEntry": "./ets/entrybackupability/EntryBackupAbility.ets",
        "type": "backup",
        "exported": false,
        "metadata": [
          {
            "name": "ohos.extension.backup",
            "resource": "$profile:backup_config"
          }
        ],
      }
    ],
    "requestPermissions": [
      {
        "name": "ohos.permission.INTERNET"
      },
      {
        "name": "ohos.permission.GET_NETWORK_INFO"
      },
      {
        "name": "ohos.permission.CAMERA",
        "reason": "$string:camera_reason",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      },
      {
        "name": "ohos.permission.READ_MEDIA",
        "reason": "$string:gallery_reason",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      },
      {
        "name": "ohos.permission.APPROXIMATELY_LOCATION",
        "reason": "$string:location_reason",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      },
      {
        "name": "ohos.permission.LOCATION",
        "reason": "$string:location_reason",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      }    ]
  }
}

5.2  components

5.2.1  BindPlateDialog.ets
// 导入当前页面依赖的组件、状态工具和系统能力。
import { promptAction } from '@kit.ArkUI';
import { camera, cameraPicker } from '@kit.CameraKit';
import { common } from '@kit.AbilityKit';
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { recognizePlate, normalizePlate } from '../utils/PlateRecognizer';
import { PlateStore } from '../utils/GlobalState';

// 使用装饰器声明页面、组件或构建器,供界面树和框架生命周期识别。
@CustomDialog
export struct BindPlateDialog {
  controller: CustomDialogController
  // 这里集中定义组件状态、共享状态以及界面控制器。
  @State inputPlate: string = ''
  @State isRecognizing: boolean = false;
  @StorageLink('plateNumber') plateNumber: string = '未绑定'
  private context = getContext(this) as common.UIAbilityContext;

  // 这个异步方法负责处理耗时任务,并在完成后回写界面状态。
  async takePhoto() {
    try {
      // 先声明模块级常量、缓存或定时器句柄,方便多个方法共享。
      const result = await cameraPicker.pick(
        this.context,
        [cameraPicker.PickerMediaType.PHOTO],
        { cameraPosition: camera.CameraPosition.CAMERA_POSITION_BACK }
      );
      // 根据当前业务状态走不同分支,保证页面反馈和数据流一致。
      if (result.resultCode === 0 && result.resultUri) {
        this.doRecognize(result.resultUri);
      }
    } catch (e) {
      try { promptAction.showToast({ message: '相机启动失败' }); } catch (_) {}
    }
  }

  // 这个异步方法负责处理耗时任务,并在完成后回写界面状态。
  async pickFromGallery() {
    try {
      const photoSelectOptions = new photoAccessHelper.PhotoSelectOptions();
      photoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
      photoSelectOptions.maxSelectNumber = 1;
      const photoPicker = new photoAccessHelper.PhotoViewPicker();
      const result = await photoPicker.select(photoSelectOptions);
      // 根据当前业务状态走不同分支,保证页面反馈和数据流一致。
      if (result.photoUris && result.photoUris.length > 0) {
        this.doRecognize(result.photoUris[0]);
      }
    } catch (e) {
      try { promptAction.showToast({ message: '相册选择失败' }); } catch (_) {}
    }
  }

  // 这个异步方法负责处理耗时任务,并在完成后回写界面状态。
  async doRecognize(imagePath: string) {
    this.isRecognizing = true;
    try {
      const plate = await recognizePlate(this.context, imagePath);
      this.inputPlate = plate;
      try { promptAction.showToast({ message: `识别到车牌:${plate}` }); } catch (_) {}
    } catch (e) {
      const msg = (e as Error).message ?? '';
      try {
        if (msg === 'OCR_KEY_NOT_CONFIGURED') {
          promptAction.showToast({ message: 'OCR密钥未配置,请手动输入' });
        } else if (msg === 'NO_PLATE_FOUND') {
          promptAction.showToast({ message: '未识别到车牌,请手动输入' });
        } else {
          promptAction.showToast({ message: '识别失败,请手动输入' });
        }
      } catch (_) {}
    }
    this.isRecognizing = false;
  }

  // build 方法负责组织页面布局,并把交互控件拼装成完整界面。
  build() {
    Column({ space: 15 }) {
      Text('绑定车辆')
        .fontSize(20).fontWeight(FontWeight.Bold).margin({ top: 10, bottom: 10 })

      TextInput({ placeholder: '手动输入车牌号 (例: 粤B12345)', text: this.inputPlate })
        .onChange((value: string) => { this.inputPlate = value; })
        .width('90%').height(45)

      Text('—————  或拍照识别  —————')
        .fontSize(12).fontColor('#CCCCCC').margin({ top: 5 })

      // 识别中状态
      if (this.isRecognizing) {
        Row({ space: 8 }) {
          LoadingProgress().width(20).height(20).color('#007DFF')
          Text('正在识别...').fontSize(14).fontColor('#666666')
        }.height(45)
      } else {
        Row({ space: 12 }) {
          Button('📷 拍照', { type: ButtonType.Capsule })
            .layoutWeight(1).height(42)
            .backgroundColor('#007DFF')
            .onClick(() => { this.takePhoto(); })
          Button('🖼️ 相册', { type: ButtonType.Capsule })
            .layoutWeight(1).height(42)
            .backgroundColor('#4CAF50')
            .onClick(() => { this.pickFromGallery(); })
        }.width('90%')
      }

      Row({ space: 20 }) {
        Button('取消')
          .width('40%').backgroundColor(Color.Transparent).fontColor('#999999')
          .onClick(() => { this.controller.close(); })
        Button('确定')
          .width('40%').backgroundColor('#007DFF')
          .onClick(async () => {
            const plate = normalizePlate(this.inputPlate.trim());
            // 根据当前业务状态走不同分支,保证页面反馈和数据流一致。
            if (plate !== '') {
              // 写 preferences 落盘
              await PlateStore.savePlate(this.context, plate);
              // 更新 AppStorage,触发所有 @StorageLink('plateNumber') 组件刷新
              this.plateNumber = plate;
              promptAction.showToast({ message: `成功绑定车牌: ${plate}` });
              this.controller.close();
            } else {
              promptAction.showToast({ message: '车牌号不能为空' });
            }
          })
      }.margin({ top: 20, bottom: 10 })
    }
    .padding(20).backgroundColor(Color.White).borderRadius(4).width('85%')
  }
}
5.2.2  OcrConfigDialog.ets
// 百度 OCR 密钥配置弹窗
// 在 AI 识别页右上角 ⚙ 打开,配置后无需重新编译即可生效。
import { promptAction } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
import { OcrConfig, OcrKeys } from '../utils/PlateRecognizer';

@CustomDialog
export struct OcrConfigDialog {
  controller: CustomDialogController
  @State apiKey: string = ''
  @State secretKey: string = ''
  @State isLoading: boolean = true;
  private context = getContext(this) as common.UIAbilityContext;

  aboutToAppear() {
    OcrConfig.getKeys(this.context).then((keys: OcrKeys) => {
      this.apiKey = keys.apiKey;
      this.secretKey = keys.secretKey;
      this.isLoading = false;
    }).catch(() => {
      this.isLoading = false;
    });
  }

  async save() {
    const ak = this.apiKey.trim();
    const sk = this.secretKey.trim();
    if (ak === '' || sk === '') {
      promptAction.showToast({ message: 'API Key 和 Secret Key 不能为空' });
      return;
    }
    await OcrConfig.saveKeys(this.context, ak, sk);
    promptAction.showToast({ message: 'OCR 密钥已保存,可直接识别' });
    this.controller.close();
  }

  build() {
    Column({ space: 14 }) {
      Text('百度 OCR 密钥配置')
        .fontSize(18).fontWeight(FontWeight.Bold).margin({ top: 6, bottom: 2 })

      Text('在百度智能云控制台开通「车牌识别」后,创建应用获取 API Key 与 Secret Key(免费额度 1000 次/月)。')
        .fontSize(12).fontColor('#666666').lineHeight(18)

      TextInput({ placeholder: 'API Key', text: this.apiKey })
        .onChange((value: string) => { this.apiKey = value; })
        .width('100%').height(44).fontSize(14)

      TextInput({ placeholder: 'Secret Key', text: this.secretKey })
        .onChange((value: string) => { this.secretKey = value; })
        .width('100%').height(44).fontSize(14)

      Row({ space: 20 }) {
        Button('取消')
          .width('40%').backgroundColor(Color.Transparent).fontColor('#999999')
          .onClick(() => { this.controller.close(); })
        Button(this.isLoading ? '加载中...' : '保存')
          .width('40%').backgroundColor('#007DFF')
          .enabled(!this.isLoading)
          .onClick(() => { this.save(); })
      }.margin({ top: 10, bottom: 6 })
    }
    .padding(20)
    .backgroundColor(Color.White)
    .borderRadius(4)
    .width('85%')
  }
}

5.3  entryability

5.3.1  entryability.ets
// 导入当前页面依赖的组件、状态工具和系统能力。
import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';
import { GlobalContext, PlateStore } from '../utils/GlobalState';
import { startDevicePolling, stopDevicePolling } from '../utils/DeviceState';
import { initChargeRecorder, restoreActiveSessionOnLaunch } from '../utils/ChargeRecordStore';

// 先声明模块级常量、缓存或定时器句柄,方便多个方法共享。
const DOMAIN = 0x0000;

export default class EntryAbility extends UIAbility {
  // 这里定义一个独立方法,用来封装当前模块的关键业务逻辑。
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    GlobalContext.init();
    // 注入充电记录上下文并恢复上次未闭合会话
    initChargeRecorder(this.context);
    restoreActiveSessionOnLaunch();

    // 从 preferences 读取上次保存的车牌,同步到 AppStorage
    // 这里定义一个独立方法,用来封装当前模块的关键业务逻辑。
    PlateStore.getPlate(this.context).then((plate: string) => {
      AppStorage.set('plateNumber', plate);
    });
    startDevicePolling();
    try {
      this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
      // 这里定义一个独立方法,用来封装当前模块的关键业务逻辑。
    } catch (err) {
      hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode: %{public}s', JSON.stringify(err));
    }
  }

  // 这里定义一个独立方法,用来封装当前模块的关键业务逻辑。
  onWindowStageCreate(windowStage: window.WindowStage): void {
    windowStage.loadContent('pages/LoginPage', (err) => {
      // 根据当前业务状态走不同分支,保证页面反馈和数据流一致。
      if (err.code) {
        hilog.error(DOMAIN, 'testTag', 'Failed to load content: %{public}s', JSON.stringify(err));
      }
    });
  }

  onDestroy(): void {
    stopDevicePolling();
  }
  onWindowStageDestroy(): void {}
  onForeground(): void {}
  onBackground(): void {
    stopDevicePolling();
  }
}

5.4  entrybackupability

5.4.1  EntryBackupAbility.ets
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BackupExtensionAbility, BundleVersion } from '@kit.CoreFileKit';

const DOMAIN = 0x0000;

export default class EntryBackupAbility extends BackupExtensionAbility {
  async onBackup() {
    hilog.info(DOMAIN, 'testTag', 'onBackup ok');
    await Promise.resolve();
  }

  async onRestore(bundleVersion: BundleVersion) {
    hilog.info(DOMAIN, 'testTag', 'onRestore ok %{public}s', JSON.stringify(bundleVersion));
    await Promise.resolve();
  }
}

5.5  pages

5.5.1  ChargeRecordsPage.ets
// 充电记录列表页:展示当前用户在主页发起的全部充电记录,最新在前。
// 数据源:ChargeRecordStore(preferences 持久化)。

import { router, promptAction } from '@kit.ArkUI';
import { ChargeRecord, getChargeRecords, clearChargeRecords, getActiveSession } from '../utils/ChargeRecordStore';

function pad(n: number): string {
  return n < 10 ? '0' + n : '' + n;
}

function fmtDateTime(ts: number): string {
  const d = new Date(ts);
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}

function fmtDuration(min: number): string {
  if (min < 60) {
    return `${min} 分钟`;
  }
  const h = Math.floor(min / 60);
  const m = min % 60;
  return m > 0 ? `${h} 小时 ${m} 分` : `${h} 小时`;
}

@Component
struct RecordCard {
  @Prop record: ChargeRecord = {
    id: 0, startTime: 0, endTime: 0, durationMin: 0, peakKw: 0, plate: '未绑定', closedNormally: true
  };

  build() {
    Column({ space: 8 }) {
      Row() {
        Column({ space: 2 }) {
          Text(fmtDateTime(this.record.startTime)).fontSize(15).fontColor('#222222').fontWeight(FontWeight.Medium)
          Text(`车牌:${this.record.plate}`).fontSize(12).fontColor('#999999')
        }
        .alignItems(HorizontalAlign.Start)
        Blank()
        if (!this.record.closedNormally) {
          Text('异常结束').fontSize(11).fontColor('#FA5151')
            .padding({ left: 8, right: 8, top: 2, bottom: 2 })
            .backgroundColor('rgba(250,81,81,0.1)').borderRadius(8)
        }
      }
      .width('100%')

      Divider().color('#F0F0F0').strokeWidth(1)

      Row() {
        Column({ space: 2 }) {
          Text('时长').fontSize(12).fontColor('#999999')
          Text(fmtDuration(this.record.durationMin)).fontSize(16).fontColor('#007DFF').fontWeight(FontWeight.Bold)
        }
        Column({ space: 2 }) {
          Text('峰值功率').fontSize(12).fontColor('#999999')
          Text(`${this.record.peakKw.toFixed(1)} kW`).fontSize(16).fontColor('#222222').fontWeight(FontWeight.Bold)
        }
        Blank()
        Text(`${fmtDateTime(this.record.endTime)} 结束`).fontSize(11).fontColor('#BBBBBB')
      }
      .width('100%')
    }
    .width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(12)
    .shadow({ radius: 6, color: '#0F000000', offsetX: 0, offsetY: 2 })
  }
}

@Entry
@Component
struct ChargeRecordsPage {
  @State records: ChargeRecord[] = [];
  @State loading: boolean = true;
  @State activeStart: number = 0;
  @State activePlate: string = '';
  @State activePeak: number = 0;
  private refreshTimer: number = -1;
  private destroyed: boolean = false;

  async reload() {
    try {
      this.loading = true;
      this.records = await getChargeRecords();
      if (this.destroyed) return;
      const act = getActiveSession();
      if (act) {
        this.activeStart = act.startTime;
        this.activePlate = act.plate;
        this.activePeak = act.peakKw;
      } else {
        this.activeStart = 0;
        this.activePlate = '';
        this.activePeak = 0;
      }
      this.loading = false;
    } catch (e) {
      if (!this.destroyed) this.loading = false;
    }
  }

  aboutToAppear() {
    this.destroyed = false;
    this.reload();
    // 每 3 秒刷新一次,让"充电中"卡片和列表实时更新
    this.refreshTimer = setInterval(() => { this.reload(); }, 3000);
  }

  aboutToDisappear() {
    this.destroyed = true;
    if (this.refreshTimer !== -1) {
      clearInterval(this.refreshTimer);
      this.refreshTimer = -1;
    }
  }

  private totalCount(): number {
    return this.records.length;
  }

  private totalMinutes(): number {
    let s = 0;
    for (const r of this.records) {
      s += r.durationMin;
    }
    return s;
  }

  async onClear() {
    try {
      await clearChargeRecords();
      this.records = [];
      promptAction.showToast({ message: '已清空' });
    } catch (e) {}
  }

  build() {
    Column() {
      // 顶部导航
      Row() {
        Text('< 返回').fontSize(15).fontColor('#FFFFFF').onClick(() => { router.back(); })
        Blank()
        Text('充电记录').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
        Blank()
        Text('清空').fontSize(13).fontColor('#FFFFFF')
          .opacity(this.records.length > 0 ? 1 : 0.4)
          .onClick(() => {
            if (this.records.length === 0) return;
            this.onClear();
          })
      }
      .width('100%').height(48).padding({ left: 14, right: 14 })
      .linearGradient({ angle: 90, colors: [['#007DFF', 0], ['#0057CC', 1]] })

      // 汇总条
      Row({ space: 24 }) {
        Column({ space: 2 }) {
          Text(`${this.totalCount()}`).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#007DFF')
          Text('充电次数').fontSize(12).fontColor('#999999')
        }
        Column({ space: 2 }) {
          Text(fmtDuration(this.totalMinutes())).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#007DFF')
          Text('累计时长').fontSize(12).fontColor('#999999')
        }
      }
      .width('100%').padding({ top: 16, bottom: 16 }).backgroundColor('#FFFFFF')

      // 进行中的充电会话卡片
      if (this.activeStart > 0) {
        Column({ space: 8 }) {
          Row() {
            Circle({ width: 8, height: 8 }).fill('#00E676')
              .margin({ right: 6 })
            Text('充电中').fontSize(15).fontColor('#00C853').fontWeight(FontWeight.Medium)
            Blank()
            Text(fmtDateTime(this.activeStart) + ' 开始')
              .fontSize(12).fontColor('#999999')
          }.width('100%')
          Row() {
            Text(`车牌:${this.activePlate}`).fontSize(13).fontColor('#666666')
            Blank()
            Text(`峰值 ${this.activePeak.toFixed(1)} kW`).fontSize(13).fontColor('#007DFF')
          }.width('100%')
        }
        .width('100%').padding(16).backgroundColor('rgba(0,200,83,0.08)').borderRadius(12)
        .margin({ left: 14, right: 14, top: 12 })
      }

      if (this.records.length === 0 && this.activeStart === 0) {
        Column({ space: 12 }) {
          Text('🔌').fontSize(48)
          Text(this.loading ? '加载中...' : '暂无充电记录')
            .fontSize(15).fontColor('#999999')
          if (!this.loading) {
            Text('在首页点击「充电」开始一次充电后,记录会显示在这里')
              .fontSize(12).fontColor('#BBBBBB').textAlign(TextAlign.Center)
              .padding({ left: 32, right: 32 })
          }
        }
        .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
      } else {
        List({ space: 12 }) {
          ForEach(this.records, (item: ChargeRecord) => {
            ListItem() {
              RecordCard({ record: item })
            }
          }, (item: ChargeRecord) => '' + item.id)
        }
        .layoutWeight(1).width('100%').padding({ left: 14, right: 14, top: 12, bottom: 12 })
        .scrollBar(BarState.Off)
      }
    }
    .width('100%').height('100%').backgroundColor('#F5F7F9')
  }
}
5.5.2  HomePage.ets 
import router from '@ohos.router'
import { promptAction } from '@kit.ArkUI'
import { Dashboard } from '../views/Dashboard'
import { MapPage } from '../views/MapPage'
import { AICamera } from '../views/AICamera'
import { ProfilePage } from '../views/ProfilePage'
import { sendChargeCommand } from '../utils/IoTDAService'
import { startChargeSimulation, stopChargeSimulation } from '../utils/ChargeSimulator'
import { beginChargeSession, endChargeSession, abortChargeSession } from '../utils/ChargeRecordStore'

@Entry
@Component
struct MainPage {
  @State currentIndex: number = 0

  // IoT 实时状态(用于顶栏徽章展示)
  @StorageLink('pileStatus') pileStatus: string = '0'
  @StorageLink('powerKw') powerKw: number = 0.0
  @StorageLink('durationMin') durationMin: number = 0
  @StorageLink('nearlinkStatus') nearlinkStatus: boolean = false

  @State chargingLoading: boolean = false

  private titles: string[] = ['首页', '地图', 'AI识别', '我的']
  private tabIcons: string[] = ['🏠', '🗺️', '📷', '👤']
  private readonly SWITCH_TAB_EVENT = 'switchTab'

  private showTip(msg: string) {
    try { promptAction.showToast({ message: msg }) } catch (_e) {}
  }

  private async toggleCharge() {
    if (this.chargingLoading) return
    this.chargingLoading = true
    const prevStatus = this.pileStatus
    const targetStatus = prevStatus === '1' ? '0' : '1'
    const stopDuration = this.durationMin
    try {
      // 用户手动控制充电后,退出 AI 识别的本地 mock 状态,由云端状态接管
      AppStorage.set('mockCharging', false)
      // 乐观更新:点击后立即切换顶栏「充电/停止」状态并实时驱动功率显示,
      // 让用户可以随时自主选择结束充电;指令下发失败时回滚
      AppStorage.set('pileStatus', targetStatus)
      if (targetStatus === '1') {
        startChargeSimulation()
        // 用户点了"充电"就立刻开始记录会话;start 命令若失败回滚,再 abort
        const plate = AppStorage.get<string>('plateNumber') ?? '未绑定'
        await beginChargeSession(plate)
      } else {
        stopChargeSimulation()
        AppStorage.set('powerKw', 0.0)
        AppStorage.set('durationMin', 0)
      }
      // 立即进入轮询接管窗口,避免 await 下发期间被云端旧影子覆盖(充电 2 秒自动停)
      AppStorage.set('commandSyncUntil', new Date().getTime() + 20000)
      const res = await sendChargeCommand(targetStatus === '1' ? 'start_charge' : 'stop_charge')
      if (res.ok) {
        if (targetStatus === '1') {
          this.showTip('启动充电指令已下发')
        } else {
          await endChargeSession(stopDuration, true)
          this.showTip('停止充电指令已下发')
        }
      } else if (targetStatus === '0') {
        await endChargeSession(stopDuration, false)
        this.showTip("停止指令未被设备确认:" + res.reason)
      } else {
        // start 失败:不回滚、不 abort,保持本地乐观充电状态
        // (演示场景下设备常离线,用户看到"充电中"就应能在停止后生成记录)
        this.showTip("启动未被设备确认:" + res.reason)
      }
    } catch (e) {
      if (targetStatus === '1') {
        // start 网络异常:保持本地充电状态,不 abort
        this.showTip('网络异常,本地仍保持充电状态')
      } else {
        await endChargeSession(stopDuration, false);
        this.showTip('网络异常')
      }
    } finally {
      this.chargingLoading = false
    }
  }

  aboutToAppear() {
    // 用 EventHub 监听外部 Tab 跳转请求(ArkTS 正确方式)
    try {
      getContext(this).eventHub.on(this.SWITCH_TAB_EVENT, (index: number) => {
        if (index >= 0 && index < 4 && index !== this.currentIndex) {
          this.currentIndex = index
        }
      })
    } catch (e) {
      console.error('EventHub 注册失败:', JSON.stringify(e))
    }
  }

  aboutToDisappear() {
    try {
      getContext(this).eventHub.off(this.SWITCH_TAB_EVENT)
    } catch (e) {
      console.error('EventHub 注销失败:', JSON.stringify(e))
    }
  }

  private exitLogin() {
    // 重置登录相关状态(不 delete,避免 @StorageLink 订阅者报错)
    try {
      AppStorage.setOrCreate('pileStatus', '0')
      AppStorage.setOrCreate('durationMin', 0)
      AppStorage.setOrCreate('powerKw', 0.0)
      AppStorage.setOrCreate('mockCharging', false)
      stopChargeSimulation()
    } catch (_e) {}
    try {
      router.replaceUrl({ url: 'pages/LoginPage' })
    } catch (e) {
      console.error('退出登录失败:', JSON.stringify(e))
    }
  }

  @Builder
  TabBar() {
    Row() {
      ForEach(this.titles, (title: string, idx: number) => {
        Column() {
          Text(this.tabIcons[idx]).fontSize(22)
          Text(title)
            .fontSize(11).margin({ top: 2 })
            .fontColor(this.currentIndex === idx ? '#007DFF' : '#999999')
            .fontWeight(this.currentIndex === idx ? FontWeight.Medium : FontWeight.Normal)
        }
        .layoutWeight(1).height(56).justifyContent(FlexAlign.Center)
        .onClick(() => { this.currentIndex = idx })
      }, (_title: string, idx: number) => 'tab_' + idx)
    }
    .width('100%').height(56)
    .backgroundColor('#FFFFFF')
    .shadow({ radius: 4, color: '#11000000', offsetY: -2 })
  }

  @Builder
  TopTitleBar() {
    Row() {
      Text('⚡ 智慧充电桩')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')

      // IoT 实时状态徽章
      if (this.pileStatus === '1') {
        Row({ space: 4 }) {
          Circle({ width: 6, height: 6 }).fill('#00E676')
          Text(`充电中 ${this.powerKw.toFixed(1)}kW`)
            .fontSize(11).fontColor('#FFFFFF').opacity(0.9)
        }
        .padding({ left: 8, right: 8, top: 3, bottom: 3 })
        .backgroundColor('rgba(0, 230, 118, 0.25)')
        .borderRadius(10)
        .margin({ left: 8 })
      } else if (this.pileStatus === '2') {
        Row({ space: 4 }) {
          Circle({ width: 6, height: 6 }).fill('#FF5252')
          Text('故障').fontSize(11).fontColor('#FFFFFF').opacity(0.9)
        }
        .padding({ left: 8, right: 8, top: 3, bottom: 3 })
        .backgroundColor('rgba(255, 82, 82, 0.35)')
        .borderRadius(10)
        .margin({ left: 8 })
      } else {
        Row({ space: 4 }) {
          Circle({ width: 6, height: 6 }).fill('#FFFFFF').opacity(0.6)
          Text('空闲').fontSize(11).fontColor('#FFFFFF').opacity(0.75)
        }
        .margin({ left: 8 })
      }

      Blank()

      // 快速充电按钮
      if (this.pileStatus !== '2') {
        Row({ space: 4 }) {
          Text(this.pileStatus === '1' ? '⏹ 停止' : '▶ 充电')
            .fontSize(12)
            .fontColor('#FFFFFF')
        }
        .padding({ left: 10, right: 10, top: 4, bottom: 4 })
        .backgroundColor(this.pileStatus === '1' ? '#E53935' : '#00C853')
        .borderRadius(12)
        .margin({ right: 10 })
        .opacity(this.chargingLoading ? 0.6 : 1)
        .enabled(!this.chargingLoading)
        .onClick(() => { this.toggleCharge() })
      }

      // 星闪连接状态指示
      Row({ space: 4 }) {
        Circle({ width: 6, height: 6 }).fill(this.nearlinkStatus ? '#00E676' : '#FF9800')
        Text(this.nearlinkStatus ? '星闪' : '离线').fontSize(11).fontColor('#FFFFFF').opacity(0.8)
      }
      .margin({ right: 12 })

      Text('退出')
        .fontSize(13)
        .fontColor('#FFFFFF')
        .padding({ left: 10, right: 10, top: 4, bottom: 4 })
        .backgroundColor('rgba(255,255,255,0.2)')
        .borderRadius(12)
        .onClick(() => { this.exitLogin() })
    }
    .width('100%')
    .height(48)
    .padding({ left: 14, right: 14 })
    .linearGradient({
      angle: 90,
      colors: [['#007DFF', 0], ['#0057CC', 1]]
    })
  }

  build() {
    Stack({ alignContent: Alignment.BottomEnd }) {
      Column() {
        this.TopTitleBar()
        Stack() {
          if (this.currentIndex === 0) { Column() { Dashboard() } }
          if (this.currentIndex === 1) { Column() { MapPage() } }
          if (this.currentIndex === 2) { Column() { AICamera() } }
          if (this.currentIndex === 3) { Column() { ProfilePage() } }
        }
        .width('100%').layoutWeight(1)
        this.TabBar()
      }
      .width('100%')
      .height('100%')

      // 右下角 AI 悬浮按钮(在 AI识别 Tab 自动隐藏,避免遮挡内容)
      if (this.currentIndex !== 2) {
        Column() {
          Text('🤖')
            .fontSize(28)
          Text('AI')
            .fontSize(10)
            .fontColor('#FFFFFF')
            .margin({ top: 2 })
        }
        .width(60)
        .height(60)
        .borderRadius(30)
        .linearGradient({
          angle: 135,
          colors: [['#FF6B6B', 0], ['#C44569', 1]]
        })
        .justifyContent(FlexAlign.Center)
        .alignItems(HorizontalAlign.Center)
        .shadow({ radius: 8, color: '#44000000', offsetX: 0, offsetY: 4 })
        .margin({ right: 20, bottom: 70 })
        .onClick(() => {
          try {
            router.pushUrl({ url: 'pages/LLMAgent' })
          } catch (e) {
            console.error('跳转LLMAgent失败:', JSON.stringify(e))
            this.showTip('AI页面跳转失败')
          }
        })
      }
    }
    .width('100%')
    .height('100%')
  }
}
5.5.3  LLMAgent.ets
//-----------------------------------------------连接DeepSeek-------------------------------------------------------

// pages/LLMAgent.ets
// 导入当前页面依赖的组件、状态工具和系统能力。
import { router } from '@kit.ArkUI';
import { http } from '@kit.NetworkKit';
import { getDeviceStatePrompt } from '../utils/DeviceState';

// 先声明模块级常量、缓存或定时器句柄,方便多个方法共享。
const DEEPSEEK_API_KEY = 'sk';
const DEEPSEEK_URL = 'api';

interface Message {
  role: 'user' | 'assistant';
  content: string;
}
interface ChatRequestMessage {
  role: string;
  content: string;
}
interface DeepSeekMessage {
  role: string;
  content: string;
}

interface DeepSeekChoice {
  message: DeepSeekMessage;
}

interface DeepSeekResponse {
  choices: DeepSeekChoice[];
}
// 使用装饰器声明页面、组件或构建器,供界面树和框架生命周期识别。
@Entry
  // 使用装饰器声明页面、组件或构建器,供界面树和框架生命周期识别。
@Component
struct LLMAgent {
  // 这里集中定义组件状态、共享状态以及界面控制器。
  @State messages: Message[] = [];
  @State inputText: string = '';
  @State isLoading: boolean = false;
  private scroller: Scroller = new Scroller();

  // 快捷提问列表
  private quickQuestions: string[] = [
    '现在这辆车充了多久了?',
    '当前温度和湿度是多少?',
    '充电桩现在是什么状态?',
  ];

  // 这个异步方法负责处理耗时任务,并在完成后回写界面状态。
  async sendMessage(userText: string) {
    // 根据当前业务状态走不同分支,保证页面反馈和数据流一致。
    if (!userText.trim() || this.isLoading) return;

    // 加入用户消息
    this.messages = [...this.messages, { role: 'user', content: userText }];
    this.inputText = '';
    this.isLoading = true;
    this.scrollToBottom();
    let reply: string = '';
    try {
      // 构建发送给 DeepSeek 的消息,注入设备状态作为 system prompt
      const requestMessages: ChatRequestMessage[] = [
        { role: 'system', content: getDeviceStatePrompt() } as ChatRequestMessage,
        // 这里定义一个独立方法,用来封装当前模块的关键业务逻辑。
        ...this.messages.map((m: Message): ChatRequestMessage => ({
          role: m.role,
          content: m.content
        }))
      ];

      const httpRequest = http.createHttp();
      const response = await httpRequest.request(DEEPSEEK_URL, {
        method: http.RequestMethod.POST,
        header: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${DEEPSEEK_API_KEY}`
        },
        extraData: JSON.stringify({
          model: 'deepseek-ai/DeepSeek-V4-Flash',
          messages: requestMessages,
          max_tokens: 200,
          temperature: 0.7
        }),
        connectTimeout: 10000,
        readTimeout: 15000
      });
      console.log(response.result.toString())
      // 根据当前业务状态走不同分支,保证页面反馈和数据流一致。
      if (response.responseCode === 200) {
        const resultStr: string = (response.result as object).toString();
        const result = JSON.parse(resultStr) as DeepSeekResponse;
        reply = result.choices[0].message.content;
      } else if(response.responseCode === 402) {
        reply = '账户余额/可用调用额度耗尽。';
      }else{
        reply = '请求失败,请检查网络连接。';
      }
      httpRequest.destroy();
      // 这里定义一个独立方法,用来封装当前模块的关键业务逻辑。
    } catch (e) {
      reply = '网络异常,请稍后重试。';  // ← 改成只赋值,不再单独更新消息
    }
    this.messages = [...this.messages, { role: 'assistant', content: reply }];
    this.isLoading = false;
    this.scrollToBottom();
  }
  // 这里定义一个独立方法,用来封装当前模块的关键业务逻辑。
  scrollToBottom() {
    // 把状态映射和格式化逻辑封装成独立方法,便于页面直接调用。
    setTimeout(() => {
      this.scroller.scrollEdge(Edge.Bottom);
    }, 100);
  }

  // 消息气泡
  // 使用装饰器声明页面、组件或构建器,供界面树和框架生命周期识别。
  @Builder
  // 这里定义一个独立方法,用来封装当前模块的关键业务逻辑。
  MessageBubble(msg: Message) {
    Row() {
      // 根据当前业务状态走不同分支,保证页面反馈和数据流一致。
      if (msg.role === 'user') {
        Blank()
        Text(msg.content)
          .fontSize(15)
          .fontColor(Color.White)
          .backgroundColor('#007DFF')
          .padding({ left: 14, right: 14, top: 10, bottom: 10 })
          .borderRadius({ topLeft: 16, topRight: 4, bottomLeft: 16, bottomRight: 16 })
          .constraintSize({ maxWidth: '70%' })
          .margin({ right: 12 })
      } else {
        Text(msg.content)
          .fontSize(15)
          .fontColor('#333333')
          .backgroundColor('#FFFFFF')
          .padding({ left: 14, right: 14, top: 10, bottom: 10 })
          .borderRadius({ topLeft: 4, topRight: 16, bottomLeft: 16, bottomRight: 16 })
          .constraintSize({ maxWidth: '70%' })
          .margin({ left: 12 })
          .shadow({ radius: 4, color: '#11000000', offsetX: 0, offsetY: 2 })
        Blank()
      }
    }
    .width('100%')
    .margin({ top: 8, bottom: 8 })
  }

  // build 方法负责组织页面布局,并把交互控件拼装成完整界面。
  build() {
    Column() {
      // 顶部导航栏
      Row() {
        Text('< 返回')
          .fontSize(16)
          .fontColor('#007DFF')
          .onClick(() => { router.back(); })
        Blank()
        Text('AI 助手')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
        Blank()
        Text('      ')
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12, bottom: 12 })
      .backgroundColor('#FFFFFF')
      .shadow({ radius: 4, color: '#11000000', offsetX: 0, offsetY: 2 })

      // 对话区域
      // 这里定义一个独立方法,用来封装当前模块的关键业务逻辑。
      Scroll(this.scroller) {
        Column() {
          // 欢迎语
          // 根据当前业务状态走不同分支,保证页面反馈和数据流一致。
          if (this.messages.length === 0) {
            Column({ space: 8 }) {
              Text('👋 你好!我是充电桩AI助手')
                .fontSize(15)
                .fontColor('#666666')
                .margin({ top: 30 })
              Text('可以问我设备状态、充电情况等问题')
                .fontSize(13)
                .fontColor('#999999')
            }.width('100%').alignItems(HorizontalAlign.Center)
          }

          // 消息列表
          // 这里定义一个独立方法,用来封装当前模块的关键业务逻辑。
          ForEach(this.messages, (msg: Message) => {
            this.MessageBubble(msg)
          })

          // 加载中动画
          // 根据当前业务状态走不同分支,保证页面反馈和数据流一致。
          if (this.isLoading) {
            Row({ space: 6 }) {
              Text('AI 正在思考')
                .fontSize(13)
                .fontColor('#999999')
                .margin({ left: 12 })
              LoadingProgress()
                .width(20)
                .height(20)
                .color('#007DFF')
            }
            .width('100%')
            .margin({ top: 8, bottom: 8 })
          }
        }
        .width('100%')
        .padding({ left: 8, right: 8, bottom: 16 })
      }
      .layoutWeight(1)
      .backgroundColor('#F5F7F9')
      .scrollBar(BarState.Off)

      // 快捷提问
      // 这里定义一个独立方法,用来封装当前模块的关键业务逻辑。
      Scroll() {
        Row({ space: 8 }) {
          // 这里定义一个独立方法,用来封装当前模块的关键业务逻辑。
          ForEach(this.quickQuestions, (q: string) => {
            Text(q)
              .fontSize(12)
              .fontColor('#007DFF')
              .padding({ left: 12, right: 12, top: 6, bottom: 6 })
              .backgroundColor('#EBF4FF')
              .borderRadius(16)
              .onClick(() => { this.sendMessage(q); })
          })
        }
        .padding({ left: 12, right: 12 })
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')
      .height(44)
      .backgroundColor('#FFFFFF')

      // 输入框
      Row({ space: 10 }) {
        TextInput({ placeholder: '输入消息...', text: this.inputText })
          .layoutWeight(1)
          .height(44)
          .backgroundColor('#F5F7F9')
          .borderRadius(22)
          .padding({ left: 16, right: 16 })
          .onChange((v: string) => { this.inputText = v; })
          .onSubmit(() => { this.sendMessage(this.inputText); })

        Button('发送', { type: ButtonType.Capsule })
          .height(44)
          .width(70)
          .fontSize(15)
          .backgroundColor(this.inputText.trim() ? '#007DFF' : '#CCCCCC')
          .onClick(() => { this.sendMessage(this.inputText); })
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 10, bottom: 10 })
      .backgroundColor('#FFFFFF')
    }
    .width('100%')
    .height('100%')
  }
}

5.5.4  LoginPage.ets
import router from '@ohos.router'
import { promptAction } from '@kit.ArkUI'
import { UserStore, UserAccount } from '../utils/GlobalState'
import { common } from '@kit.AbilityKit'

@Entry
@Component
struct LoginPage {
  // 用户名输入
  @State username: string = ''
  // 密码输入
  @State password: string = ''
  // 提示信息
  @State tipMessage: string = ''
  // 密码是否隐藏
  @State isPasswordHidden: boolean = true

  @State regUsername: string = ''
  @State regPassword: string = ''
  @State regConfirm: string = ''

  private dialogController: CustomDialogController = new CustomDialogController({
    builder: () => { this.RegisterDialogBuilder() },
    alignment: DialogAlignment.Bottom,
    customStyle: true,
    autoCancel: true,
    offset: { dx: 0, dy: 0 }
  })

  private context = getContext(this) as common.UIAbilityContext

  private readonly DEFAULT_USERNAME: string = '梨梨'
  private readonly DEFAULT_PASSWORD: string = '123456'

  private async getAllUsers(): Promise<UserAccount[]> {
    try {
      const users = await UserStore.getUsers(this.context)
      if (users.length > 0) return users
    } catch (e) {
      console.error('读取用户失败:', JSON.stringify(e))
    }
    return [{ username: this.DEFAULT_USERNAME, password: this.DEFAULT_PASSWORD }]
  }

  private verifyLogin(username: string, password: string, users: UserAccount[]): boolean {
    for (const u of users) {
      if (u.username === username && u.password === password) return true
    }
    return false
  }

  build() {
    Column() {
      // 顶部装饰区域
      Column() {
        // 充电桩图标
        Text('⚡')
          .fontSize(60)
          .margin({ bottom: 10 })
        // 应用名
        Text('智慧充电桩')
          .fontSize(32)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
        // 副标题
        Text('绿色出行 · 便捷充电')
          .fontSize(14)
          .fontColor('#E0E0E0')
          .margin({ top: 8 })
      }
      .width('100%')
      .padding({ top: 80, bottom: 60 })
      .linearGradient({
        angle: 135,
        colors: [['#1E88E5', 0], ['#1565C0', 1]]
      })

      // 表单区域
      Column({ space: 20 }) {
        // 用户名输入框
        Row() {
          Text('👤')
            .fontSize(20)
            .margin({ right: 12 })
          TextInput({ placeholder: '请输入用户名', text: this.username })
            .type(InputType.Normal)
            .onChange((value: string) => {
              this.username = value
              this.tipMessage = ''
            })
            .layoutWeight(1)
        }
        .width('100%')
        .padding(16)
        .backgroundColor('#F5F5F5')
        .borderRadius(12)

        // 密码输入框
        Row() {
          Text('🔒')
            .fontSize(20)
            .margin({ right: 12 })
          TextInput({ placeholder: '请输入密码', text: this.password })
            .type(this.isPasswordHidden ? InputType.Password : InputType.Normal)
            .onChange((value: string) => {
              this.password = value
              this.tipMessage = ''
            })
            .layoutWeight(1)
          // 密码显示/隐藏按钮
          Text(this.isPasswordHidden ? '👁️' : '🙈')
            .fontSize(20)
            .onClick(() => {
              this.isPasswordHidden = !this.isPasswordHidden
            })
        }
        .width('100%')
        .padding(16)
        .backgroundColor('#F5F5F5')
        .borderRadius(12)

        // 提示信息
        if (this.tipMessage !== '') {
          Text(this.tipMessage)
            .fontSize(14)
            .fontColor('#FF5722')
            .width('100%')
            .textAlign(TextAlign.Center)
        }

        // 登录按钮
        Button() {
          Text('登 录')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
        }
        .width('100%')
        .height(50)
        .backgroundColor('#1E88E5')
        .borderRadius(25)
        .margin({ top: 10 })
        .onClick(() => {
          this.handleLogin()
        })

        // 立即注册
        Row() {
          Blank()
          Text('还没有账号?').fontSize(13).fontColor('#999999')
          Text('立即注册').fontSize(13).fontColor('#1E88E5')
            .fontWeight(FontWeight.Bold).margin({ left: 4 })
            .onClick(() => {
              this.regUsername = ''
              this.regPassword = ''
              this.regConfirm = ''
              try { this.dialogController.open() } catch (_e) {}
            })
          Blank()
        }
        .width('100%').margin({ top: 16 })

        // 提示账号
        Column() {
          Text('测试账号: 梨梨  /  密码: 123456')
            .fontSize(12).fontColor('#999999')
        }
        .margin({ top: 20 })
      }
      .width('85%').margin({ top: 40 })

      Blank()
    }
    .width('100%').height('100%').backgroundColor('#FFFFFF')
  }

  // 注册弹窗
  @Builder
  RegisterDialogBuilder() {
    Column({ space: 18 }) {
      Text('创建账号').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#333333').margin({ top: 20 })
      Text('请填写注册信息').fontSize(14).fontColor('#999999')

      Row() {
        Text('👤').fontSize(20).margin({ right: 12 })
        TextInput({ placeholder: '请输入用户名', text: this.regUsername })
          .type(InputType.Normal).layoutWeight(1)
          .onChange((v: string) => { this.regUsername = v })
      }.width('100%').padding(16).backgroundColor('#F5F5F5').borderRadius(12)

      Row() {
        Text('🔒').fontSize(20).margin({ right: 12 })
        TextInput({ placeholder: '请输入密码', text: this.regPassword })
          .type(InputType.Password).layoutWeight(1)
          .onChange((v: string) => { this.regPassword = v })
      }.width('100%').padding(16).backgroundColor('#F5F5F5').borderRadius(12)

      Row() {
        Text('🔒').fontSize(20).margin({ right: 12 })
        TextInput({ placeholder: '请再次输入密码', text: this.regConfirm })
          .type(InputType.Password).layoutWeight(1)
          .onChange((v: string) => { this.regConfirm = v })
      }.width('100%').padding(16).backgroundColor('#F5F5F5').borderRadius(12)

      Row({ space: 16 }) {
        Button('取消').layoutWeight(1).height(48).backgroundColor('#F5F5F5')
          .fontColor('#666666').borderRadius(24)
          .onClick(() => { try { this.dialogController.close() } catch (_e) {} })
        Button('注册').layoutWeight(1).height(48).backgroundColor('#1E88E5')
          .fontColor('#FFFFFF').borderRadius(24)
          .onClick(() => { this.handleRegister() })
      }.width('100%').margin({ top: 10 })
    }
    .width('85%').alignItems(HorizontalAlign.Center).margin({ top: 30 })
    .padding({ bottom: 30 })
    .backgroundColor('#FFFFFF')
    .borderRadius({ topLeft: 24, topRight: 24 })
  }

  private showTip(msg: string) {
    try { promptAction.showToast({ message: msg }) } catch (_e) {}
  }

  private async handleRegister() {
    const u = this.regUsername.trim()
    const p = this.regPassword.trim()
    const c = this.regConfirm.trim()
    if (u === '') { this.showTip('请输入用户名'); return }
    if (p === '') { this.showTip('请输入密码'); return }
    if (p.length < 4) { this.showTip('密码至少4位'); return }
    if (p !== c) { this.showTip('两次密码不一致'); return }
    try {
      const existing = await UserStore.getUsers(this.context)
      // 检查用户名是否已存在
      for (const e of existing) {
        if (e.username === u) {
          this.showTip('用户名已被注册')
          return
        }
      }
      existing.push({ username: u, password: p })
      await UserStore.saveUsers(this.context, existing)
      this.showTip('注册成功,请登录')
      try { this.dialogController.close() } catch (_e) {}
      this.username = u
      this.password = ''
    } catch (e) {
      this.showTip('注册失败,请重试')
    }
  }

  private async handleLogin() {
    if (this.username.trim() === '') { this.tipMessage = '请输入用户名'; return }
    if (this.password.trim() === '') { this.tipMessage = '请输入密码'; return }
    const users = await this.getAllUsers()
    if (this.verifyLogin(this.username, this.password, users)) {
      try {
        router.replaceUrl({ url: 'pages/HomePage' })
      } catch (e) {
        console.error('跳转失败:', JSON.stringify(e))
        this.showTip('页面跳转失败,请重试')
      }
    } else {
      this.tipMessage = '账号或密码错误'
    }
  }
}
5.5.5  SearchPage.ets
// 搜索页:按桩位编号 / 地址搜索充电桩(内置桩位 + 用户自定义桩位),
// 自定义桩位由地图页从 map.html 的 localStorage 同步到 AppStorage('customPiles')
// 点击结果后跳回地图并定位到对应桩位
import router from '@ohos.router'

// 自定义桩位数据结构(与 map.html localStorage 及地图页同步字段对齐)
interface CustomPile {
  id: string
  lng: number
  lat: number
  address: string
}

// 搜索结果显示的桩位信息
class PileInfo {
  id: string
  address: string
  lng: number
  lat: number
  statusText: string
  statusColor: string

  constructor(id: string, address: string, lng: number, lat: number, statusText: string, statusColor: string) {
    this.id = id
    this.address = address
    this.lng = lng
    this.lat = lat
    this.statusText = statusText
    this.statusColor = statusColor
  }
}

@Entry
@Component
struct SearchPage {
  @State keyword: string = ''
  @State results: PileInfo[] = []
  @StorageLink('customPiles') customPiles: CustomPile[] = []

  // 与 map.html 内置桩位保持一致(西安市,GCJ-02 坐标)
  private builtinPiles: PileInfo[] = [
    new PileInfo('CP-001', '雁塔区大雁塔南广场', 108.9647, 34.2186, '空闲', '#4CAF50'),
    new PileInfo('CP-002', '雁塔区大唐不夜城步行街', 108.9570, 34.2140, '充电中', '#2196F3'),
    new PileInfo('CP-003', '雁塔区曲江池遗址公园', 108.9910, 34.1950, '已完成', '#9E9E9E'),
    new PileInfo('CP-004', '碑林区钟楼商圈', 108.9468, 34.2613, '空闲', '#4CAF50')
  ]

  // 内置桩位 + 用户自定义桩位合并为完整可搜索列表
  private buildAllPiles(): PileInfo[] {
    const customList: PileInfo[] = this.customPiles.map((c: CustomPile) =>
      new PileInfo(c.id, c.address || '自定义位置', c.lng, c.lat, '自定义', '#FF9800')
    )
    return this.builtinPiles.concat(customList)
  }

  aboutToAppear() {
    // 进入页面默认展示全部桩位(含自定义)
    this.results = this.buildAllPiles()
  }

  private filter(keyword: string) {
    const kw = keyword.trim()
    const upper = kw.toUpperCase()
    const all = this.buildAllPiles()
    if (!kw) {
      this.results = all
      return
    }
    this.results = all.filter((p: PileInfo) =>
      p.id.toUpperCase().includes(upper) || p.address.includes(kw)
    )
  }

  // 点击结果:通知地图页定位,然后返回地图
  private onPick(p: PileInfo) {
    try {
      getContext(this).eventHub.emit('mapFocus', p.lng, p.lat)
    } catch (e) {
      console.error('mapFocus 发送失败:', JSON.stringify(e))
    }
    try {
      router.back()
    } catch (e) {
      console.error('返回地图失败:', JSON.stringify(e))
    }
  }

  build() {
    Column() {
      // 顶部蓝条
      Row() {
        Text('←')
          .fontSize(22)
          .fontColor('#FFFFFF')
          .padding({ left: 4, right: 12, top: 4, bottom: 4 })
          .onClick(() => {
            try { router.back() } catch (e) { console.error('返回失败:', JSON.stringify(e)) }
          })
        Text('搜索充电站')
          .fontSize(16)
          .fontColor('#FFFFFF')
      }
      .width('100%')
      .height(48)
      .padding({ left: 12, right: 14 })
      .linearGradient({
        angle: 90,
        colors: [['#007DFF', 0], ['#0057CC', 1]]
      })

      // 搜索输入框(自动聚焦弹出键盘)
      Row() {
        Text('🔍')
          .fontSize(15)
          .margin({ right: 8 })
        TextInput({ placeholder: '输入桩位编号或地址,如 CP-001 / 天河路' })
          .layoutWeight(1)
          .fontSize(14)
          .defaultFocus(true)
          .onChange((value: string) => {
            this.keyword = value
            this.filter(value)
          })
      }
      .width('100%')
      .margin({ left: 16, right: 16, top: 12 })
      .padding({ left: 14, right: 14, top: 4, bottom: 4 })
      .backgroundColor('#FFFFFF')
      .borderRadius(20)
      .alignItems(VerticalAlign.Center)

      // 结果列表 / 空态
      if (this.results.length === 0) {
        Column() {
          Text('未找到匹配的充电桩')
            .fontSize(14)
            .fontColor('#999999')
        }
        .width('100%')
        .layoutWeight(1)
        .justifyContent(FlexAlign.Center)
      } else {
        List({ space: 10 }) {
          ForEach(this.results, (p: PileInfo) => {
            ListItem() {
              Row() {
                // 状态圆标
                Column() {
                  Text('⚡')
                    .fontSize(14)
                    .fontColor('#FFFFFF')
                }
                .width(32)
                .height(32)
                .borderRadius(16)
                .backgroundColor(p.statusColor)
                .justifyContent(FlexAlign.Center)

                Column() {
                  Row() {
                    Text(p.id)
                      .fontSize(15)
                      .fontWeight(FontWeight.Bold)
                      .fontColor('#333333')
                    Text(p.statusText)
                      .fontSize(11)
                      .fontColor(p.statusColor)
                      .margin({ left: 8 })
                      .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                      .backgroundColor(`${p.statusColor}1A`)
                      .borderRadius(10)
                  }
                  Text(p.address)
                    .fontSize(13)
                    .fontColor('#888888')
                    .margin({ top: 4 })
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)
                .margin({ left: 12 })

                Text('定位 ›')
                  .fontSize(13)
                  .fontColor('#007DFF')
              }
              .width('100%')
              .padding(14)
              .backgroundColor('#FFFFFF')
              .borderRadius(12)
              .onClick(() => this.onPick(p))
            }
          }, (p: PileInfo) => p.id)
        }
        .width('100%')
        .layoutWeight(1)
        .margin({ top: 12 })
        .padding({ left: 16, right: 16 })
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }
}

5.6  utils

5.6.1  ChargeRecordStore.ets
// 充电记录存储与会话录制器。
// - 充电开始:beginChargeSession() 记录开始时间、车牌,并落盘活跃会话标记(防 App 被杀丢失)
// - 充电进行:trackChargePower() 实时跟踪峰值功率
// - 充电结束:endChargeSession() 生成一条记录并追加到历史列表(preferences 持久化)
// - 应用启动:restoreActiveSessionOnLaunch() 恢复上次未闭合会话;轮询发现设备已空闲时由 reconcile 自动闭合

import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';

export interface ChargeRecord {
  id: number;            // = startTime,作为唯一 id
  startTime: number;     // 开始时间 ms
  endTime: number;       // 结束时间 ms
  durationMin: number;   // 时长(分钟)
  peakKw: number;        // 峰值功率 kW
  plate: string;         // 当时车牌
  closedNormally: boolean; // true=用户主动停止;false=异常结束(设备掉线/启动未生效等)
}

// 进行中会话的快照(用于 UI 展示"充电中"卡片)
export interface ActiveSessionInfo {
  startTime: number;
  plate: string;
  peakKw: number;
}

const PREF_NAME = 'chargeRecordStore';
const KEY_RECORDS = 'records';
const KEY_ACTIVE = 'activeSession';
const MAX_RECORDS = 200;

// 内存中的进行中会话
let activeStart: number = 0;
let activePeak: number = 0;
let activePlate: string = '';
let appContext: common.Context | null = null;

interface ActiveMarker {
  startTime: number;
  peakKw: number;
  plate: string;
}

// 在 EntryAbility.onCreate 中调用一次,注入上下文
export function initChargeRecorder(ctx: common.Context): void {
  appContext = ctx;
}

async function getPref(): Promise<preferences.Preferences | null> {
  if (!appContext) return null;
  try {
    return await preferences.getPreferences(appContext, PREF_NAME);
  } catch (e) {
    return null;
  }
}

async function readList(pref: preferences.Preferences): Promise<ChargeRecord[]> {
  const raw = await pref.get(KEY_RECORDS, '[]') as string;
  try {
    return JSON.parse(raw) as ChargeRecord[];
  } catch (e) {
    return [];
  }
}

// 开始一段充电会话(在 start_charge 指令被 IoTDA 接受后调用)
export async function beginChargeSession(plate: string): Promise<void> {
  activeStart = new Date().getTime();
  activePeak = 0;
  activePlate = plate;
  const pref = await getPref();
  if (!pref) return;
  const marker: ActiveMarker = { startTime: activeStart, peakKw: 0, plate: activePlate };
  try {
    await pref.put(KEY_ACTIVE, JSON.stringify(marker));
    await pref.flush();
  } catch (e) {}
}

// 实时记录功率(每秒由模拟器/设备轮询调用)
export function trackChargePower(p: number): void {
  if (activeStart > 0 && p > activePeak) {
    activePeak = p;
  }
}

// 放弃当前会话(start 命令失败、状态回滚时调用),不生成记录
export async function abortChargeSession(): Promise<void> {
  activeStart = 0;
  activePeak = 0;
  activePlate = '';
  const pref = await getPref();
  if (!pref) return;
  try {
    await pref.delete(KEY_ACTIVE);
    await pref.flush();
  } catch (e) {}
}

// 结束充电会话,生成一条记录并落盘。durationMin 优先用设备上报值,缺省则按起止时间计算。
// closedNormally: 用户主动点停止=true;设备侧异常掉电/启动未生效=false。
export async function endChargeSession(durationMin: number, closedNormally: boolean): Promise<ChargeRecord | null> {
  if (activeStart <= 0) return null;
  const endTime = new Date().getTime();
  const realMinutes = (endTime - activeStart) / 60000;
  let dur: number;
  let peak: number;
  if (realMinutes < 5) {
    // 演示模式:真实只充了几秒~几分钟,模拟成一次完整充电
    // 时长 45~90 分钟随机,峰值功率 5.5~7.0kW 随机
    dur = 45 + Math.floor(Math.random() * 46);
    peak = Math.round((5.5 + Math.random() * 1.5) * 10) / 10;
  } else {
    dur = Math.floor(durationMin);
    if (!dur || dur <= 0) {
      dur = Math.max(1, Math.round(realMinutes));
    }
    peak = Math.round(activePeak * 10) / 10;
  }
  const rec: ChargeRecord = {
    id: activeStart,
    startTime: activeStart,
    endTime: endTime,
    durationMin: dur,
    peakKw: peak,
    plate: activePlate || '未绑定',
    closedNormally: closedNormally
  };
  activeStart = 0;
  activePeak = 0;
  activePlate = '';

  const pref = await getPref();
  if (pref) {
    try {
      const list = await readList(pref);
      list.unshift(rec);
      if (list.length > MAX_RECORDS) {
        list.length = MAX_RECORDS;
      }
      await pref.put(KEY_RECORDS, JSON.stringify(list));
      await pref.delete(KEY_ACTIVE);
      await pref.flush();
    } catch (e) {}
  }
  return rec;
}

// 查询全部记录(最新在前)
export async function getChargeRecords(): Promise<ChargeRecord[]> {
  const pref = await getPref();
  if (!pref) return [];
  try {
    return await readList(pref);
  } catch (e) {
    return [];
  }
}

// 清空记录
export async function clearChargeRecords(): Promise<void> {
  const pref = await getPref();
  if (!pref) return;
  try {
    await pref.put(KEY_RECORDS, '[]');
    await pref.flush();
  } catch (e) {}
}

// 应用启动时调用:把上次未闭合的活跃会话恢复到内存,让峰值跟踪继续。
// 是否真的结束由后续设备轮询 reconcile 决定(避免误判)。
export async function restoreActiveSessionOnLaunch(): Promise<void> {
  const pref = await getPref();
  if (!pref) return;
  try {
    const raw = await pref.get(KEY_ACTIVE, '') as string;
    if (!raw) return;
    const marker = JSON.parse(raw) as ActiveMarker;
    activeStart = marker.startTime || 0;
    activePeak = marker.peakKw || 0;
    activePlate = marker.plate || '';
  } catch (e) {}
}

// 由设备轮询在拿到 pile_status 后调用:
// - 若有活跃会话但设备已上报空闲(pile_status='0'),说明设备侧已停止(或启动未真正生效),闭合为异常记录
// - 设备仍在充电则保持会话继续跟踪
export async function reconcileSessionWithDevice(pileStatus: string, durationMin: number): Promise<void> {
  if (activeStart <= 0) return;
  if (pileStatus !== '0') return;
  await endChargeSession(durationMin, false);
}

// 当前是否有进行中的会话(供 UI 展示)
export function hasActiveSession(): boolean {
  return activeStart > 0;
}

// 获取进行中会话的快照(用于在记录页/我的页顶部显示"充电中"卡片)
export function getActiveSession(): ActiveSessionInfo | null {
  if (activeStart <= 0) return null;
  const info: ActiveSessionInfo = {
    startTime: activeStart,
    plate: activePlate || '未绑定',
    peakKw: Math.round(activePeak * 10) / 10
  };
  return info;
}
5.6.2  ChargeSimulator.ets
// 本地充电功率模拟器:充电状态下驱动功率实时变化(从 0 爬升到约 6.5kW 后小幅波动),
// 保证界面在设备真实功率未上报时也能实时显示充电功率;
// 当设备真实上报功率(power_kw > 0)在新鲜窗口内时,以真实值为准,模拟值让位。
// 使用方式:开始充电时调用 startChargeSimulation(),结束充电时调用 stopChargeSimulation()。

import { trackChargePower } from './ChargeRecordStore';

// 真实功率新鲜窗口(毫秒):窗口内展示设备实际功率,不覆盖
const REAL_FRESH_MS = 10000;
// 模拟功率峰值(kW)
const PEAK_POWER = 6.5;
// 模拟功率波动区间下限(kW)
const WAVE_LOW = 6.0;
// 模拟功率波动区间上限(kW)
const WAVE_HIGH = 7.0;

let simTimer: number = -1;
let basePower: number = 0.0;

function nextPower(): number {
  if (basePower < PEAK_POWER) {
    basePower = Math.min(PEAK_POWER, basePower + 0.55);
  } else {
    basePower = WAVE_LOW + Math.random() * (WAVE_HIGH - WAVE_LOW);
  }
  return Math.round(basePower * 10) / 10;
}

// 开始本地功率模拟:仅在未运行时启动,从当前功率继续爬升
export function startChargeSimulation(): void {
  if (simTimer !== -1) return;
  const curPower = AppStorage.get<number>('powerKw') ?? 0.0;
  basePower = curPower > 0 && curPower < PEAK_POWER ? curPower : 0.0;
  const tick = () => {
    // 状态联动:未处于充电状态时停止模拟并归零,避免残留功率
    const status = AppStorage.get<string>('pileStatus') ?? '0';
    if (status !== '1') {
      stopChargeSimulation();
      AppStorage.set('powerKw', 0.0);
      return;
    }
    // 设备真实功率新鲜窗口内不覆盖真实值
    const lastRealTs = AppStorage.get<number>('lastRealPowerTs') ?? 0;
    if (new Date().getTime() - lastRealTs < REAL_FRESH_MS) return;
    const p = nextPower();
    AppStorage.set('powerKw', p);
    trackChargePower(p);
  };
  tick();
  simTimer = setInterval(tick, 1000);
}

// 停止本地功率模拟并清除真实功率时间戳,保证下次充电从 0 重新爬升
export function stopChargeSimulation(): void {
  if (simTimer !== -1) {
    clearInterval(simTimer);
    simTimer = -1;
  }
  AppStorage.setOrCreate('lastRealPowerTs', 0);
}
5.6.3  DeviceState.ets
// 导入当前页面依赖的组件、状态工具和系统能力。
import { queryDeviceProperties } from './IoTDAService';
import { trackChargePower, reconcileSessionWithDevice } from './ChargeRecordStore';

// 先声明模块级常量、缓存或定时器句柄,方便多个方法共享。
let pollTimer: number = -1;

// 这里定义一个独立方法,用来封装当前模块的关键业务逻辑。
export function startDevicePolling(): void {
  // 根据当前业务状态走不同分支,保证页面反馈和数据流一致。
  if (pollTimer !== -1) return;

  // 这里定义一个独立方法,用来封装当前模块的关键业务逻辑。
  const poll = async () => {
    try {
      const props = await queryDeviceProperties();
      if (props) {
        AppStorage.set('temperature', props.temperature);
        AppStorage.set('humidity', props.humidity);
        AppStorage.set('nearlinkStatus', String(props.nearlink_status).toLowerCase() === 'true');
        AppStorage.set('distance', props.distance);
        // AI 识别成功后进入本地 mock 充电:不再用云端状态覆盖充电状态,
        // 直到用户在首页/仪表盘手动下发充电指令(此时 mockCharging 会被清除)
        const mockCharging = AppStorage.get<boolean>('mockCharging') ?? false;
        // 指令下发后的短时接管窗口:轮询不覆盖充电状态,让本地乐观更新先展示,
        // 窗口结束后以设备上报(影子)为准自动收敛
        const syncHoldUntil = AppStorage.get<number>('commandSyncUntil') ?? 0;
        const inSyncHold = new Date().getTime() < syncHoldUntil;
        if (!mockCharging && !inSyncHold) {
          const pileStatus = String(props.pile_status);
          AppStorage.set('pileStatus', pileStatus);
          AppStorage.set('durationMin', parseInt(props.duration_min) || 0);
          if (pileStatus === '0') {
            // 设备未充电:功率强制归零,避免设备残留功率数据显示
            AppStorage.set('powerKw', 0.0);
            AppStorage.set('lastRealPowerTs', 0);
          } else {
            const realPower = parseFloat(props.power_kw) || 0.0;
            AppStorage.set('powerKw', realPower);
            trackChargePower(realPower);
            // 记录真实功率新鲜时间:窗口内本地模拟值让位,优先展示设备实际充电功率
            if (realPower > 0) {
              AppStorage.set('lastRealPowerTs', new Date().getTime());
            }
          }
          // 充电会话与设备状态对账:在充会话但设备已空闲 → 闭合异常记录
          reconcileSessionWithDevice(pileStatus, parseInt(props.duration_min) || 0);
        }
      }
    } catch (e) {
      // 网络请求异常静默忽略,避免刷屏
      // 生产环境可按需上报
    }
  };

  poll();
  pollTimer = setInterval(poll, 5000);
}

// 这里定义一个独立方法,用来封装当前模块的关键业务逻辑。
export function stopDevicePolling(): void {
  // 根据当前业务状态走不同分支,保证页面反馈和数据流一致。
  if (pollTimer !== -1) {
    clearInterval(pollTimer);
    pollTimer = -1;
  }
}

// 这里定义一个独立方法,用来封装当前模块的关键业务逻辑。
export function getDeviceStatePrompt(): string {
  const pileStatus = AppStorage.get<string>('pileStatus') ?? '0';
  const duration = AppStorage.get<number>('durationMin') ?? 0;
  const temperature = AppStorage.get<string>('temperature') ?? '--';
  const humidity = AppStorage.get<string>('humidity') ?? '--';
  const nearlinkStatus = AppStorage.get<boolean>('nearlinkStatus') ?? false;
  const powerKw = AppStorage.get<number>('powerKw') ?? 0.0;
  const statusText =
    pileStatus === '1' ? `充电中,已充 ${duration} 分钟,当前功率 ${powerKw.toFixed(1)} kW` :
      pileStatus === '2' ? '设备故障' : '空闲';

  return `你是智慧充电桩AI助手,回答简洁友好,不超过80字。当前设备实时状态:
- 充电状态:${statusText}
- 星闪连接:${nearlinkStatus ? '已连接' : '未连接'}
- 温度:${temperature}℃
- 湿度:${humidity}%`;
}
5.6.4  GlobalState.ets
// 导入当前页面依赖的组件、状态工具和系统能力。
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';

export interface UserAccount {
  username: string;
  password: string;
}

export class UserStore {
  // 这里集中定义组件状态、共享状态以及界面控制器。
  private static readonly PREF_NAME = 'userStore';
  private static readonly KEY = 'userList';

  // 这里定义一个独立方法,用来封装当前模块的关键业务逻辑。
  static async getUsers(context: common.UIAbilityContext): Promise<UserAccount[]> {
    // 先声明模块级常量、缓存或定时器句柄,方便多个方法共享。
    const pref = await preferences.getPreferences(context, UserStore.PREF_NAME);
    const json = await pref.get(UserStore.KEY, '[]') as string;
    try {
      return JSON.parse(json) as UserAccount[];
    } catch {
      return [];
    }
  }

  // 这里定义一个独立方法,用来封装当前模块的关键业务逻辑。
  static async saveUsers(context: common.UIAbilityContext, users: UserAccount[]): Promise<void> {
    const pref = await preferences.getPreferences(context, UserStore.PREF_NAME);
    await pref.put(UserStore.KEY, JSON.stringify(users));
    await pref.flush();
  }
}

export class PlateStore {
  private static readonly PREF_NAME = 'plateStore';
  private static readonly KEY = 'plateNumber';

  // 这里定义一个独立方法,用来封装当前模块的关键业务逻辑。
  static async getPlate(context: common.UIAbilityContext): Promise<string> {
    const pref = await preferences.getPreferences(context, PlateStore.PREF_NAME);
    return await pref.get(PlateStore.KEY, '未绑定') as string;
  }

  // 这里定义一个独立方法,用来封装当前模块的关键业务逻辑。
  static async savePlate(context: common.UIAbilityContext, plate: string):
    Promise<void> {
    const pref = await preferences.getPreferences(context, PlateStore.PREF_NAME);
    await pref.put(PlateStore.KEY, plate);
    await pref.flush();
  }
}

export class GlobalContext {
  // 这里定义一个独立方法,用来封装当前模块的关键业务逻辑。
  static init(): void {
    AppStorage.setOrCreate('plateNumber', '未绑定');
    AppStorage.setOrCreate('isNearLinkConnected', true);
    AppStorage.setOrCreate('currentPower', 0.0);
    AppStorage.setOrCreate('pileStatus', '0');
    AppStorage.setOrCreate('durationMin', 0);
    AppStorage.setOrCreate('temperature', '26.5');
    AppStorage.setOrCreate('humidity', '48');
    AppStorage.setOrCreate('nearlinkStatus', true);
    AppStorage.setOrCreate('distance', '35');
    AppStorage.setOrCreate('powerKw', 0.0);
    // lastRealPowerTs:设备真实功率最近上报时间戳,用于判断本地模拟值是否让位
    AppStorage.setOrCreate('lastRealPowerTs', 0);
    // mockCharging:AI 识别成功后驱动的本地模拟充电标记,
    // 为 true 时设备轮询不再用云端状态覆盖本地充电状态(实验1-4 mock 链路)
    AppStorage.setOrCreate('mockCharging', false);
    AppStorage.setOrCreate('commandSyncUntil', 0);
    AppStorage.setOrCreate('switchTab', -1);
  }
}
5.6.5  IoTDAService.ets
import { http } from '@kit.NetworkKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

export const TAG = 'IoTDAService';
export const DOMAIN = 0x0002;

// ===== 配置1 =====
// ===== 配置 =====
const AK = '请配置';
const SK = '请配置';
export const PROJECT_ID = '请配置';
export const DEVICE_ID = '请配置';
const REGION = '请配置';
export const ENDPOINT = '请配置';
const IAM_URL = `请配置`;
export const SERVICE_ID = '请配置';


// ===== 数据结构 =====
export interface DeviceProperties {
  temperature: string;
  humidity: string;
  nearlink_status: string;
  pile_status: string;
  duration_min: string;
  power_kw: string;
  distance: string;
}

interface ReportedData {
  properties: DeviceProperties;
}

interface ShadowItem {
  service_id: string;
  reported: ReportedData;
}

interface ShadowResponse {
  shadow: ShadowItem[];
}

// 显式声明各请求的 Header 结构,避免索引签名
interface JsonHeader {
  'Content-Type': string;
}

export interface AuthHeader {
  'Content-Type': string;
  'X-Auth-Token': string;
}

// 命令参数结构(与设备端对齐)
export interface CommandParas {
  onoff?: string;    // charge 命令:ON / OFF
  Number?: string;  // plateNumber 命令:车牌后5位
}

export interface CommandBody {
  service_id: string;
  command_name: string;
  paras: CommandParas;
}

// 下发命令结果:ok 是否被 IoTDA 接受,reason 为失败时的具体原因(中文友好提示)
export interface CommandResult {
  ok: boolean;
  reason: string;
}

// 常见 IoTDA 错误码 → 用户可读提示
const COMMAND_ERROR_MAP: Record<string, string> = {
  'IOTDA.014016': '设备离线:请保持设备在线(MQTTX 已连接并订阅命令)后再操作',
  'IOTDA.014111': '设备未在规定时间内响应命令,请确认设备在线并已订阅命令响应后重试',
  'IOTDA.000004': '命令不存在或参数错误,请核对产品模型',
  'IOTDA.000006': '请求参数错误',
  'IOTDA.001000': '设备不存在',
  'IOTDA.001002': '产品不存在',
  'IOTDA.000005': '认证失败,请检查 AK/SK 配置',
};

interface CommandErrorBody {
  error_code?: string;
  error_msg?: string;
}

// ===== Token 缓存 =====
let cachedToken: string = '';
let tokenFetching: boolean = false;

// ===== 获取 IAM Token =====
function fetchToken(): Promise<string> {
  const body =
    `{"auth":{"identity":{"methods":["hw_ak_sk"],"hw_ak_sk":{"access":{"key":"${AK}"},"secret":{"key":"${SK}"}}},"scope":{"project":{"name":"${REGION}"}}}}`;

  return new Promise<string>((resolve, reject) => {
    const req = http.createHttp();
    const header: JsonHeader = { 'Content-Type': 'application/json' };
    req.request(IAM_URL, {
      method: http.RequestMethod.POST,
      header: header,
      extraData: body,
      connectTimeout: 60000,
      readTimeout: 60000
    }, (err, data) => {
      req.destroy();
      if (err) { reject(new Error(`Token 请求失败: ${err.message}`)); return; }
      if (data.responseCode < 200 || data.responseCode >= 300) {
        reject(new Error(`IAM HTTP ${data.responseCode}`)); return;
      }
      const headers = data.header as Record<string, string>;
      const token = headers['x-subject-token'] ?? headers['X-Subject-Token'] ?? '';
      if (!token) { reject(new Error('未获取到 x-subject-token')); return; }
      hilog.info(DOMAIN, TAG, 'Token 获取成功,长度=%{public}d', token.length);
      resolve(token);
    });
  });
}

export function clearCachedToken(): void {
  cachedToken = '';
}

export async function ensureToken(): Promise<string> {
  if (cachedToken) return cachedToken;
  if (tokenFetching) {
    for (let i = 0; i < 20; i++) {
      await new Promise<void>(r => { setTimeout(r, 500); });
      if (cachedToken) return cachedToken;
    }
    throw new Error('等待 Token 超时');
  }
  tokenFetching = true;
  try {
    cachedToken = await fetchToken();
    return cachedToken;
  } finally {
    tokenFetching = false;
  }
}

// ===== 查询设备影子数据 =====
export async function queryDeviceProperties(): Promise<DeviceProperties | null> {
  const token = await ensureToken();
  const url = `${ENDPOINT}/v5/iot/${PROJECT_ID}/devices/${DEVICE_ID}/shadow`;
  const header: AuthHeader = { 'Content-Type': 'application/json', 'X-Auth-Token': token };

  return new Promise<DeviceProperties | null>((resolve) => {
    const req = http.createHttp();
    req.request(url, {
      method: http.RequestMethod.GET,
      header: header,
      connectTimeout: 10000,
      readTimeout: 10000
    }, (err, data) => {
      req.destroy();
      if (err) {
        hilog.error(DOMAIN, TAG, 'queryDeviceProperties failed: %{public}d %{public}s', err.code, err.message);
        resolve(null); return;
      }
      hilog.info(DOMAIN, TAG, 'shadow HTTP %{public}d', data.responseCode);
      if (data.responseCode === 401) { cachedToken = ''; resolve(null); return; }
      try {
        const body = JSON.parse(data.result as string) as ShadowResponse;
        const svc = body.shadow?.find(s => s.service_id === SERVICE_ID);
        const props = svc?.reported?.properties ?? null;
        hilog.info(DOMAIN, TAG, 'props: %{public}s', JSON.stringify(props));
        resolve(props);
      } catch (e) {
        hilog.error(DOMAIN, TAG, 'JSON 解析失败: %{public}s', (e as Error).message);
        resolve(null);
      }
    });
  });
}

// ===== 下发车牌号(后5位)=====
export async function sendPlateCommand(plateCode: string): Promise<boolean> {
  const token = await ensureToken();
  const url = `${ENDPOINT}/v5/iot/${PROJECT_ID}/devices/${DEVICE_ID}/commands`;
  const header: AuthHeader = { 'Content-Type': 'application/json', 'X-Auth-Token': token };

  const paras: CommandParas = { Number: plateCode };
  const cmdBody: CommandBody = {
    service_id: SERVICE_ID,
    command_name: 'plateNumber',
    paras: paras
  };

  return new Promise<boolean>((resolve) => {
    const req = http.createHttp();
    req.request(url, {
      method: http.RequestMethod.POST,
      header: header,
      extraData: JSON.stringify(cmdBody),
      connectTimeout: 10000,
      readTimeout: 10000
    }, (err, data) => {
      req.destroy();
      if (err) {
        hilog.error(DOMAIN, TAG, 'sendPlateCommand failed: %{public}s', err.message);
        resolve(false); return;
      }
      if (data.responseCode === 401) { cachedToken = ''; }
      hilog.info(DOMAIN, TAG, 'sendPlateCommand [%{public}s] -> HTTP %{public}d', plateCode, data.responseCode);
      resolve(data.responseCode === 201);
    });
  });
}

// ===== 下发充电指令 =====
export async function sendChargeCommand(action: 'start_charge' | 'stop_charge'): Promise<CommandResult> {
  const token = await ensureToken();
  const url = `${ENDPOINT}/v5/iot/${PROJECT_ID}/devices/${DEVICE_ID}/commands`;
  const header: AuthHeader = {
    'Content-Type': 'application/json',
    'X-Auth-Token': token,
    // 基础版不传 Instance-Id
  };

  const paras: CommandParas = action === 'start_charge'
    ? { onoff: 'ON' }
    : { onoff: 'OFF' };

  const cmdBody: CommandBody = {
    service_id: SERVICE_ID,
    command_name: 'charge',
    paras: paras,
  };

  // 解析 IoTDA 响应体:成功 201 Created;失败时提取 error_code/error_msg 并映射为中文提示
  const parseResult = (code: number, bodyStr: string): CommandResult => {
    if (code === 201) return { ok: true, reason: '' };
    let errorCode = '';
    let errorMsg = '';
    try {
      const parsed = JSON.parse(bodyStr) as CommandErrorBody;
      errorCode = parsed.error_code ?? '';
      errorMsg = parsed.error_msg ?? '';
    } catch (_e) {
      // body 不是 JSON 时保留默认提示
    }
    const friendly = COMMAND_ERROR_MAP[errorCode];
    return {
      ok: false,
      reason: friendly ?? (errorMsg ? `${errorMsg}(${errorCode})` : `HTTP ${code}`)
    };
  };

  // 单次请求:网络层错误返回 null,交由上层自动重试;HTTP 响应直接解析
  const requestOnce = (): Promise<CommandResult | null> => {
    return new Promise<CommandResult | null>((resolve) => {
      const req = http.createHttp();
      req.request(url, {
        method: http.RequestMethod.POST,
        header: header,
        extraData: JSON.stringify(cmdBody),
        connectTimeout: 30000,
        readTimeout: 30000,
      }, (err, data) => {
        req.destroy();
        if (err) {
          hilog.error(DOMAIN, TAG, 'sendChargeCommand failed: %{public}s', err.message);
          resolve(null);
          return;
        }
        if (data.responseCode === 401) cachedToken = '';
        const body = typeof data.result === 'string'
          ? data.result
          : JSON.stringify(data.result ?? '');
        hilog.info(DOMAIN, TAG, 'sendChargeCommand %{public}s -> %{public}d body=%{public}s',
          action, data.responseCode, body);
        resolve(parseResult(data.responseCode, body));
      });
    });
  };

  // 网络错误(超时/断连等)自动重试一次,避免瞬时抖动导致下发失败
  let result = await requestOnce();
  if (result === null) {
    result = await requestOnce();
  }
  if (result === null) {
    return {
      ok: false,
      reason: '网络超时:请检查手机网络,并确认设备(MQTTX)已保持在线并订阅命令后重试'
    };
  }
  return result;
}
5.6.6  PlateRecognizer.ets
// 车牌识别核心工具模块
// 负责:图片读取与压缩、百度 OCR 接口调用、车牌结果解析与归一化。
// 页面层只需要关心"传入图片、拿回车牌",识别逻辑与界面交互分离。
import { http } from '@kit.NetworkKit';
import { fileIo } from '@kit.CoreFileKit';
import { util } from '@kit.ArkTS';
import { image } from '@kit.ImageKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';

const DOMAIN = 0x0001;
const TAG = 'PlateRecognizer';

// ===== 百度 OCR 密钥 =====
// 方式一(推荐):在应用内 AI 识别页右上角 ⚙ 配置,无需重新编译。
// 方式二:直接修改下面两个常量并重新编译。
// 申请地址:https://cloud.baidu.com/  → 人工智能 → 文字识别 OCR → 车牌识别
const BAIDU_API_KEY = '请配置';
const BAIDU_SECRET_KEY = '请配置';

// 图片压缩参数:百度 OCR 要求图片 base64 后不超过 4MB,压缩到长边 1280、质量 90
const MAX_IMAGE_DIMENSION = 1280;
const JPEG_QUALITY = 90;

export interface OcrKeys {
  apiKey: string;
  secretKey: string;
}

export class OcrConfig {
  private static readonly PREF_NAME = 'ocrConfig';
  private static readonly KEY_API = 'baiduApiKey';
  private static readonly KEY_SECRET = 'baiduSecretKey';

  static async getKeys(context: common.UIAbilityContext): Promise<OcrKeys> {
    const pref = await preferences.getPreferences(context, OcrConfig.PREF_NAME);
    const apiKey = await pref.get(OcrConfig.KEY_API, '') as string;
    const secretKey = await pref.get(OcrConfig.KEY_SECRET, '') as string;
    return { apiKey: apiKey, secretKey: secretKey };
  }

  static async saveKeys(context: common.UIAbilityContext, apiKey: string, secretKey: string): Promise<void> {
    const pref = await preferences.getPreferences(context, OcrConfig.PREF_NAME);
    await pref.put(OcrConfig.KEY_API, apiKey);
    await pref.put(OcrConfig.KEY_SECRET, secretKey);
    await pref.flush();
  }

  // 取生效密钥:优先使用应用内保存的,否则回退到代码常量
  static async getEffectiveKeys(context: common.UIAbilityContext): Promise<OcrKeys> {
    const saved = await OcrConfig.getKeys(context);
    const apiKey = saved.apiKey !== '' ? saved.apiKey : BAIDU_API_KEY;
    const secretKey = saved.secretKey !== '' ? saved.secretKey : BAIDU_SECRET_KEY;
    return { apiKey: apiKey, secretKey: secretKey };
  }

  // 判断密钥是否还是占位符/未填写
  static isPlaceholder(key: string): boolean {
    return key === '' || key.indexOf('请替换') >= 0;
  }
}

interface TokenResponse {
  access_token: string;
}

interface PlateWordsResult {
  number: string;
}

interface PlateResponse {
  words_result: PlateWordsResult;
  error_code?: number;
  error_msg?: string;
}

// 车牌号归一化:去掉分隔符(·、-、空格、点等),统一大写。
// 例如 "鲁H·S6S88" → "鲁HS6S88",便于与绑定车牌精确比对。
export function normalizePlate(input: string): string {
  if (!input) {
    return '';
  }
  let out = '';
  for (let i = 0; i < input.length; i++) {
    const ch = input.charAt(i);
    if (ch === ' ' || ch === '·' || ch === '•' || ch === '-' || ch === '_' || ch === '.' || ch === ',') {
      continue;
    }
    out += ch.toUpperCase();
  }
  return out;
}

// 获取百度 access_token
async function getAccessToken(context: common.UIAbilityContext): Promise<string> {
  const keys = await OcrConfig.getEffectiveKeys(context);
  if (OcrConfig.isPlaceholder(keys.apiKey) || OcrConfig.isPlaceholder(keys.secretKey)) {
    throw new Error('OCR_KEY_NOT_CONFIGURED');
  }

  hilog.info(DOMAIN, TAG, 'Requesting Baidu access token...');
  const url = `https://aip.baidubce.com/oauth/2.0/token` +
    `?grant_type=client_credentials` +
    `&client_id=${keys.apiKey}` +
    `&client_secret=${keys.secretKey}`;

  const req = http.createHttp();
  try {
    const response = await req.request(url, {
      method: http.RequestMethod.POST,
      connectTimeout: 10000,
      readTimeout: 15000
    });
    hilog.info(DOMAIN, TAG, 'Token response code: %{public}d', response.responseCode);

    const bodyStr = (response.result as object).toString();
    const result = JSON.parse(bodyStr) as TokenResponse;
    if (!result.access_token) {
      hilog.error(DOMAIN, TAG, 'Token response body: %{public}s', bodyStr);
      throw new Error('access_token 为空,请检查 API Key 和 Secret Key');
    }
    hilog.info(DOMAIN, TAG, 'Access token obtained successfully');
    return result.access_token;
  } catch (e) {
    hilog.error(DOMAIN, TAG, 'getAccessToken failed: %{public}s', (e as Error).message);
    throw new Error(`获取百度Token失败:${(e as Error).message}`);
  } finally {
    req.destroy();
  }
}

// 读取图片文件为 base64(先压缩,保证体积符合 OCR 接口限制且识别更快)
async function compressImageToBase64(imagePath: string): Promise<string> {
  hilog.info(DOMAIN, TAG, 'Reading image URI: %{public}s', imagePath);

  const file = await fileIo.open(imagePath, fileIo.OpenMode.READ_ONLY);
  const fd: number = file.fd;
  try {
    // 1) 尝试解码并压缩为 JPEG
    const packed = await decodeAndCompress(fd);
    if (packed.byteLength > 0) {
      const base64Helper = new util.Base64Helper();
      const base64Str = base64Helper.encodeToStringSync(new Uint8Array(packed));
      hilog.info(DOMAIN, TAG, 'Compressed base64 length: %{public}d', base64Str.length);
      if (base64Str.length > 0) {
        return base64Str;
      }
    }
  } catch (e) {
    hilog.warn(DOMAIN, TAG, 'Image compress failed, fallback to raw: %{public}s', (e as Error).message);
  } finally {
    try { await fileIo.close(fd); } catch (_e) {}
  }

  // 2) 兜底:直接读取原始文件(重新打开,避免与解码共用句柄)
  const rawFile = await fileIo.open(imagePath, fileIo.OpenMode.READ_ONLY);
  try {
    const stat = await fileIo.stat(rawFile.fd);
    hilog.info(DOMAIN, TAG, 'Image file size: %{public}d bytes', stat.size);
    const buffer = new ArrayBuffer(stat.size);
    await fileIo.read(rawFile.fd, buffer);

    const base64Helper = new util.Base64Helper();
    const base64Str = base64Helper.encodeToStringSync(new Uint8Array(buffer));
    hilog.info(DOMAIN, TAG, 'Raw base64 length: %{public}d', base64Str.length);
    return base64Str;
  } finally {
    try { await fileIo.close(rawFile.fd); } catch (_e) {}
  }
}

// 解码并按目标尺寸压缩为 JPEG ArrayBuffer
async function decodeAndCompress(fd: number): Promise<ArrayBuffer> {
  const src = image.createImageSource(fd);
  let pixelMap: image.PixelMap | undefined = undefined;
  let packer: image.ImagePacker | undefined = undefined;
  try {
    const info = await src.getImageInfo();
    const srcWidth = info.size.width;
    const srcHeight = info.size.height;

    let targetW = srcWidth;
    let targetH = srcHeight;
    if (srcWidth > MAX_IMAGE_DIMENSION || srcHeight > MAX_IMAGE_DIMENSION) {
      const scale = srcWidth >= srcHeight
        ? MAX_IMAGE_DIMENSION / srcWidth
        : MAX_IMAGE_DIMENSION / srcHeight;
      targetW = Math.max(1, Math.round(srcWidth * scale));
      targetH = Math.max(1, Math.round(srcHeight * scale));
    }

    if (targetW !== srcWidth || targetH !== srcHeight) {
      const opts: image.DecodingOptions = { desiredSize: { width: targetW, height: targetH } };
      pixelMap = await src.createPixelMap(opts);
    } else {
      pixelMap = await src.createPixelMap();
    }

    packer = image.createImagePacker();
    const outBuf: ArrayBuffer = await packer.packToData(pixelMap, {
      format: 'image/jpeg',
      quality: JPEG_QUALITY
    });
    hilog.info(DOMAIN, TAG, 'Compressed size: %{public}d, %{public}dx%{public}d',
      outBuf.byteLength, targetW, targetH);
    return outBuf;
  } finally {
    try { src.release(); } catch (_e) {}
    if (pixelMap !== undefined) {
      try { pixelMap.release(); } catch (_e) {}
    }
    if (packer !== undefined) {
      try { packer.release(); } catch (_e) {}
    }
  }
}

/**
 * 识别图片中的车牌号
 * @param context 页面上下文(用于读取密钥配置)
 * @param imagePath 图片路径(支持 file:// URI 或绝对路径)
 * @returns 识别到的车牌号,失败时抛出带中文说明的错误
 */
export async function recognizePlate(context: common.UIAbilityContext, imagePath: string): Promise<string> {
  hilog.info(DOMAIN, TAG, '====== recognizePlate START ======');
  hilog.info(DOMAIN, TAG, 'Input imagePath: %{public}s', imagePath);

  let base64Str: string = '';
  try {
    base64Str = await compressImageToBase64(imagePath);
  } catch (e) {
    hilog.error(DOMAIN, TAG, 'Failed to read image: %{public}s', (e as Error).message);
    throw new Error(`图片读取失败:${(e as Error).message}`);
  }

  let token: string = '';
  try {
    token = await getAccessToken(context);
  } catch (e) {
    const msg = (e as Error).message;
    if (msg === 'OCR_KEY_NOT_CONFIGURED') {
      throw new Error('OCR_KEY_NOT_CONFIGURED');
    }
    throw new Error(msg);
  }

  hilog.info(DOMAIN, TAG, 'Calling Baidu OCR license plate API...');
  const req = http.createHttp();
  try {
    const response = await req.request(
      `https://aip.baidubce.com/rest/2.0/ocr/v1/license_plate?access_token=${token}`,
      {
        method: http.RequestMethod.POST,
        header: { 'Content-Type': 'application/x-www-form-urlencoded' },
        extraData: `image=${encodeURIComponent(base64Str)}`,
        connectTimeout: 10000,
        readTimeout: 20000
      }
    );

    hilog.info(DOMAIN, TAG, 'OCR response code: %{public}d', response.responseCode);
    const bodyStr = (response.result as object).toString();
    hilog.info(DOMAIN, TAG, 'OCR response body: %{public}s', bodyStr);

    const result = JSON.parse(bodyStr) as PlateResponse;

    if (result.error_code) {
      hilog.error(DOMAIN, TAG, 'OCR error_code: %{public}d, msg: %{public}s',
        result.error_code, result.error_msg ?? '');
      throw new Error(`识别失败(${result.error_code}):${result.error_msg ?? '未知错误'}`);
    }

    const plateNumber = result.words_result?.number ?? '';
    const normalized = normalizePlate(plateNumber);
    if (normalized === '') {
      throw new Error('NO_PLATE_FOUND');
    }
    hilog.info(DOMAIN, TAG, 'Recognized plate: %{public}s', normalized);
    hilog.info(DOMAIN, TAG, '====== recognizePlate END ======');
    return normalized;
  } catch (e) {
    const msg = (e as Error).message;
    if (msg === 'NO_PLATE_FOUND') {
      throw new Error('NO_PLATE_FOUND');
    }
    hilog.error(DOMAIN, TAG, 'OCR request failed: %{public}s', msg);
    throw new Error(msg);
  } finally {
    req.destroy();
  }
}

5.7  views

5.7.1   AICamera.ets
// AI 车牌识别页:图片来源选择 → OCR 识别 → 结果展示 → 车牌绑定 → 本地 mock 充电联动
import { promptAction } from '@kit.ArkUI';
import { camera, cameraPicker } from '@kit.CameraKit';
import { common } from '@kit.AbilityKit';
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { recognizePlate, normalizePlate, OcrConfig, OcrKeys } from '../utils/PlateRecognizer';
import { startChargeSimulation } from '../utils/ChargeSimulator';
import { PlateStore } from '../utils/GlobalState';
import { OcrConfigDialog } from '../components/OcrConfigDialog';
import { BindPlateDialog } from '../components/BindPlateDialog';

@Component
export struct AICamera {
  @StorageLink('plateNumber') plateNumber: string = '未绑定';
  @State recognizedPlate: string = '';
  @State isRecognizing: boolean = false;
  @State resultState: 'none' | 'match' | 'mismatch' | 'unbound' | 'error' = 'none';
  @State errorMsg: string = '';
  @State imagePath: string = '';
  @State ocrConfigured: boolean = true;
  private context = getContext(this) as common.UIAbilityContext;

  private ocrDialogController: CustomDialogController = new CustomDialogController({
    builder: OcrConfigDialog(),
    alignment: DialogAlignment.Center,
    autoCancel: true
  });

  private bindDialogController: CustomDialogController = new CustomDialogController({
    builder: BindPlateDialog(),
    alignment: DialogAlignment.Center,
    autoCancel: true
  });

  aboutToAppear() {
    // 检查密钥是否已配置,未配置时在页面上给出明确提示
    OcrConfig.getEffectiveKeys(this.context).then((keys: OcrKeys) => {
      this.ocrConfigured = !OcrConfig.isPlaceholder(keys.apiKey) && !OcrConfig.isPlaceholder(keys.secretKey);
    }).catch(() => {
      this.ocrConfigured = false;
    });
  }

  async takePhoto() {
    try {
      const result = await cameraPicker.pick(
        this.context,
        [cameraPicker.PickerMediaType.PHOTO],
        { cameraPosition: camera.CameraPosition.CAMERA_POSITION_BACK }
      );
      if (result.resultCode === 0 && result.resultUri) {
        this.imagePath = result.resultUri;
        this.doRecognize();
      }
    } catch (e) {
      promptAction.showToast({ message: '相机启动失败' });
    }
  }

  async pickFromGallery() {
    try {
      const photoSelectOptions = new photoAccessHelper.PhotoSelectOptions();
      photoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
      photoSelectOptions.maxSelectNumber = 1;
      const photoPicker = new photoAccessHelper.PhotoViewPicker();
      const result = await photoPicker.select(photoSelectOptions);
      if (result.photoUris && result.photoUris.length > 0) {
        this.imagePath = result.photoUris[0];
        this.doRecognize();
      }
    } catch (e) {
      promptAction.showToast({ message: '相册选择失败' });
    }
  }

  async doRecognize() {
    if (this.plateNumber === '未绑定') {
      this.resultState = 'unbound';
      return;
    }
    this.isRecognizing = true;
    this.recognizedPlate = '';
    this.errorMsg = '';
    this.resultState = 'none';
    try {
      const plate = await recognizePlate(this.context, this.imagePath);
      this.recognizedPlate = plate;
      this.ocrConfigured = true;
      this.evaluateResult(plate);
    } catch (e) {
      const msg = (e as Error).message ?? '';
      if (msg === 'OCR_KEY_NOT_CONFIGURED') {
        this.ocrConfigured = false;
        this.errorMsg = '尚未配置百度OCR密钥:请点击右上角 ⚙ 配置(百度智能云免费开通车牌识别)';
      } else if (msg === 'NO_PLATE_FOUND') {
        this.errorMsg = '未识别到车牌:请确保车牌清晰、完整、无遮挡后重试';
      } else {
        this.errorMsg = msg;
      }
      this.resultState = 'error';
    }
    this.isRecognizing = false;
  }

  // 识别结果与绑定车牌比对,并联动本地 mock 充电状态
  private evaluateResult(plate: string) {
    const normPlate = normalizePlate(plate);
    if (normPlate !== '' && normPlate === normalizePlate(this.plateNumber)) {
      this.resultState = 'match';
      // 本地 mock:识别成功先驱动本地状态,实验1-5再替换为真实充电指令
      AppStorage.set('mockCharging', true);
      AppStorage.set('pileStatus', '1');
      AppStorage.set('durationMin', 0);
      // 启动本地功率模拟,让充电中界面实时显示充电功率
      startChargeSimulation();
      promptAction.showToast({ message: '车牌匹配成功,已启动本地模拟充电' });
    } else {
      this.resultState = 'mismatch';
    }
  }

  // 把识别结果一键绑定为当前车牌,绑定后重新比对
  async bindRecognizedPlate() {
    const plate = this.recognizedPlate;
    if (plate === '') {
      return;
    }
    try {
      const normalized = normalizePlate(plate);
      await PlateStore.savePlate(this.context, normalized);
      this.plateNumber = normalized;
      promptAction.showToast({ message: `车牌绑定成功:${normalized}` });
      this.evaluateResult(normalized);
    } catch (e) {
      promptAction.showToast({ message: '绑定失败,请重试' });
    }
  }

  getResultCardColor(): string {
    if (this.resultState === 'match') return '#E8F5E9';
    if (this.resultState === 'mismatch') return '#FFEBEE';
    if (this.resultState === 'unbound') return '#FFF8E1';
    if (this.resultState === 'error') return '#FFEBEE';
    return '#F5F5F5';
  }

  getResultIcon(): string {
    if (this.resultState === 'match') return '✅';
    if (this.resultState === 'mismatch') return '❌';
    if (this.resultState === 'unbound') return '⚠️';
    if (this.resultState === 'error') return '⚠️';
    return '';
  }

  getResultText(): string {
    if (this.resultState === 'match') {
      return this.recognizedPlate !== ''
        ? `识别车牌 ${this.recognizedPlate},与绑定车牌一致,已启动本地模拟充电`
        : '与绑定车牌一致,已启动本地模拟充电';
    }
    if (this.resultState === 'mismatch') return `识别车牌:${this.recognizedPlate}(与已绑定 ${this.plateNumber} 不一致)`;
    if (this.resultState === 'unbound') return '请先绑定车牌,再使用 AI 识别';
    if (this.resultState === 'error') return '车牌识别失败';
    return '';
  }

  build() {
    Column() {
      Row() {
        Text('AI 车牌识别').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#333333')
        Blank()
        Text(this.plateNumber === '未绑定' ? '未绑定车牌' : `已绑定 ${this.plateNumber}`)
          .fontSize(13)
          .fontColor(this.plateNumber === '未绑定' ? '#FF9800' : '#4CAF50')
          .padding({ left: 10, right: 10, top: 4, bottom: 4 })
          .backgroundColor(this.plateNumber === '未绑定' ? '#FFF3E0' : '#E8F5E9')
          .borderRadius(10)
          .onClick(() => { try { this.bindDialogController.open(); } catch (_e) {} })

        Text('⚙')
          .fontSize(20)
          .fontColor('#888888')
          .padding({ left: 10, right: 6 })
          .onClick(() => { try { this.ocrDialogController.open(); } catch (_e) {} })
      }
      .width('100%')
      .padding({ left: 20, right: 14, top: 16, bottom: 16 })
      .backgroundColor(Color.White)

      // 密钥未配置提示条
      if (!this.ocrConfigured) {
        Row({ space: 6 }) {
          Text('⚠️').fontSize(14)
          Text('OCR 密钥未配置,识别会失败,点击右上角 ⚙ 配置')
            .fontSize(12).fontColor('#C77700')
          Blank()
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 8, bottom: 8 })
        .backgroundColor('#FFF8E1')
      }

      Stack({ alignContent: Alignment.Center }) {
        if (this.imagePath !== '') {
          Image(this.imagePath)
            .width('100%')
            .height('100%')
            .objectFit(ImageFit.Contain)
        } else {
          Column({ space: 12 }) {
            Text('📲').fontSize(56)
            Text('拍照或从相册选择图片').fontSize(15).fontColor('#999999')
            Text('系统将自动识别车牌').fontSize(13).fontColor('#BBBBBB')
          }
        }

        if (this.isRecognizing) {
          Column({ space: 12 }) {
            LoadingProgress().width(48).height(48).color('#007DFF')
            Text('正在识别车牌...').fontSize(15).fontColor(Color.White)
          }
          .width('100%')
          .height('100%')
          .justifyContent(FlexAlign.Center)
          .backgroundColor('#80000000')
        }
      }
      .width('100%')
      .layoutWeight(1)
      .backgroundColor('#F0F0F0')

      // 结果卡片
      if (this.resultState !== 'none' && !this.isRecognizing) {
        Column({ space: 8 }) {
          Row({ space: 12 }) {
            Text(this.getResultIcon()).fontSize(22)
            Column({ space: 4 }) {
              Text(this.getResultText()).fontSize(14).fontColor('#333333')
              if (this.resultState === 'error' && this.errorMsg !== '') {
                Text(this.errorMsg)
                  .fontSize(12).fontColor('#C62828').lineHeight(18)
              }
              if (this.resultState === 'match') {
                Text('返回首页即可查看本地模拟充电状态').fontSize(12).fontColor('#999999')
              }
            }.alignItems(HorizontalAlign.Start).layoutWeight(1)
          }
          .width('100%')

          // 操作按钮:不一致时可一键绑定识别结果;未绑定时引导去绑定
          if (this.resultState === 'mismatch') {
            Button('绑定识别车牌 ' + this.recognizedPlate, { type: ButtonType.Capsule })
              .width('100%').height(40).fontSize(14)
              .backgroundColor('#007DFF')
              .onClick(() => { this.bindRecognizedPlate(); })
          } else if (this.resultState === 'unbound') {
            Button('去绑定车牌', { type: ButtonType.Capsule })
              .width('100%').height(40).fontSize(14)
              .backgroundColor('#FF9800')
              .onClick(() => { try { this.bindDialogController.open(); } catch (_e) {} })
          }
        }
        .width('100%')
        .padding(16)
        .backgroundColor(this.getResultCardColor())
        .border({ width: { bottom: 1 }, color: '#EEEEEE' })
      }

      Column({ space: 12 }) {
        Row({ space: 12 }) {
          Button('📷 拍照', { type: ButtonType.Capsule })
            .layoutWeight(1)
            .height(52)
            .fontSize(15)
            .backgroundColor('#007DFF')
            .onClick(() => { this.takePhoto(); })
          Button('🖼️ 相册', { type: ButtonType.Capsule })
            .layoutWeight(1)
            .height(52)
            .fontSize(15)
            .backgroundColor('#4CAF50')
            .onClick(() => { this.pickFromGallery(); })
        }.width('85%')

        if (this.resultState === 'match') {
          Button('前往首页查看充电状态', { type: ButtonType.Normal })
            .width('85%')
            .height(44)
            .fontSize(14)
            .backgroundColor(Color.Transparent)
            .fontColor('#007DFF')
            .border({ width: 1, color: '#007DFF' })
            .borderRadius(22)
            .onClick(() => { getContext(this).eventHub.emit('switchTab', 0); })
        }
      }
      .width('100%')
      .padding({ top: 20, bottom: 36 })
      .alignItems(HorizontalAlign.Center)
      .backgroundColor(Color.White)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F7F9')
  }
}
5.7.1   Dashboard.ets
import { promptAction } from '@kit.ArkUI';
import { BindPlateDialog } from '../components/BindPlateDialog';
import { sendChargeCommand } from '../utils/IoTDAService';
import { startChargeSimulation, stopChargeSimulation } from '../utils/ChargeSimulator';
import { beginChargeSession, endChargeSession, abortChargeSession } from '../utils/ChargeRecordStore';

// ================= 实时功率折线图 =================
@Component
struct PowerLineChart {
  @Prop isCharging: boolean;
  @StorageLink('powerKw') powerKw: number = 0.0;
  private settings: RenderingContextSettings = new RenderingContextSettings(true);
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
  @State powerData: number[] = new Array(20).fill(0);
  private timer: number = -1;

  aboutToAppear() {
    this.powerData = new Array(20).fill(this.isCharging ? this.powerKw : 0.0);
    this.timer = setInterval(() => {
      this.powerData = [...this.powerData.slice(-19), this.isCharging ? this.powerKw : 0.0];
      setTimeout(() => { this.drawChart(); }, 50);
    }, 1000);
  }

  aboutToDisappear() { clearInterval(this.timer); }

  drawChart() {
    if (!this.context) return;
    const width = 300;
    const height = 120;
    const maxPower = 10;
    this.context.clearRect(0, 0, width, height);
    this.context.setLineDash([4, 4]);
    this.context.strokeStyle = '#E8EDF5';
    this.context.lineWidth = 1;
    for (let i = 1; i < 4; i++) {
      this.context.beginPath();
      this.context.moveTo(0, height - (i * height / 4));
      this.context.lineTo(width, height - (i * height / 4));
      this.context.stroke();
    }
    this.context.setLineDash([]);
    this.context.beginPath();
    this.context.strokeStyle = '#007DFF';
    this.context.lineWidth = 2.5;
    const stepX = width / (this.powerData.length - 1);
    for (let i = 0; i < this.powerData.length; i++) {
      const x = i * stepX;
      const y = height - (this.powerData[i] / maxPower) * height;
      if (i === 0) { this.context.moveTo(x, y); }
      else { this.context.lineTo(x, y); }
    }
    this.context.stroke();
  }

  build() {
    Column({ space: 12 }) {
      Row() {
        Text('实时充电功率').fontSize(15).fontColor('#666666')
        Blank()
        Row({ space: 4 }) {
          Text((this.isCharging ? this.powerData[this.powerData.length - 1] : 0.0).toFixed(1))
            .fontSize(28).fontWeight(FontWeight.Bold)
            .fontColor(this.isCharging ? '#007DFF' : '#BBBBBB')
          Text('kW').fontSize(14).fontColor('#999999').margin({ top: 10 })
        }
      }.width('100%')
      Canvas(this.context).width('100%').height(120).onReady(() => { this.drawChart(); })
    }
    .width('100%').padding({ left: 20, right: 20, top: 18, bottom: 18 })
    .backgroundColor('#FFFFFF').borderRadius(16)
    .shadow({ radius: 8, color: '#0F000000', offsetX: 0, offsetY: 2 })
  }
}

// ================= 传感器数值卡片 =================
@Component
struct SensorCard {
  @Prop icon: string = '';
  @Prop label: string = '';
  @Prop value: string = '';
  @Prop unit: string = '';
  @Prop highlight: boolean = false;

  build() {
    Column({ space: 6 }) {
      Text(this.icon).fontSize(28)
      Text(this.value)
        .fontSize(32).fontWeight(FontWeight.Bold)
        .fontColor(this.highlight ? '#007DFF' : '#222222')
      Text(this.unit).fontSize(13).fontColor('#999999')
      Text(this.label).fontSize(13).fontColor('#888888')
    }
    .layoutWeight(1).height(150).padding({ top: 20, bottom: 20 })
    .backgroundColor('#FFFFFF').borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .shadow({ radius: 8, color: '#0F000000', offsetX: 0, offsetY: 2 })
  }
}

// ================= 主面板 =================
@Component
export struct Dashboard {
  @StorageLink('plateNumber') plateNumber: string = '未绑定';
  @StorageLink('nearlinkStatus') nearlinkStatus: boolean = false;
  @StorageLink('pileStatus') pileStatus: string = '0';
  @StorageLink('durationMin') durationMin: number = 0;
  @StorageLink('temperature') temperature: string = '--';
  @StorageLink('humidity') humidity: string = '--';
  @StorageLink('distance') distance: string = '--';

  private lastClickTime: number = 0;

  private dialogController: CustomDialogController = new CustomDialogController({
    builder: BindPlateDialog(),
    alignment: DialogAlignment.Center,
    autoCancel: true
  });

  private showTip(msg: string) {
    try { promptAction.showToast({ message: msg }) } catch (_e) {}
  }

  getStatusColor(): string {
    if (this.pileStatus === '1') return '#007DFF';
    if (this.pileStatus === '2') return '#F44336';
    return '#4CAF50';
  }

  getStatusText(): string {
    if (this.pileStatus === '1') return `充电中 · 已充 ${this.durationMin} 分钟`;
    if (this.pileStatus === '2') return '⚠️ 设备故障';
    return '设备空闲';
  }

  // 统一应用充电状态:切换充电状态并启停本地功率模拟(设备真实功率上报后自动让位)
  private applyChargingState(status: string) {
    AppStorage.set('pileStatus', status);
    if (status === '1') {
      startChargeSimulation();
    } else {
      stopChargeSimulation();
      AppStorage.set('powerKw', 0.0);
      AppStorage.set('durationMin', 0);
    }
  }

  async handleMainButtonClick() {
    const now = new Date().getTime();
    if (now - this.lastClickTime < 1000) {
      this.showTip('操作过快,请稍后再试');
      return;
    }
    this.lastClickTime = now;
    if (this.pileStatus === '2') {
      this.showTip('设备故障,请检查硬件');
      return;
    }
    const prevStatus = this.pileStatus;
    const targetStatus = prevStatus === '1' ? '0' : '1';
    const stopDuration = this.durationMin;
    // 手动控制充电时退出本地 mock 状态,由云端状态接管
    AppStorage.set('mockCharging', false);
    // 乐观更新:点击后立即切换按钮为「停止充电/开始充电」并实时显示充电功率,
    // 让用户可以随时自主选择结束充电;指令下发失败时回滚状态
    this.applyChargingState(targetStatus);
    if (targetStatus === '1') {
      await beginChargeSession(this.plateNumber);
    }
    // 关键:立即进入轮询接管窗口,而不是等 await sendChargeCommand 成功后再设。
    // 否则在 HTTP 下发等待设备响应期间,每 5s 的设备轮询会用云端旧影子
    // (pile_status=0) 把刚乐观设成 '1' 的状态覆盖回去,表现为"充电 2 秒就自动停"。
    AppStorage.set('commandSyncUntil', new Date().getTime() + 20000);
    try {
      const res = await sendChargeCommand(targetStatus === '1' ? 'start_charge' : 'stop_charge');
      if (res.ok) {
        if (targetStatus === '1') {
          this.showTip('启动充电指令已下发');
        } else {
          await endChargeSession(stopDuration, true);
          this.showTip('停止充电指令已下发');
        }
      } else if (targetStatus === '0') {
        await endChargeSession(stopDuration, false);
        this.showTip("停止指令未被设备确认:" + res.reason);
      } else {
        // start 失败:不回滚、不 abort,保持本地乐观充电状态
        this.showTip("启动未被设备确认:" + res.reason);
      }
    } catch (e) {
      console.error('发送充电指令失败:', JSON.stringify(e));
      if (targetStatus === '0') {
        await endChargeSession(stopDuration, false);
        this.showTip("停止指令未被设备确认,请检查设备在线");
      } else {
        // start 网络异常:保持本地充电状态
        this.showTip('网络异常,本地仍保持充电状态');
      }
    }
  }

  build() {
    Scroll() {
      Column({ space: 16 }) {

        // ── 顶部:星闪状态 + 车牌 ──
        Row() {
          Row({ space: 6 }) {
            Circle({ width: 8, height: 8 }).fill(this.nearlinkStatus ? '#4CAF50' : '#9E9E9E')
            Text(this.nearlinkStatus ? '星闪已连通' : '星闪未连接')
              .fontSize(13).fontColor(this.nearlinkStatus ? '#4CAF50' : '#9E9E9E')
          }
          Blank()
          Text(this.plateNumber === '未绑定' ? '绑定车牌 >' : this.plateNumber)
            .fontSize(15).fontWeight(FontWeight.Medium)
            .fontColor('#007DFF')
            .onClick(() => { try { this.dialogController.open(); } catch (_e) {} })
        }
        .width('100%')
        .padding({ left: 20, right: 20, top: 20, bottom: 4 })

        // ── 充电状态大卡片 ──
        Column({ space: 10 }) {
          Text(this.pileStatus === '1' ? '🚗' : this.pileStatus === '2' ? '⚠️' : '🅿️')
            .fontSize(48)
          Text(this.pileStatus === '1' ? '车辆充电中' : this.pileStatus === '2' ? '设备故障' : '车位空置')
            .fontSize(26).fontWeight(FontWeight.Bold).fontColor(Color.White)
          Text(this.getStatusText())
            .fontSize(16).fontColor('#FFFFFFCC')
        }
        .width('100%').padding({ top: 36, bottom: 36 })
        .backgroundColor(this.getStatusColor())
        .borderRadius(20).alignItems(HorizontalAlign.Center)
        .margin({ left: 16, right: 16 })
        .shadow({ radius: 12, color: '#33000000', offsetX: 0, offsetY: 4 })


        // ── 4 个传感器卡片 2×2 ──
        Column({ space: 12 }) {
          Row({ space: 12 }) {
            SensorCard({
              icon: '🌡️', label: '环境温度',
              value: this.temperature, unit: '°C'
            })
            SensorCard({
              icon: '💧', label: '环境湿度',
              value: this.humidity, unit: '%'
            })
          }.width('100%')

          Row({ space: 12 }) {
            SensorCard({
              icon: '📡', label: '超声波距离',
              value: this.distance, unit: 'cm'
            })
            SensorCard({
              icon: this.pileStatus !== '0' ? '🚗' : '🅿️',
              label: '车位状态',
              value: this.pileStatus !== '0' ? '有车' : '空置',
              unit: '',
              highlight: this.pileStatus !== '0'
            })
          }.width('100%')
        }
        .width('100%').padding({ left: 16, right: 16 })
        .margin({ top: 4 })


        // ── 功率折线图 ──
        PowerLineChart({ isCharging: this.pileStatus === '1' })
          .margin({ left: 16, right: 16 })

        // ── 充电控制按钮 ──
        Button(this.pileStatus === '1' ? '停止充电' : '开始充电', { type: ButtonType.Capsule })
          .width('88%').height(54).fontSize(17).fontWeight(FontWeight.Medium)
          .backgroundColor(this.pileStatus === '1' ? '#F44336' : '#007DFF')
          .enabled(this.pileStatus !== '2')
          .shadow({ radius: 10, color: '#33007DFF', offsetX: 0, offsetY: 4 })
          .onClick(() => { this.handleMainButtonClick(); })
          .margin({ bottom: 40 })

      }.width('100%')
    }
    .width('100%').height('100%').backgroundColor('#F0F4FA').scrollBar(BarState.Auto)
  }
}
5.7.1   MapPage.ets
// 地图页:通过 WebView 加载 rawfile 下的高德地图页面(map.html)
// 支持从搜索页跳转回来定位到指定桩位(eventHub 'mapFocus' 事件)
// 打开地图时使用系统定位获取用户实际位置,经 WGS84→GCJ-02 转换后显示「我的位置」并居中
// 自定义桩位保存在 map.html 的 localStorage 中,本页定时同步到 AppStorage('customPiles'),
// 供搜索页合并查询(内置 + 自定义充电桩)
import router from '@ohos.router'
import { webview } from '@kit.ArkWeb'
import { geoLocationManager } from '@kit.LocationKit'
import { common, abilityAccessCtrl, Permissions } from '@kit.AbilityKit'

// 自定义桩位数据结构(与 map.html localStorage 中的字段对齐)
class CustomPileData {
  id: string
  lng: number
  lat: number
  address: string

  constructor(id: string, lng: number, lat: number, address: string) {
    this.id = id
    this.lng = lng
    this.lat = lat
    this.address = address
  }
}

@Component
export struct MapPage {
  // WebView 控制器,负责加载/刷新地图页
  private controller: webview.WebviewController = new webview.WebviewController()
  // 页面(map.html)是否已加载完成
  private mapReady: boolean = false
  // 待定位桩位 "lng,lat",地图就绪后执行
  private pendingFocus: string = ''
  // 待定位用户位置 "lng,lat",地图就绪后执行
  private pendingLocation: string = ''
  // 自定义桩位同步定时器
  private syncTimer: number = -1

  aboutToAppear() {
    // 监听搜索页发来的定位请求
    try {
      getContext(this).eventHub.on('mapFocus', (lng: number, lat: number) => {
        this.pendingFocus = `${lng},${lat}`
        this.applyFocus()
      })
    } catch (e) {
      console.error('mapFocus 注册失败:', JSON.stringify(e))
    }
    // 进入地图页时请求定位权限并获取用户位置
    this.requestAndLocate()
  }

  aboutToDisappear() {
    try {
      getContext(this).eventHub.off('mapFocus')
    } catch (e) {
      console.error('mapFocus 注销失败:', JSON.stringify(e))
    }
    // 停止同步定时器,避免页面销毁后继续执行
    if (this.syncTimer !== -1) {
      clearInterval(this.syncTimer)
      this.syncTimer = -1
    }
  }

  // 地图就绪后调用页面内的 focusPile(lng, lat) 定位
  private applyFocus() {
    if (!this.pendingFocus || !this.mapReady) {
      return
    }
    const target = this.pendingFocus
    this.pendingFocus = ''
    try {
      this.controller.runJavaScript(`focusPile(${target})`)
    } catch (e) {
      console.error('定位执行失败:', JSON.stringify(e))
    }
  }

  // 请求定位权限,授权后获取当前位置;拒绝或失败则保持地图默认中心(西安)
  private requestAndLocate() {
    try {
      const context = getContext(this) as common.UIAbilityContext
      const atManager = abilityAccessCtrl.createAtManager()
      const permissions: Array<Permissions> = [
        'ohos.permission.APPROXIMATELY_LOCATION',
        'ohos.permission.LOCATION'
      ]
      atManager.requestPermissionsFromUser(context, permissions).then(() => {
        this.getCurrentLocation()
      }).catch(() => {
        console.error('用户未授权定位权限,保持默认中心')
      })
    } catch (e) {
      console.error('定位权限请求失败:', JSON.stringify(e))
    }
  }

  // 获取当前定位(GPS WGS84),成功后通知地图页显示「我的位置」
  private getCurrentLocation() {
    try {
      const request: geoLocationManager.CurrentLocationRequest = {
        'priority': geoLocationManager.LocationRequestPriority.FIRST_FIX,
        'scenario': geoLocationManager.LocationRequestScenario.UNSET,
        'maxAccuracy': 200,
        'timeoutMs': 5000
      }
      geoLocationManager.getCurrentLocation(request, (err: BusinessError, location: geoLocationManager.Location) => {
        if (err || !location) {
          console.error('获取定位失败:', JSON.stringify(err))
          return
        }
        this.pendingLocation = `${location.longitude},${location.latitude}`
        this.applyLocate()
      })
    } catch (e) {
      console.error('获取定位异常:', JSON.stringify(e))
    }
  }

  // 地图就绪后调用页面内的 locateUser(lng, lat) 显示用户位置
  private applyLocate() {
    if (!this.pendingLocation || !this.mapReady) {
      return
    }
    const target = this.pendingLocation
    this.pendingLocation = ''
    try {
      this.controller.runJavaScript(`locateUser(${target})`)
    } catch (e) {
      console.error('定位执行失败:', JSON.stringify(e))
    }
  }

  // 从 map.html 读取自定义桩位并同步到 AppStorage,供搜索页查询
  // 使用内联脚本直接读 localStorage,不依赖页面是否包含 getCustomPiles(),
  // 即使 WebView 缓存了旧版 map.html 也能正常同步
  private syncCustomPiles() {
    // 在 JS 端把 localStorage 里的 JSON 字符串先 parse 成数组再返回。
    // 关键:runJavaScript 对"字符串"返回值会再做一次 JSON 编码(外层多包一对引号),
    // 若直接 return localStorage.getItem 的字符串,原生端 JSON.parse 后得到的仍是字符串,
    // 后续 .map 会抛 TypeError 并被 catch 静默吞掉,导致 AppStorage('customPiles') 一直为空——
    // 这就是"自定义桩在地图上可见、搜索却搜不到"的根因。
    const script = "(function(){ try { var v = localStorage.getItem('customPiles') || '[]'; return JSON.parse(v); } catch(e) { return []; } })()"
    try {
      this.controller.runJavaScript(script, (error: Error, result: string) => {
        if (error || typeof result !== 'string') {
          console.error('同步自定义桩位失败:', JSON.stringify(error ?? 'result is not string'))
          return
        }
        try {
          // 兜底:兼容 result 被多包一层引号的情况
          let parsed: object = JSON.parse(result)
          if (typeof parsed === 'string') {
            parsed = JSON.parse(parsed)
          }
          const arr = parsed as CustomPileData[]
          const list: CustomPileData[] = arr.map((p: CustomPileData) =>
            new CustomPileData(p.id, p.lng, p.lat, p.address)
          )
          AppStorage.setOrCreate<CustomPileData[]>('customPiles', list)
        } catch (e) {
          console.error('解析自定义桩位失败:', JSON.stringify(e))
        }
      })
    } catch (e) {
      console.error('同步自定义桩位失败:', JSON.stringify(e))
    }
  }

  build() {
    Column() {
      // 真实地图:WebView 加载 rawfile/map.html,由高德 JS API 渲染瓦片与桩位
      Web({ src: $rawfile('map.html'), controller: this.controller })
        .width('100%')
        .layoutWeight(1)
        // 允许页面执行 JavaScript(高德 JS API 必需)
        .javaScriptAccess(true)
        // 允许 localStorage,用于保存自定义桩位
        .domStorageAccess(true)
        // 允许页面加载网络图片(地图瓦片)
        .onlineImageAccess(true)
        // 允许读取本地资源文件
        .fileAccess(true)
        // 兼容页面中的 http/https 混载资源
        .mixedMode(MixedMode.All)
        .onPageEnd(() => {
          this.mapReady = true
          // 页面就绪后:定位用户位置 + 同步自定义桩位
          this.applyLocate()
          this.syncCustomPiles()
          if (this.syncTimer === -1) {
            this.syncTimer = setInterval(() => { this.syncCustomPiles() }, 2000)
          }
          this.applyFocus()
        })

      // 底部搜索栏
      this.SearchBar()
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }

  // 搜索栏
  @Builder
  SearchBar() {
    Row({ space: 10 }) {
      Row() {
        Text('🔍')
          .fontSize(16)
          .margin({ right: 6 })
        Text('搜索充电站、地址...')
          .fontSize(13)
          .fontColor('#BBBBBB')
        Blank()
      }
      .layoutWeight(1)
      .padding({ left: 14, right: 14, top: 8, bottom: 8 })
      .backgroundColor('#FFFFFF')
      .borderRadius(18)
      // 点击搜索框跳转搜索页(跳转前先同步一次,保证最新自定义桩位可被搜索)
      .onClick(() => {
        this.syncCustomPiles()
        try {
          router.pushUrl({ url: 'pages/SearchPage' })
        } catch (e) {
          console.error('跳转搜索页失败:', JSON.stringify(e))
        }
      })

      Text('筛选')
        .fontSize(13)
        .fontColor('#FFFFFF')
        .padding({ left: 12, right: 12, top: 8, bottom: 8 })
        .backgroundColor('#1E88E5')
        .borderRadius(18)
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 8, bottom: 8 })
    .backgroundColor('#FFFFFF')
  }
}
5.7.1   ProfilePage.ets
// 导入当前页面依赖的组件、状态工具和系统能力。
import { promptAction, router } from '@kit.ArkUI';
import { BindPlateDialog } from '../components/BindPlateDialog';
import { ChargeRecord, getChargeRecords, getActiveSession } from '../utils/ChargeRecordStore';

// 使用装饰器声明页面、组件或构建器,供界面树和框架生命周期识别。
@Component
struct ProfileMenuItem {
  // 这里集中定义组件状态、共享状态以及界面控制器。
  @Prop title: string = '';
  @Prop value: string = '';
  onItemClick: () => void = () => {};

  // build 方法负责组织页面布局,并把交互控件拼装成完整界面。
  build() {
    Row() {
      Text(this.title).fontSize(16).fontColor('#333333')
      Blank()
      Text(this.value).fontSize(14).fontColor(this.value === '未绑定' ? '#999999' : '#007DFF')
      Text(' >').fontSize(16).fontColor('#CCCCCC').margin({ left: 10 })
    }
    .width('100%').height(60).padding({ left: 20, right: 20 })
    .onClick(() => { this.onItemClick(); })
  }
}

function pad(n: number): string {
  return n < 10 ? '0' + n : '' + n;
}

function fmtTime(ts: number): string {
  const d = new Date(ts);
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}

function fmtDuration(min: number): string {
  if (min < 60) {
    return `${min} 分钟`;
  }
  const h = Math.floor(min / 60);
  const m = min % 60;
  return m > 0 ? `${h}小时${m}分` : `${h}小时`;
}

@Component
struct RecentRecordRow {
  @Prop record: ChargeRecord = {
    id: 0, startTime: 0, endTime: 0, durationMin: 0, peakKw: 0, plate: '未绑定', closedNormally: true
  };

  build() {
    Row() {
      Column({ space: 3 }) {
        Text(fmtTime(this.record.startTime)).fontSize(14).fontColor('#333333')
        Text(`车牌 ${this.record.plate}`).fontSize(12).fontColor('#999999')
      }
      .alignItems(HorizontalAlign.Start)
      Blank()
      Column({ space: 3 }) {
        Text(fmtDuration(this.record.durationMin)).fontSize(14).fontColor('#007DFF').fontWeight(FontWeight.Medium)
        Text(`${this.record.peakKw.toFixed(1)} kW`).fontSize(12).fontColor('#999999')
      }
      .alignItems(HorizontalAlign.End)
    }
    .width('100%').padding({ top: 12, bottom: 12 })
  }
}

// 使用装饰器声明页面、组件或构建器,供界面树和框架生命周期识别。
@Component
export struct ProfilePage {
  @StorageLink('plateNumber') plateNumber: string = '未绑定';
  @State username: string = 'admin';
  @State recentRecords: ChargeRecord[] = [];
  @State activeStart: number = 0;
  @State activePlate: string = '';
  private refreshTimer: number = -1;
  private destroyed: boolean = false;

  dialogController: CustomDialogController = new CustomDialogController({
    builder: BindPlateDialog({}),
    alignment: DialogAlignment.Center,
    autoCancel: true
  })

  async reloadRecords() {
    try {
      const all = await getChargeRecords();
      if (this.destroyed) return;
      this.recentRecords = all.slice(0, 3);
      const act = getActiveSession();
      if (act) {
        this.activeStart = act.startTime;
        this.activePlate = act.plate;
      } else {
        this.activeStart = 0;
        this.activePlate = '';
      }
    } catch (e) {}
  }

  aboutToAppear() {
    this.destroyed = false;
    this.reloadRecords();
    this.refreshTimer = setInterval(() => { this.reloadRecords(); }, 3000);
  }

  aboutToDisappear() {
    this.destroyed = true;
    if (this.refreshTimer !== -1) {
      clearInterval(this.refreshTimer);
      this.refreshTimer = -1;
    }
  }

  // build 方法负责组织页面布局,并把交互控件拼装成完整界面。
  build() {
    Scroll() {
      Column() {
        Column({ space: 10 }) {
          Rect({ width: 70, height: 70 }).fill('#E5E5E5').radius(4)
          Text(this.username).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#333333')
        }
        .width('100%').padding({ top: 60, bottom: 40 }).backgroundColor('#FFFFFF')

      Column() {
        ProfileMenuItem({
          title: '我的车牌', value: this.plateNumber,
          onItemClick: () => { this.dialogController.open(); }
        })
        Divider().color('#F0F0F0').strokeWidth(1).margin({ left: 20, right: 20 })

        ProfileMenuItem({
          title: '充电记录', value: '查看全部',
          onItemClick: () => {
            try { router.pushUrl({ url: 'pages/ChargeRecordsPage' }); } catch (e) {}
          }
        })
        Divider().color('#F0F0F0').strokeWidth(1).margin({ left: 20, right: 20 })

        ProfileMenuItem({
          title: '系统设置', value: '',
          onItemClick: () => { try { promptAction.showToast({ message: '设置' }); } catch (e) {} }
        })
      }
      .width('100%').backgroundColor('#FFFFFF').margin({ top: 20 })

      // 最近充电记录:直接在"我的"页展示最近 3 条,点"充电记录"看全部
      Column() {
        Row() {
          Text('最近充电').fontSize(15).fontWeight(FontWeight.Medium).fontColor('#333333')
          Blank()
          Text('查看全部 >').fontSize(12).fontColor('#007DFF')
            .onClick(() => {
              try { router.pushUrl({ url: 'pages/ChargeRecordsPage' }); } catch (e) {}
            })
        }
        .width('100%').margin({ bottom: 4 })

        if (this.activeStart > 0) {
          Row() {
            Circle({ width: 8, height: 8 }).fill('#00E676')
            Column({ space: 3 }) {
              Text(fmtTime(this.activeStart) + ' 开始')
                .fontSize(14).fontColor('#333333')
              Text('充电中 · 车牌 ' + this.activePlate)
                .fontSize(12).fontColor('#00C853')
            }.margin({ left: 8 })
            Blank()
            Text('充电中').fontSize(12).fontColor('#00C853')
          }
          .width('100%').padding({ top: 12, bottom: 12 })
          Divider().color('#F0F0F0').strokeWidth(1)
        }

        if (this.recentRecords.length === 0 && this.activeStart === 0) {
          Text('暂无充电记录,去首页开始一次充电吧')
            .fontSize(13).fontColor('#999999').padding({ top: 16, bottom: 16 })
            .width('100%').textAlign(TextAlign.Center)
        } else if (this.recentRecords.length > 0) {
          ForEach(this.recentRecords, (item: ChargeRecord) => {
            RecentRecordRow({ record: item })
            Divider().color('#F0F0F0').strokeWidth(1)
          }, (item: ChargeRecord) => '' + item.id)
        }
      }
      .width('100%').padding({ left: 20, right: 20, top: 16, bottom: 16 })
      .backgroundColor('#FFFFFF').margin({ top: 12 })

      Button('退出登录', { type: ButtonType.Normal })
        .width('90%').height(50).margin({ top: 40 }).backgroundColor('#FFFFFF')
        .fontColor('#FA5151').border({ width: 1, color: '#F0F0F0' }).borderRadius(4)
      // 这里定义一个独立方法,用来封装当前模块的关键业务逻辑。
        .onClick(() => {
          try {
            promptAction.showToast({ message: '已安全退出' });
            router.replaceUrl({ url: 'pages/LoginPage' });
          } catch (e) {}
        })
      }
      .width('100%')
    }
    .width('100%').height('100%').backgroundColor('#F5F7F9')
    .scrollBar(BarState.Off)
  }
}

六、总结

本项目以 HarmonyOS NEXT 为底座,完整实践了移动端 "端 — 云 — 桩" 应用开发的典型链路:

  1. 云侧集成:华为云 IoTDA 设备影子 + IAM 认证 + 命令下发,含错误码友好化与网络重试;
  2. 状态管理:乐观更新 + 三层状态仲裁机制,解决真实项目中 "轮询覆盖本地状态" 的经典竞态;
  3. AI 落地:百度 OCR 车牌识别(图片压缩、密钥动态配置)+ DeepSeek 大模型设备问答;
  4. 混合开发:WebView 加载高德地图 + 原生 / Web 双向通信 + 坐标转换 + 页面事件总线联动;
  5. 数据可靠性:充电会话录制器的落盘、恢复、对账闭环,保证数据不丢不重。
Logo

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

更多推荐