6 Commits

49 changed files with 1125 additions and 102 deletions

7
.gitignore vendored
View File

@@ -9,4 +9,9 @@
/.clang-format
/.clang-tidy
**/.test
/.appanalyzer
/.appanalyzer
# 签名材料(个人 debug 证书,勿提交)
*.p12
*.cer
*.p7b
**/signing/

View File

@@ -13,8 +13,9 @@ HarmonyOS NEXT app. ArkTS + ArkUI declarative UI, hvigor build, DevEco Studio.
## Architecture Overview
单 HAP 模块应用(`entry/`)。页面在 `main_pages.json` 注册;`EntryAbility` 是应用入口 Ability。
Single-HAP module (`entry/`). Pages registered in `main_pages.json`; `EntryAbility` is the app entry.
单 HAP + 多 HAR 模块化应用:`entry/`(HAP,应用壳 + 业务)、`common/`(HAR,基础设施)、`uikit/`(HAR,通用 UI)。
Single-HAP + multi-HAR: `entry/` (HAP shell + business), `common/` (HAR infra), `uikit/` (HAR shared UI).
状态管理全量 StateManagement V2(`@ComponentV2/@Local/@Param/@Event/@ObservedV2/@Trace`);导航用 `Navigation` + `NavPathStack`。
详见 `docs/architecture.md`。
## Directory Structure
@@ -22,19 +23,27 @@ Single-HAP module (`entry/`). Pages registered in `main_pages.json`; `EntryAbili
```
harmony-client/
├── AppScope/ # 应用级配置(bundleName、应用图标/标签)
├── entry/ # 主模块(单 HAP)
├── entry/ # HAP 应用壳 + 业务(唯一入口)
│ └── src/
│ ├── main/
│ │ ├── ets/ # ArkTS 源码
│ │ │ ├── entryability/ # 应用入口 Ability
│ │ ├── ets/
│ │ │ ├── entryability/ # 应用入口 Ability(依赖装配)
│ │ │ ├── entrybackupability/ # 备份扩展 Ability
│ │ │ └── pages/ # 页面(@Entry 组件)
│ │ ├── resources/ # 资源(字符串/颜色/媒体,用 $r() 引用)
│ │ │ ├── router/ # 路由(AppRouter + RouteMap)
│ │ │ ├── di/ # 依赖注入(ServiceLocator)
│ │ │ ├── model/ # 领域模型(@ObservedV2 + @Trace)
│ │ │ ├── data/ # 数据层(api / repository)
│ │ │ ├── viewmodel/ # 视图模型(@ObservedV2 三态)
│ │ │ ├── view/ # 业务复用组件
│ │ │ └── pages/ # 页面(@Entry @ComponentV2,仅编排)
│ │ ├── resources/ # 资源(字符串/媒体,用 $r() 引用)
│ │ └── module.json5 # 模块清单(Ability 声明、权限)
│ ├── test/ # 本地单元测试(hypium)
│ ├── ohosTest/ # 设备集成测试(hypium)
│ └── mock/ # mock 配置
├── build-profile.json5 # 构建/签名/产品配置
├── common/ # HAR 基础设施(网络/日志/状态/工具)
├── uikit/ # HAR 通用 UI 组件库 + 主题 token
├── build-profile.json5 # 构建/签名/产品/模块配置
├── code-linter.json5 # 代码规范(TS ESLint + 安全/性能规则)
├── hvigorfile.ts # hvigor 构建入口
├── oh-package.json5 # 依赖声明(hypium / hamock)
@@ -48,10 +57,10 @@ harmony-client/
2. **声明式 UI Declarative UI** — 用状态驱动渲染,禁止命令式直接改 UI。
Drive UI via state; never mutate UI imperatively.
3. **命名 Naming** — 组件/Ability `PascalCase`(文件 `.ets`);变量/函数/属性 `camelCase`;常量 `UPPER_SNAKE_CASE`。
4. **资源 Resources** — 字符串/颜色/媒体一律 `$r('app.xxx')`,禁止硬编码。
All strings/colors/media via `$r('app.xxx')`; no hardcoding.
5. **分层 Layering** — 页面只做 UI;业务逻辑放 `service/`,数据模型放 `model/`,复用组件放 `components/`。
Pages = UI only; logic → `service/`, models → `model/`, reusable components → `components/`.
4. **资源 Resources** — 颜色/间距/字号走 `uikit/theme/Theme.ets` 的 token;用户可见文案优先 `$r('app.string.xxx')`;媒体 `$r('app.media.xxx')`。禁止组件内散落魔法值。
Colors/spacing/fonts via `uikit/theme/Theme.ets` tokens; copy via `$r('app.string.xxx')`; media via `$r('app.media.xxx')`. No scattered magic values.
5. **分层 Layering** — 页面只编排;业务逻辑放 `viewmodel/`,数据放 `data/`(repository/api),模型放 `model/`,复用组件放 `view/`(通用下沉 `uikit/`)。
Pages orchestrate only; logic → `viewmodel/`, data → `data/`, models → `model/`, reusable components → `view/` (shared → `uikit/`).
6. **安全 Security** — 遵守 `code-linter.json5` 的 `@security/*` 规则(禁用不安全哈希/AES 等)。
7. **改后必验证 Verify after change** — 每次改动跑 lint + 相关测试,见 `docs/testing.md`。
@@ -66,9 +75,9 @@ harmony-client/
## Common Tasks
- 加页面 Add a page → `entry/src/main/ets/pages/` + 在 `main_pages.json` 注册
- 加复用组件 Reusable component → `entry/src/main/ets/components/`
- 写业务逻辑 Business logic → `entry/src/main/ets/service/`
- 加页面 Add a page → `entry/src/main/ets/pages/`(Navigation 路由则加 `router/RouteMap.ets`)
- 加复用组件 Reusable component → `entry/src/main/ets/view/`(跨模块通用 → `uikit/src/main/ets/components/`)
- 写业务逻辑 Business logic → `entry/src/main/ets/viewmodel/` + `entry/src/main/ets/data/`
- 加数据模型 Data model → `entry/src/main/ets/model/`
- 跑单测 Run tests → `docs/testing.md`

View File

@@ -37,6 +37,30 @@
]
}
]
},
{
"name": "common",
"srcPath": "./common",
"targets": [
{
"name": "default",
"applyToProducts": [
"default"
]
}
]
},
{
"name": "uikit",
"srcPath": "./uikit",
"targets": [
{
"name": "default",
"applyToProducts": [
"default"
]
}
]
}
]
}

17
common/BuildProfile.ets Normal file
View File

@@ -0,0 +1,17 @@
/**
* Use these variables when you tailor your ArkTS code. They must be of the const type.
*/
export const HAR_VERSION = '1.0.0';
export const BUILD_MODE_NAME = 'debug';
export const DEBUG = true;
export const TARGET_NAME = 'default';
/**
* BuildProfile Class is used only for compatibility purposes.
*/
export default class BuildProfile {
static readonly HAR_VERSION = HAR_VERSION;
static readonly BUILD_MODE_NAME = BUILD_MODE_NAME;
static readonly DEBUG = DEBUG;
static readonly TARGET_NAME = TARGET_NAME;
}

1
common/Index.ets Normal file
View File

@@ -0,0 +1 @@
export * from './src/main/ets/Index.ets';

View File

@@ -0,0 +1,10 @@
{
"apiType": "stageMode",
"buildOption": {
},
"targets": [
{
"name": "default"
}
]
}

6
common/hvigorfile.ts Normal file
View File

@@ -0,0 +1,6 @@
import { harTasks } from '@ohos/hvigor-ohos-plugin';
export default {
system: harTasks, /* Built-in plugin of Hvigor. It cannot be modified. */
plugins: [] /* Custom plugin to extend the functionality of Hvigor. */
}

9
common/oh-package.json5 Normal file
View File

@@ -0,0 +1,9 @@
{
"name": "common",
"version": "1.0.0",
"description": "基础设施层:网络 / 日志 / 状态 / 工具",
"main": "Index.ets",
"author": "",
"license": "Apache-2.0",
"dependencies": {}
}

View File

@@ -0,0 +1,8 @@
export { HttpClient } from './network/HttpClient';
export { HttpInterceptor } from './network/HttpInterceptor';
export { AuthInterceptor } from './network/AuthInterceptor';
export { ApiError } from './network/ApiError';
export { ApiResult } from './network/ApiResult';
export { SessionStore, SessionManager } from './state/SessionStore';
export { Logger } from './logging/Logger';
export { FormatUtil } from './utils/FormatUtil';

View File

@@ -0,0 +1,24 @@
import { hilog } from '@kit.PerformanceAnalysisKit';
const APP_DOMAIN: number = 0x0000;
/**
* 日志封装:统一 DOMAIN 与等级,后续可在此加开关/上报。
*/
export class Logger {
static debug(tag: string, message: string): void {
hilog.debug(APP_DOMAIN, tag, '%{public}s', message);
}
static info(tag: string, message: string): void {
hilog.info(APP_DOMAIN, tag, '%{public}s', message);
}
static warn(tag: string, message: string): void {
hilog.warn(APP_DOMAIN, tag, '%{public}s', message);
}
static error(tag: string, message: string): void {
hilog.error(APP_DOMAIN, tag, '%{public}s', message);
}
}

View File

@@ -0,0 +1,26 @@
/**
* 统一错误模型:业务错误码 / HTTP 状态码 / 网络异常。
* 继承 Error 以符合 ArkTS 的 throw 限制(只能抛 Error 及其子类)。
*/
export class ApiError extends Error {
readonly code: number;
readonly isNetworkError: boolean;
constructor(code: number, message: string, isNetworkError: boolean = false) {
super(message);
this.code = code;
this.isNetworkError = isNetworkError;
}
static network(detail: string): ApiError {
return new ApiError(-1, `网络请求失败: ${detail}`, true);
}
static http(statusCode: number): ApiError {
return new ApiError(statusCode, `HTTP 状态码异常: ${statusCode}`, false);
}
static business(code: number, message: string): ApiError {
return new ApiError(code, message, false);
}
}

View File

@@ -0,0 +1,18 @@
/**
* 统一响应包装:与后端约定 code / message / data 结构。
*/
export class ApiResult<T> {
code: number;
message: string;
data: T;
constructor(code: number, message: string, data: T) {
this.code = code;
this.message = message;
this.data = data;
}
isSuccess(): boolean {
return this.code === 0;
}
}

View File

@@ -0,0 +1,24 @@
import { HttpInterceptor } from './HttpInterceptor';
import { SessionManager } from '../state/SessionStore';
/**
* 鉴权拦截器:请求前注入 Authorization 头;401 时清空会话(登出)。
*
* TODO(鉴权): 401 时的 token 刷新 + 原请求重放(防并发刷新)需在
* 后端 refresh 接口约定后实现,当前先清空会话交由上层跳登录。
*/
export class AuthInterceptor implements HttpInterceptor {
onRequest(header: Record<string, string>): Record<string, string> {
const session = SessionManager.get();
if (session.token.length > 0) {
header['Authorization'] = `Bearer ${session.token}`;
}
return header;
}
onResponse(responseCode: number, result: string): void {
if (responseCode === 401) {
SessionManager.get().clear();
}
}
}

View File

@@ -0,0 +1,65 @@
import { http } from '@kit.NetworkKit';
import { HttpInterceptor } from './HttpInterceptor';
import { ApiError } from './ApiError';
/**
* HTTP 客户端封装:统一超时、请求头、拦截器链。
* 基于 @kit.NetworkKit 的 http 模块(Promise 风格)。
*/
export class HttpClient {
private readonly client: http.HttpRequest = http.createHttp();
private interceptors: HttpInterceptor[] = [];
addInterceptor(interceptor: HttpInterceptor): HttpClient {
this.interceptors.push(interceptor);
return this;
}
async get(url: string, header?: Record<string, string>): Promise<string> {
return this.request(url, http.RequestMethod.GET, undefined, header);
}
async post(url: string, body: string, header?: Record<string, string>): Promise<string> {
return this.request(url, http.RequestMethod.POST, body, header);
}
async put(url: string, body: string, header?: Record<string, string>): Promise<string> {
return this.request(url, http.RequestMethod.PUT, body, header);
}
async delete(url: string, header?: Record<string, string>): Promise<string> {
return this.request(url, http.RequestMethod.DELETE, undefined, header);
}
private async request(url: string, method: http.RequestMethod, body?: string,
header?: Record<string, string>): Promise<string> {
let headers: Record<string, string> = {};
if (header !== undefined) {
headers = header;
}
for (const interceptor of this.interceptors) {
headers = interceptor.onRequest(headers);
}
const options: http.HttpRequestOptions = {
method: method,
header: headers,
expectDataType: http.HttpDataType.STRING,
connectTimeout: 10000,
readTimeout: 10000
};
if (body !== undefined) {
options.extraData = body;
}
try {
const response: http.HttpResponse = await this.client.request(url, options);
for (const interceptor of this.interceptors) {
interceptor.onResponse(response.responseCode, response.result as string);
}
return response.result as string;
} catch (err) {
throw ApiError.network(JSON.stringify(err));
}
}
}

View File

@@ -0,0 +1,10 @@
/**
* 请求/响应拦截器接口。
* 实现类可注入请求头(鉴权)、统一处理响应(401/错误码)等。
*/
export interface HttpInterceptor {
/** 请求发出前,返回(可修改的)请求头。 */
onRequest(header: Record<string, string>): Record<string, string>;
/** 响应返回后,可据此抛错中断(如 401 触发登出)。 */
onResponse(responseCode: number, result: string): void;
}

View File

@@ -0,0 +1,56 @@
import { PersistenceV2 } from '@kit.ArkUI';
/**
* 会话状态:登录 token / 用户信息。
* 用 PersistenceV2 持久化,@Trace 字段变更自动落盘。
*/
@ObservedV2
export class SessionStore {
@Trace token: string = '';
@Trace refreshToken: string = '';
@Trace userId: string = '';
@Trace displayName: string = '';
get isLoggedIn(): boolean {
return this.token.length > 0;
}
setSession(token: string, refreshToken: string, userId: string, displayName: string): void {
this.token = token;
this.refreshToken = refreshToken;
this.userId = userId;
this.displayName = displayName;
}
clear(): void {
this.token = '';
this.refreshToken = '';
this.userId = '';
this.displayName = '';
}
}
/**
* 会话单例管理:全局唯一连接,跨页面/跨组件共享同一份持久化状态。
*/
export class SessionManager {
private static instance: SessionStore | undefined = undefined;
static init(): SessionStore {
if (SessionManager.instance === undefined) {
SessionManager.instance = PersistenceV2.globalConnect({
type: SessionStore,
key: 'session',
defaultCreator: () => new SessionStore()
})!;
}
return SessionManager.instance;
}
static get(): SessionStore {
if (SessionManager.instance === undefined) {
return SessionManager.init();
}
return SessionManager.instance;
}
}

View File

@@ -0,0 +1,15 @@
/**
* 通用工具(占位示例,随业务扩充)。
*/
export class FormatUtil {
static isEmpty(value: string): boolean {
return value.length === 0;
}
static formatCount(count: number): string {
if (count >= 10000) {
return `${(count / 10000).toFixed(1)}w`;
}
return `${count}`;
}
}

View File

@@ -0,0 +1,10 @@
{
"module": {
"name": "common",
"type": "har",
"description": "$string:module_desc",
"deviceTypes": [
"phone"
]
}
}

View File

@@ -0,0 +1,8 @@
{
"string": [
{
"name": "module_desc",
"value": "common module"
}
]
}

View File

@@ -1,58 +1,209 @@
# Architecture(架构)
# HarmonyOS NEXT 客户端 · 架构设计文档
HarmonyOS NEXT 单 HAP 应用,API 22(HarmonyOS 6.0)。技术栈:ArkTS + ArkUI 声明式 UI + hvigor。
Single-HAP HarmonyOS NEXT app, API 22 (HarmonyOS 6.0). Stack: ArkTS + ArkUI declarative UI + hvigor.
> 项目:`harmony-client`(ArkTS + ArkUI,SDK 6.0.2 / API 22,仅 phone)
> 业务形态:对接自有后端的通用业务客户端(HTTP + 鉴权 + CRUD)
## 模块结构 Module Structure
---
- **AppScope/** — 应用级信息:`app.json5` 里定义 `bundleName`(`com.mingyuan.harmony_client`)、应用图标/标签(`$media:layered_image` / `$string:app_name`)。
- **entry/** — 唯一主模块(HAP)。`module.json5` 声明 Ability、权限、入口。
## 1. 背景与目标
## Ability 生命周期 Ability Lifecycle
本工程从 DevEco 空脚手架起步,目标是建立一套**可持续演进、可测试、低耦合**的客户端架构骨架,为后续业务开发打底。设计依据来自一轮 deep-research 调研(交叉验证华为官方文档与社区实践,9 条结论全部通过对抗式验证)。
**EntryAbility**(`entry/src/main/ets/entryability/EntryAbility.ets`)是应用入口,继承 `UIAbility`。关键回调:
核心决策:
| 回调 Callback | 用途 Purpose |
|--------------|-------------|
| `onCreate` | Ability 创建,初始化状态(**此时不要做 UI 操作**) |
| `onWindowStageCreate` | 窗口创建,在这里 `loadContent('pages/Index')` 加载首页 |
| `onForeground` / `onBackground` | 前后台切换 |
| `onDestroy` | 销毁,释放资源 |
| 决策点 | 选择 | 理由 |
|---|---|---|
| 工程结构 | 官方三层 commons / features / products | 单向依赖、官方白皮书推荐 |
| 模块化 | **单 HAP + 多 HAR** | HAR 编译期打包、可裁剪;HSP 仅按需加载 |
| 代码分层 | **MVVM + 独立 data 层** | 官方指南 + 网络/数据解耦 |
| 状态管理 | **全量 StateManagement V2** | API 22 下 V2 能力完整,字段级精准刷新 |
**EntryBackupAbility** — 应用备份扩展 Ability(备份/恢复数据)。
---
## 页面与路由 Pages & Routing
## 2. 模块划分与依赖
- 页面组件用 `@Entry` 装饰,在 `entry/src/main/resources/base/profile/main_pages.json` 注册。
- 页面跳转:`router.pushUrl()`(栈式导航),或 `Navigation` 组件(推荐,用于复杂导航)。
```
harmony-client/
├── entry/ HAP 应用壳 + 业务功能(唯一入口,EntryAbility)
├── common/ HAR 基础设施层(网络 / 日志 / 状态 / 工具,无业务、无 UI 组件)
└── uikit/ HAR 通用 UI 组件库 + 主题(无业务、无网络依赖)
```
## 目录分层 Layering Convention
依赖方向严格单向,禁止反向/循环:
```
entry ──依赖──▶ common
entry ──依赖──▶ uikit
common / uikit 各自独立,互不依赖
```
- **common**:`HttpClient`、鉴权拦截器、统一错误模型、`Logger`、`SessionStore`(PersistenceV2 持久化)、工具函数。
- **uikit**:主题 token(`AppColor/AppSpacing/AppFontSize`)、通用组件(`AppButton/LoadingView/EmptyView`)。
- **entry**:应用壳装配、路由、DI 容器、MVVM 分层业务代码。
---
## 3. 分层与目录结构(entry 内)
采用官方 MVVM 指南的**层优先**结构,叠加独立 `data/` 层与横切的 `router/`、`di/`:
```
entry/src/main/ets/
├── entryability/ # Ability 入口(生命周期)
├── entrybackupability/ # 备份扩展
├── pages/ # 页面(@Entry 组件,只做 UI 编排)
├── components/ # 可复用 UI 组件(@Component / @Reusable)
├── viewmodel/ # 状态管理(连接 model 与 UI)
├── service/ # 业务逻辑、数据访问(网络/存储)
├── model/ # 数据模型、类型定义
└── common/ # 公共常量、工具函数
├── entryability/
│ └── EntryAbility.ets # 生命周期装配:Logger / ServiceLocator / SessionStore
├── router/
│ ├── RouteMap.ets # 路由名常量 + name→页面 分发
│ └── AppRouter.ets # Navigation 导航封装(push/replace/pop/...)
├── di/
│ └── ServiceLocator.ets # 轻量服务定位器(ArkTS 无反射,手动注册/解析)
├── model/ # 领域模型:@ObservedV2 + @Trace 实体
├── data/
│ ├── api/ # DTO + 请求服务(调 common 的 HttpClient)
│ └── repository/ # 数据仓库契约(抽象类)+ 实现
├── viewmodel/ # @ObservedV2 状态容器(data/loading/error 三态)
├── view/ # 可复用 UI 组件(@ComponentV2)
└── pages/ # 页面,仅编排(组合 view + viewmodel)
```
依赖方向(只能从右往左引,禁止反向):
Dependency direction (only right→left, never reverse):
`pages → viewmodel → service → model`;`components` 不依赖业务层。
**单向数据流**:
## 状态管理 State Management
```
pages → viewmodel → repository(契约) → api → HttpClient → 后端
↑ 状态回传由 @Trace 触发自动刷新
```
- 组件内:`@State`(本地)、`@Prop`(父传子单向)、`@Link`(父子双向)。
- 跨组件:`@Provide` / `@Consume`,或 `AppStorage`(全局)/ `LocalStorage`(页面级)。
- 持久化:`PersistentStorage`(简单键值)。
- 原则:**UI 由状态驱动**,不在组件外手动改 UI。
---
## 关键约定速查 Key Contracts
## 4. 状态管理约定(全量 V2,禁用 V1)
- 所有字符串/颜色/媒体走 `$r()` 资源引用。
- 页面只做 UI 编排;数据获取/业务判断在 `service/`,类型在 `model/`。
- 新增页面必须同步注册到 `main_pages.json`。
| 场景 | 使用 |
|---|---|
| 组件/页面 | `@ComponentV2` |
| 组件内部状态 | `@Local` |
| 父→子输入 | `@Param` |
| 子→父输出/回调 | `@Event`(**不用 `@Link`**;需双向时用 `@Param` 的 `!!` 语法糖) |
| 类/模型可观察 | `@ObservedV2` 类 + `@Trace` 字段 |
| 跨层共享 | `@Provider` / `@Consumer` |
| 变化监听 | `@Monitor` |
| 全局会话 | `SessionStore`(`@ObservedV2` 单例,见 `common/state/SessionStore.ets`) |
| 轻量持久化 | `PersistenceV2.globalConnect(...)`;结构化数据后续用 relationalStore(RDB) |
**核心约定**:ViewModel 是 `@ObservedV2` 类,持有 `@Trace` 的 `data/loading/error` 三态;页面只读状态、调方法,不直接碰 Repository / 网络层。
---
## 5. 网络与数据层
### 5.1 网络层(`common/src/main/ets/network/`)
- `HttpClient`:封装 `@kit.NetworkKit` 的 http,统一超时/请求头,支持**拦截器链**。
- `HttpInterceptor`:拦截器接口(`onRequest` / `onResponse`)。
- `AuthInterceptor`:请求前注入 `Authorization: Bearer <token>`;401 时清空会话。
### 5.2 鉴权流程
```
请求 → AuthInterceptor.onRequest 注入 token → 发出
→ AuthInterceptor.onResponse 检查 401
→ (当前) 清空 SessionStore,由上层跳登录
→ (TODO) token 刷新 + 原请求重放(防并发刷新),需后端 refresh 接口约定后实现
```
### 5.3 Repository 模式
- `data/repository/` 中定义**抽象类契约**,ViewModel 只依赖契约,实现可替换(真实 / mock)。
- 用抽象类而非 interface,是为了在 `ServiceLocator` 中做 `Object → 契约` 强转时兼容 ArkTS。
- `data/api/` 返回 DTO,Repository 负责 **DTO → 领域模型** 映射。
### 5.4 统一响应与错误
- `ApiResult<T>`:`{ code, message, data }`,`code === 0` 为成功。
- `ApiError`:区分网络异常 / HTTP 状态码 / 业务错误码。
---
## 6. 横切关注点
### 6.1 依赖注入
`di/ServiceLocator.ets`:`Map<string, Object>` 单例注册表。启动时在 `EntryAbility.onCreate` 注册 `HttpClient`、各 Repository、`SessionStore`;业务侧 `get(key)` 后强转。
> ArkTS 无反射,用字符串 key + 显式强转;若后续规模增大可引入类型安全的 key 常量或轻量注入容器。
### 6.2 导航
`Navigation` + `NavPathStack`(官方推荐,替代旧 Router)。`RouteMap.ets` 定义路由名常量与 `name→页面` 分发;`AppRouter` 持有全局 `NavPathStack` 提供 `push/replace/pop/popToName/clear`。HAR 不承载路由。
### 6.3 日志
`common/logging/Logger.ets`:封装 `hilog`,统一 DOMAIN,后续可加等级开关/上报。
### 6.4 持久化
- `PersistenceV2`:轻量 KV(token / 用户偏好),`@Trace` 字段自动落盘。
- `relationalStore(RDB)`:结构化数据(订单、列表缓存等),后续引入。
---
## 7. 编码规范
1. **状态管理只用 V2**:新组件一律 `@ComponentV2`,禁止混入 V1 装饰器。
2. **单向数据流**:父→子用 `@Param`,子→父用 `@Event`;页面不直接改 ViewModel 之外的全局状态。
3. **页面只编排**:`pages/` 下只做组件组合与 ViewModel 调用,业务逻辑进 ViewModel,数据进 Repository/Api。
4. **跨模块引用走 `Index.ets`**:entry 只 `import { ... } from 'common'/'uikit'`,不直接引 HAR 内部路径。
5. **魔法值收敛到 `uikit/theme/Theme.ets`**:颜色/间距/字号统一取 token。
---
## 8. 演进指南
### 8.1 抽取 feature HAR(业务领域明确后)
当某个业务领域(如订单、支付)足够独立时,新增 `feature_xxx` HAR:
```
feature_order/
├── src/main/ets/{model,data,viewmodel,view,pages}
└── oh-package.json5 (name: feature_order, main: Index.ets)
```
- 根 `build-profile.json5` 的 `modules` 增加该模块。
- 依赖它的模块在其 `oh-package.json5` 增加 `"feature_order": "file:../feature_order"`。
- 依赖方向:`entry → feature_xxx → common/uikit`,禁止 feature 之间横向依赖(确实需共享时下沉到 `common`)。
### 8.2 迁移到 feature-first
当 feature 数量增多、单层目录过深时,可将 `model/data/viewmodel/view/pages` 按 feature 重组:
```
ets/features/<feature>/{model,data,viewmodel,view,pages}
ets/shared/… # 跨 feature 共享
```
迁移原则:先把稳定边界抽 HAR,再在 HAR 内部做 feature 分组。
---
## 9. 调研结论摘要与引用
- 官方 MVVM(StateManagement V2)指南给出 `model/viewmodel/view/pages` 分层。
- 官方三层工程结构:commons(禁止反向依赖)/ features / products(不可横向调用),打包形态 HAR/HSP/Feature HAP/Entry HAP。
- V2 装饰器映射:`@State→@Local`、`@Prop→@Param`、`@Link→@Param+@Event`、`@Observed→@ObservedV2+@Trace`、`@Watch→@Monitor`、`AppStorage→AppStorageV2`、`PersistentStorage→PersistenceV2`。
- V1/V2 不可混用(编译错误);API 19+ 提供 `UIUtils.enableV2Compatibility/makeV1Observed` 放宽混合约束。
主要引用:
- https://developer.huawei.com/consumer/en/doc/harmonyos-guides/arkts-mvvm-v2
- https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/arkts-decorator-overview
- https://device.harmonyos.com/en/docs/apiref/harmonyos-guides/arkts-v1-v2-update-difference
- https://github.com/openharmony/docs(arkts-v1-v2-migration-application / mixusage / inner-component)
- https://github.com/OpeNopEn2007/arkts-patterns
---
## 10. 边界与后续
- 本次为**骨架级**交付,未实现真实后端对接与具体业务(保留示例链路)。
- 未新增 feature HAR(无业务领域),仅写入抽取指南。
- 未做多设备适配(已确认仅 phone)。
- 后续待办:token 刷新策略、RDB 持久化、自动化测试基建、环境配置注入(BASE_URL)。

View File

@@ -17,6 +17,20 @@ hvigorw assembleHap --mode module -p product=default -p buildMode=release
产物通常在 `entry/build/default/outputs/` 下(`default` 为 product 名)。
## 多模块构建 Multi-module Build
本工程为「单 HAP + 多 HAR」:`entry/`(HAP)+ `common/`(HAR)+ `uikit/`(HAR)。
- HAR(`common`/`uikit`)作为 `entry` 的依赖,构建 entry 时会**自动先编译**依赖的 HAR,正常开发只需构建 entry。
- 任务类型:HAP 用 `assembleHap`,HAR 用 `assembleHar`。
- 构建整个工程用 `--mode project`(按依赖顺序构建全部模块);构建单个模块用 `--mode module`。
- 模块注册:根 `build-profile.json5` 的 `modules`;模块间依赖:各自 `oh-package.json5` 的 `dependencies` 用 `file:../xxx` 声明。
**HAR 配置要点(易错)**:
- `module.json5` 的 `type` 必须是 `har`(**不是** `shared`——`shared` 对应 HSP,会要求 `hspTasks`)。
- `oh-package.json5` 的 `main: "Index.ets"` 指向**模块根目录**的 `Index.ets`(该文件 `export * from './src/main/ets/Index.ets'` 转发公共 API)。
- 权限(如 `ohos.permission.INTERNET`)只声明在 HAP 的 `module.json5` 的 `requestPermissions`;HAR 不声明权限。
## 签名 Signing
`build-profile.json5` 里 `signingConfigs` 目前为**空**,且 `products[].signingConfig` 引用了 `"default"` —— **签名尚未配置**。

View File

@@ -21,25 +21,33 @@ ArkTS(TypeScript 的超集,HarmonyOS 严格类型方言)+ ArkUI 声明式
## 声明式 UI 与状态 Declarative UI & State
- UI 由状态驱动:改状态 → 自动重渲染,禁止命令式直接改 UI。
- 状态装饰器选择:
- 局部状态 `@State`
- 父子传递 `@Prop`(单向)/ `@Link`(双向)
- 跨层 `@Provide` / `@Consume`
- 避免不必要的重渲染:状态粒度要细,不要一个 `@State` 装一整个大对象。
- 状态管理**全量使用 StateManagement V2**(禁用 V1 装饰器):
- 组件/页面 `@ComponentV2`
- 局部状态 `@Local`
- 父子传递 `@Param`(单向输入)/ `@Event`(子→父回调,**不用 `@Link`**)
- 类可观察 `@ObservedV2` + `@Trace`(字段级精准刷新)
- 跨层共享 `@Provider` / `@Consumer`;变化监听 `@Monitor`
- 全局/持久化 `AppStorageV2` / `PersistenceV2`
- 避免不必要的重渲染:`@Trace` 字段粒度要细,不要一个大对象整体刷新。
## 资源 Resources
- 字符串、颜色、媒体一律 `$r('app.xxx')`(定义在 `resources/base/element/`)。
- 禁止在 `.ets` 里硬编码字符串、颜色值、路径。
- 颜色 / 间距 / 字号统一走 `uikit/theme/Theme.ets` 的 token(`AppColor` / `AppSpacing` / `AppFontSize`),禁止在组件里散落颜色/尺寸魔法值。
- 用户可见文案:优先 `$r('app.string.xxx')`(便于国际化);临时/演示文案可内联,正式功能应收口到资源。
- 图片/图标等媒体:`$r('app.media.xxx')`。
## 组件化 Components
- 页面:`@Entry` 组件,放 `pages/`。
- 复用组件:`@Component` 或 `@Reusable`(长列表复用),放 `components/`,通过 `@Prop`/事件回调与外界通信,不直接依赖业务层。
- 页面:`@Entry @ComponentV2`,放 `pages/`,仅编排(组合 view + viewmodel,不含业务逻辑)。
- 业务复用组件:`@ComponentV2`,放 `view/`,通过 `@Param`(输入)/ `@Event`(回调)与外界通信,不直接依赖业务层。
- 跨模块通用组件:下沉到 `uikit/`(`uikit/src/main/ets/components/`)。
- 长列表复用用 `@Reusable`。
## 分层与依赖方向 Layering
`pages → viewmodel → service → model`。禁止反向依赖(如 `model` import `service`)。
`pages → viewmodel → data(repository/api) → model`。禁止反向依赖(如 `model` import `repository`)。
- `viewmodel` 只依赖 `data/repository` 的抽象契约;`repository` 实现依赖 `data/api` + `common` 网络层。
- 跨模块依赖:`entry → common / uikit`;`common`、`uikit` 相互独立,禁止反向/循环依赖。
架构越界由约定 + 人工审查把关(ArkTS 侧暂无现成的依赖方向 linter)。
## 安全 Security

View File

@@ -6,7 +6,7 @@
| 类型 Kind | 目录 Dir | 说明 Notes |
|----------|---------|-----------|
| 本地单元测试 Local unit | `entry/src/test/` | 不依赖设备,跑纯逻辑(service/model 等) |
| 本地单元测试 Local unit | `entry/src/test/` | 不依赖设备,跑纯逻辑(viewmodel/data/model 等) |
| 设备集成测试 Device test | `entry/src/ohosTest/` | 依赖真机/模拟器,跑 Ability/UI/系统能力 |
已有示例文件:`LocalUnit.test.ets`、`List.test.ets`(单测);`Ability.test.ets`、`List.test.ets`(设备测试)。
@@ -17,7 +17,7 @@
import { describe, it, expect } from '@ohos/hypium';
export default function exampleTest() {
describe('UserService', () => {
describe('UserRepository', () => {
it('should_return_trimmed_name', 0, () => {
expect(formatName(' alice ')).assertEqual('alice');
});
@@ -37,7 +37,7 @@ export default function exampleTest() {
## 约定 Conventions
- 每个 `service/`、`model/` 的新逻辑都要有对应的本地单测。
- 每个 `viewmodel/`、`data/repository/`、`model/` 的新逻辑都要有对应的本地单测。
- 涉及 UI/系统能力/Ability 生命周期 → 设备测试(`ohosTest/`)。
- mock 用 `@ohos/hamock`,不要为了测试改生产代码结构。
- 改动后至少跑受影响的测试再提交。

View File

@@ -0,0 +1,26 @@
{
"meta": {
"stableOrder": true,
"enableUnifiedLockfile": false
},
"lockfileVersion": 3,
"ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.",
"specifiers": {
"common@../common": "common@../common",
"uikit@../uikit": "uikit@../uikit"
},
"packages": {
"common@../common": {
"name": "common",
"version": "1.0.0",
"resolved": "../common",
"registryType": "local"
},
"uikit@../uikit": {
"name": "uikit",
"version": "1.0.0",
"resolved": "../uikit",
"registryType": "local"
}
}
}

View File

@@ -5,6 +5,9 @@
"main": "",
"author": "",
"license": "",
"dependencies": {}
"dependencies": {
"common": "file:../common",
"uikit": "file:../uikit"
}
}

View File

@@ -0,0 +1,33 @@
import { HttpClient, ApiResult, ApiError } from 'common';
/**
* 后端用户接口 DTO(与后端字段约定对齐)。
*/
export class UserDto {
id: string = '';
name: string = '';
email: string = '';
}
/**
* 用户相关网络请求服务:负责发请求、解析统一响应、返回 DTO。
* TODO: BASE_URL 应改为环境注入(debug/release 分离)。
*/
export class UserApi {
private static readonly BASE_URL: string = 'https://api.example.com';
private readonly http: HttpClient;
constructor(http: HttpClient) {
this.http = http;
}
async fetchUser(id: string): Promise<UserDto> {
const raw = await this.http.get(`${UserApi.BASE_URL}/api/user/${id}`);
const result = JSON.parse(raw) as ApiResult<UserDto>;
if (!result.isSuccess()) {
throw ApiError.business(result.code, result.message);
}
return result.data;
}
}

View File

@@ -0,0 +1,35 @@
import { User } from '../../model/User';
import { UserApi, UserDto } from '../api/UserApi';
/**
* 用户数据仓库契约(抽象类):ViewModel 只依赖此契约,便于替换 mock/真实实现。
* 注:用抽象类而非 interface,是为了在 ServiceLocator 中做 Object -> 契约 的强转时兼容 ArkTS。
*/
export abstract class UserRepository {
abstract getUser(id: string): Promise<User>;
}
/**
* 真实实现:调用 UserApi,做 DTO -> 领域模型映射。
*/
export class UserRepositoryImpl extends UserRepository {
private readonly api: UserApi;
constructor(api: UserApi) {
super();
this.api = api;
}
async getUser(id: string): Promise<User> {
const dto: UserDto = await this.api.fetchUser(id);
return UserRepositoryImpl.toDomain(dto);
}
private static toDomain(dto: UserDto): User {
const user = new User();
user.id = dto.id;
user.name = dto.name;
user.email = dto.email;
return user;
}
}

View File

@@ -0,0 +1,23 @@
/**
* 轻量服务定位器:ArkTS 无反射,用字符串 key + 显式类型转换做手动 DI。
* 约定:启动时在 EntryAbility.onCreate 中注册单例,业务侧通过 get() 解析后强转。
*/
export class ServiceLocator {
private static readonly services: Map<string, Object> = new Map<string, Object>();
static register(key: string, service: Object): void {
ServiceLocator.services.set(key, service);
}
static get(key: string): Object {
const service = ServiceLocator.services.get(key);
if (service === undefined) {
throw new Error(`[ServiceLocator] 未注册的服务: ${key}`);
}
return service;
}
static clear(): void {
ServiceLocator.services.clear();
}
}

View File

@@ -1,48 +1,62 @@
import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';
import { HttpClient, AuthInterceptor, SessionManager, Logger } from 'common';
import { ServiceLocator } from '../di/ServiceLocator';
import { UserApi } from '../data/api/UserApi';
import { UserRepositoryImpl } from '../data/repository/UserRepository';
const DOMAIN = 0x0000;
const TAG: string = 'EntryAbility';
export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
try {
this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
} catch (err) {
hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err));
Logger.error(TAG, `Failed to set colorMode: ${JSON.stringify(err)}`);
}
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
Logger.info(TAG, 'onCreate');
// 装配依赖:会话 -> 网络 -> 数据层
this.initDependencies();
}
private initDependencies(): void {
// 会话(持久化)
SessionManager.init();
// 网络层
const httpClient = new HttpClient();
httpClient.addInterceptor(new AuthInterceptor());
ServiceLocator.register('httpClient', httpClient);
// 数据层
const userApi = new UserApi(httpClient);
ServiceLocator.register('userRepository', new UserRepositoryImpl(userApi));
}
onDestroy(): void {
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onDestroy');
Logger.info(TAG, 'onDestroy');
}
onWindowStageCreate(windowStage: window.WindowStage): void {
// Main window is created, set main page for this ability
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate');
windowStage.loadContent('pages/Index', (err) => {
if (err.code) {
hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err));
Logger.error(TAG, `Failed to load content: ${JSON.stringify(err)}`);
return;
}
hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.');
Logger.info(TAG, 'Succeeded in loading the content.');
});
}
onWindowStageDestroy(): void {
// Main window is destroyed, release UI related resources
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageDestroy');
Logger.info(TAG, 'onWindowStageDestroy');
}
onForeground(): void {
// Ability has brought to foreground
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onForeground');
Logger.info(TAG, 'onForeground');
}
onBackground(): void {
// Ability has back to background
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground');
Logger.info(TAG, 'onBackground');
}
}
}

View File

@@ -0,0 +1,9 @@
/**
* 领域模型示例:@ObservedV2 + @Trace 实现字段级可观察。
*/
@ObservedV2
export class User {
@Trace id: string = '';
@Trace name: string = '';
@Trace email: string = '';
}

View File

@@ -0,0 +1,35 @@
import { AppColor, AppSpacing, AppFontSize, AppButton } from 'uikit';
import { AppRouter } from '../router/AppRouter';
/**
* 详情页:NavDestination 页面,演示 Navigation 路由与 @Param 入参。
*/
@ComponentV2
export struct DetailPage {
@Param title: string = '';
build() {
NavDestination() {
Column({ space: AppSpacing.md }) {
Text(this.title)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(AppColor.textPrimary)
Text('这是一个 NavDestination 页面,用于演示 Navigation 路由。')
.fontSize(AppFontSize.body)
.fontColor(AppColor.textSecondary)
AppButton({
text: '返回',
onTap: () => {
AppRouter.pop();
}
})
}
.width('100%')
.height('100%')
.padding(AppSpacing.lg)
.backgroundColor(AppColor.background)
}
.title('详情')
}
}

View File

@@ -1,23 +1,63 @@
import { AppColor, AppSpacing, AppButton, LoadingView, EmptyView } from 'uikit';
import { HomeViewModel } from '../viewmodel/HomeViewModel';
import { User } from '../model/User';
import { AppRouter } from '../router/AppRouter';
import { RouteMap, RouteNames } from '../router/RouteMap';
import { StatusCard } from '../view/StatusCard';
/**
* 首页(@Entry):Navigation 根容器,仅编排。
* 演示「页面 -> ViewModel -> Repository -> Api -> HttpClient」完整链路。
*/
@Entry
@Component
@ComponentV2
struct Index {
@State message: string = 'Hello World';
@Local pathStack: NavPathStack = new NavPathStack();
@Local viewModel: HomeViewModel = HomeViewModel.create();
@Local title: string = 'Harmony Client';
aboutToAppear(): void {
AppRouter.init(this.pathStack);
this.viewModel.load();
}
build() {
RelativeContainer() {
Text(this.message)
.id('HelloWorld')
.fontSize($r('app.float.page_text_font_size'))
.fontWeight(FontWeight.Bold)
.alignRules({
center: { anchor: '__container__', align: VerticalAlign.Center },
middle: { anchor: '__container__', align: HorizontalAlign.Center }
})
.onClick(() => {
this.message = 'Welcome';
Navigation(this.pathStack) {
Column({ space: AppSpacing.md }) {
Text(this.title)
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor(AppColor.textPrimary)
if (this.viewModel.loading) {
LoadingView({ text: '加载中...' })
} else if (this.viewModel.error.length > 0) {
EmptyView({ text: this.viewModel.error })
} else {
ForEach(this.viewModel.users, (user: User) => {
StatusCard({ title: user.name, subtitle: user.email })
}, (user: User) => user.id)
}
AppButton({
text: '进入详情页',
onTap: () => {
AppRouter.push(RouteNames.DETAIL, '来自首页的参数');
}
})
}
.width('100%')
.height('100%')
.padding(AppSpacing.lg)
.backgroundColor(AppColor.background)
}
.height('100%')
.width('100%')
.navDestination(this.routeMap)
.mode(NavigationMode.Stack)
.hideTitleBar(true)
}
}
@Builder
routeMap(name: string, param: Object) {
RouteMap(name, param)
}
}

View File

@@ -0,0 +1,30 @@
/**
* 应用级导航封装:持有全局 NavPathStack,页面通过静态方法导航。
*/
export class AppRouter {
private static stack: NavPathStack | null = null;
static init(stack: NavPathStack): void {
AppRouter.stack = stack;
}
static push(name: string, param?: Object): void {
AppRouter.stack?.pushPathByName(name, param);
}
static replace(name: string, param?: Object): void {
AppRouter.stack?.replacePathByName(name, param);
}
static pop(): void {
AppRouter.stack?.pop();
}
static popToName(name: string): void {
AppRouter.stack?.popToName(name);
}
static clear(): void {
AppRouter.stack?.clear();
}
}

View File

@@ -0,0 +1,18 @@
import { DetailPage } from '../pages/DetailPage';
/**
* 路由名常量。
*/
export class RouteNames {
static readonly DETAIL: string = 'detail';
}
/**
* 路由表:name -> 页面。由 Navigation.navDestination 调用分发。
*/
@Builder
export function RouteMap(name: string, param: Object) {
if (name === RouteNames.DETAIL) {
DetailPage({ title: param as string })
}
}

View File

@@ -0,0 +1,25 @@
import { AppColor, AppSpacing } from 'uikit';
/**
* 可复用信息卡片(示例):@ComponentV2 + @Param 输入。
*/
@ComponentV2
export struct StatusCard {
@Param title: string = '';
@Param subtitle: string = '';
build() {
Column({ space: AppSpacing.sm }) {
Text(this.title)
.fontSize(16)
.fontColor(AppColor.textPrimary)
Text(this.subtitle)
.fontSize(14)
.fontColor(AppColor.textSecondary)
}
.width('100%')
.padding(AppSpacing.md)
.backgroundColor(AppColor.white)
.borderRadius(8)
}
}

View File

@@ -0,0 +1,41 @@
import { User } from '../model/User';
import { UserRepository } from '../data/repository/UserRepository';
import { ServiceLocator } from '../di/ServiceLocator';
/**
* 首页视图模型:@ObservedV2 三态(loading / error / data)。
* 只依赖 UserRepository 契约,不直接接触网络层。
*/
@ObservedV2
export class HomeViewModel {
@Trace users: User[] = [];
@Trace loading: boolean = false;
@Trace error: string = '';
private readonly repository: UserRepository;
constructor(repository: UserRepository) {
this.repository = repository;
}
/**
* 工厂:从 DI 容器解析依赖并装配,避免页面直接触碰 data 层类型。
*/
static create(): HomeViewModel {
return new HomeViewModel(ServiceLocator.get('userRepository') as UserRepository);
}
async load(): Promise<void> {
this.loading = true;
this.error = '';
try {
// 演示:拉取一个示例用户;真实业务可替换为列表接口。
const user = await this.repository.getUser('demo-user');
this.users = [user];
} catch (err) {
this.error = JSON.stringify(err);
} finally {
this.loading = false;
}
}
}

View File

@@ -9,6 +9,11 @@
],
"deliveryWithInstall": true,
"installationFree": false,
"requestPermissions": [
{
"name": "ohos.permission.INTERNET"
}
],
"pages": "$profile:main_pages",
"abilities": [
{

17
uikit/BuildProfile.ets Normal file
View File

@@ -0,0 +1,17 @@
/**
* Use these variables when you tailor your ArkTS code. They must be of the const type.
*/
export const HAR_VERSION = '1.0.0';
export const BUILD_MODE_NAME = 'debug';
export const DEBUG = true;
export const TARGET_NAME = 'default';
/**
* BuildProfile Class is used only for compatibility purposes.
*/
export default class BuildProfile {
static readonly HAR_VERSION = HAR_VERSION;
static readonly BUILD_MODE_NAME = BUILD_MODE_NAME;
static readonly DEBUG = DEBUG;
static readonly TARGET_NAME = TARGET_NAME;
}

1
uikit/Index.ets Normal file
View File

@@ -0,0 +1 @@
export * from './src/main/ets/Index.ets';

10
uikit/build-profile.json5 Normal file
View File

@@ -0,0 +1,10 @@
{
"apiType": "stageMode",
"buildOption": {
},
"targets": [
{
"name": "default"
}
]
}

6
uikit/hvigorfile.ts Normal file
View File

@@ -0,0 +1,6 @@
import { harTasks } from '@ohos/hvigor-ohos-plugin';
export default {
system: harTasks, /* Built-in plugin of Hvigor. It cannot be modified. */
plugins: [] /* Custom plugin to extend the functionality of Hvigor. */
}

9
uikit/oh-package.json5 Normal file
View File

@@ -0,0 +1,9 @@
{
"name": "uikit",
"version": "1.0.0",
"description": "通用 UI 组件库与主题",
"main": "Index.ets",
"author": "",
"license": "Apache-2.0",
"dependencies": {}
}

View File

@@ -0,0 +1,4 @@
export { AppColor, AppSpacing, AppFontSize } from './theme/Theme';
export { AppButton, ButtonVariant } from './components/AppButton';
export { LoadingView } from './components/LoadingView';
export { EmptyView } from './components/EmptyView';

View File

@@ -0,0 +1,33 @@
import { AppColor } from '../theme/Theme';
export enum ButtonVariant {
Primary = 'primary',
Secondary = 'secondary'
}
/**
* 通用按钮:@ComponentV2 + @Param 输入 + @Event 输出回调。
*/
@ComponentV2
export struct AppButton {
@Param text: string = '';
@Param variant: ButtonVariant = ButtonVariant.Primary;
@Param disabled: boolean = false;
@Event onTap: () => void;
build() {
Button(this.text)
.width('100%')
.height(48)
.fontSize(16)
.fontColor(this.variant === ButtonVariant.Primary ? AppColor.white : AppColor.primary)
.backgroundColor(this.variant === ButtonVariant.Primary ? AppColor.primary : AppColor.white)
.borderRadius(8)
.enabled(!this.disabled)
.onClick(() => {
if (!this.disabled) {
this.onTap();
}
})
}
}

View File

@@ -0,0 +1,20 @@
import { AppColor, AppFontSize } from '../theme/Theme';
/**
* 通用空态组件。
*/
@ComponentV2
export struct EmptyView {
@Param text: string = '暂无数据';
build() {
Column({ space: 8 }) {
Text('暂无内容')
.fontSize(AppFontSize.headline)
.fontColor(AppColor.textSecondary)
Text(this.text)
.fontSize(AppFontSize.body)
.fontColor(AppColor.textSecondary)
}
}
}

View File

@@ -0,0 +1,21 @@
import { AppColor, AppFontSize } from '../theme/Theme';
/**
* 通用加载态组件。
*/
@ComponentV2
export struct LoadingView {
@Param text: string = '加载中...';
build() {
Column({ space: 8 }) {
LoadingProgress()
.width(32)
.height(32)
.color(AppColor.primary)
Text(this.text)
.fontSize(AppFontSize.body)
.fontColor(AppColor.textSecondary)
}
}
}

View File

@@ -0,0 +1,29 @@
/**
* 主题 token:颜色 / 间距 / 字号。
* 全 App 统一从这里取值,避免散落魔法值。
*/
export class AppColor {
static readonly primary: string = '#0A59F7';
static readonly primaryPressed: string = '#094CCB';
static readonly textPrimary: string = '#182431';
static readonly textSecondary: string = '#66182431';
static readonly background: string = '#F1F3F5';
static readonly white: string = '#FFFFFF';
static readonly divider: string = '#0D182431';
static readonly danger: string = '#E84026';
}
export class AppSpacing {
static readonly xs: number = 4;
static readonly sm: number = 8;
static readonly md: number = 16;
static readonly lg: number = 24;
static readonly xl: number = 32;
}
export class AppFontSize {
static readonly caption: number = 12;
static readonly body: number = 14;
static readonly title: number = 16;
static readonly headline: number = 20;
}

View File

@@ -0,0 +1,10 @@
{
"module": {
"name": "uikit",
"type": "har",
"description": "$string:module_desc",
"deviceTypes": [
"phone"
]
}
}

View File

@@ -0,0 +1,8 @@
{
"string": [
{
"name": "module_desc",
"value": "uikit module"
}
]
}