Compare commits
8 Commits
2dc02b44c7
...
1c98772253
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c98772253 | ||
|
|
9fb87d2d08 | ||
|
|
a91b4afdf2 | ||
|
|
d179073e53 | ||
|
|
058b978f25 | ||
|
|
3a116df871 | ||
|
|
d5e2d88c4c | ||
|
|
b73e74fa4a |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -10,3 +10,8 @@
|
|||||||
/.clang-tidy
|
/.clang-tidy
|
||||||
**/.test
|
**/.test
|
||||||
/.appanalyzer
|
/.appanalyzer
|
||||||
|
# 签名材料(个人 debug 证书,勿提交)
|
||||||
|
*.p12
|
||||||
|
*.cer
|
||||||
|
*.p7b
|
||||||
|
**/signing/
|
||||||
|
|||||||
39
AGENTS.md
39
AGENTS.md
@@ -13,8 +13,9 @@ HarmonyOS NEXT app. ArkTS + ArkUI declarative UI, hvigor build, DevEco Studio.
|
|||||||
|
|
||||||
## Architecture Overview
|
## Architecture Overview
|
||||||
|
|
||||||
单 HAP 模块应用(`entry/`)。页面在 `main_pages.json` 注册;`EntryAbility` 是应用入口 Ability。
|
单 HAP + 多 HAR 模块化应用:`entry/`(HAP,应用壳 + 业务)、`common/`(HAR,基础设施)、`uikit/`(HAR,通用 UI)。
|
||||||
Single-HAP module (`entry/`). Pages registered in `main_pages.json`; `EntryAbility` is the app entry.
|
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`。
|
详见 `docs/architecture.md`。
|
||||||
|
|
||||||
## Directory Structure
|
## Directory Structure
|
||||||
@@ -22,19 +23,27 @@ Single-HAP module (`entry/`). Pages registered in `main_pages.json`; `EntryAbili
|
|||||||
```
|
```
|
||||||
harmony-client/
|
harmony-client/
|
||||||
├── AppScope/ # 应用级配置(bundleName、应用图标/标签)
|
├── AppScope/ # 应用级配置(bundleName、应用图标/标签)
|
||||||
├── entry/ # 主模块(单 HAP)
|
├── entry/ # HAP 应用壳 + 业务(唯一入口)
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── main/
|
│ ├── main/
|
||||||
│ │ ├── ets/ # ArkTS 源码
|
│ │ ├── ets/
|
||||||
│ │ │ ├── entryability/ # 应用入口 Ability
|
│ │ │ ├── entryability/ # 应用入口 Ability(依赖装配)
|
||||||
│ │ │ ├── entrybackupability/ # 备份扩展 Ability
|
│ │ │ ├── entrybackupability/ # 备份扩展 Ability
|
||||||
│ │ │ └── pages/ # 页面(@Entry 组件)
|
│ │ │ ├── router/ # 路由(AppRouter + RouteMap)
|
||||||
│ │ ├── resources/ # 资源(字符串/颜色/媒体,用 $r() 引用)
|
│ │ │ ├── di/ # 依赖注入(ServiceLocator)
|
||||||
|
│ │ │ ├── model/ # 领域模型(@ObservedV2 + @Trace)
|
||||||
|
│ │ │ ├── data/ # 数据层(api / repository)
|
||||||
|
│ │ │ ├── viewmodel/ # 视图模型(@ObservedV2 三态)
|
||||||
|
│ │ │ ├── view/ # 业务复用组件
|
||||||
|
│ │ │ └── pages/ # 页面(@Entry @ComponentV2,仅编排)
|
||||||
|
│ │ ├── resources/ # 资源(字符串/媒体,用 $r() 引用)
|
||||||
│ │ └── module.json5 # 模块清单(Ability 声明、权限)
|
│ │ └── module.json5 # 模块清单(Ability 声明、权限)
|
||||||
│ ├── test/ # 本地单元测试(hypium)
|
│ ├── test/ # 本地单元测试(hypium)
|
||||||
│ ├── ohosTest/ # 设备集成测试(hypium)
|
│ ├── ohosTest/ # 设备集成测试(hypium)
|
||||||
│ └── mock/ # mock 配置
|
│ └── mock/ # mock 配置
|
||||||
├── build-profile.json5 # 构建/签名/产品配置
|
├── common/ # HAR 基础设施(网络/日志/状态/工具)
|
||||||
|
├── uikit/ # HAR 通用 UI 组件库 + 主题 token
|
||||||
|
├── build-profile.json5 # 构建/签名/产品/模块配置
|
||||||
├── code-linter.json5 # 代码规范(TS ESLint + 安全/性能规则)
|
├── code-linter.json5 # 代码规范(TS ESLint + 安全/性能规则)
|
||||||
├── hvigorfile.ts # hvigor 构建入口
|
├── hvigorfile.ts # hvigor 构建入口
|
||||||
├── oh-package.json5 # 依赖声明(hypium / hamock)
|
├── oh-package.json5 # 依赖声明(hypium / hamock)
|
||||||
@@ -48,10 +57,10 @@ harmony-client/
|
|||||||
2. **声明式 UI Declarative UI** — 用状态驱动渲染,禁止命令式直接改 UI。
|
2. **声明式 UI Declarative UI** — 用状态驱动渲染,禁止命令式直接改 UI。
|
||||||
Drive UI via state; never mutate UI imperatively.
|
Drive UI via state; never mutate UI imperatively.
|
||||||
3. **命名 Naming** — 组件/Ability `PascalCase`(文件 `.ets`);变量/函数/属性 `camelCase`;常量 `UPPER_SNAKE_CASE`。
|
3. **命名 Naming** — 组件/Ability `PascalCase`(文件 `.ets`);变量/函数/属性 `camelCase`;常量 `UPPER_SNAKE_CASE`。
|
||||||
4. **资源 Resources** — 字符串/颜色/媒体一律 `$r('app.xxx')`,禁止硬编码。
|
4. **资源 Resources** — 颜色/间距/字号走 `uikit/theme/Theme.ets` 的 token;用户可见文案优先 `$r('app.string.xxx')`;媒体 `$r('app.media.xxx')`。禁止组件内散落魔法值。
|
||||||
All strings/colors/media via `$r('app.xxx')`; no hardcoding.
|
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** — 页面只做 UI;业务逻辑放 `service/`,数据模型放 `model/`,复用组件放 `components/`。
|
5. **分层 Layering** — 页面只编排;业务逻辑放 `viewmodel/`,数据放 `data/`(repository/api),模型放 `model/`,复用组件放 `view/`(通用下沉 `uikit/`)。
|
||||||
Pages = UI only; logic → `service/`, models → `model/`, reusable components → `components/`.
|
Pages orchestrate only; logic → `viewmodel/`, data → `data/`, models → `model/`, reusable components → `view/` (shared → `uikit/`).
|
||||||
6. **安全 Security** — 遵守 `code-linter.json5` 的 `@security/*` 规则(禁用不安全哈希/AES 等)。
|
6. **安全 Security** — 遵守 `code-linter.json5` 的 `@security/*` 规则(禁用不安全哈希/AES 等)。
|
||||||
7. **改后必验证 Verify after change** — 每次改动跑 lint + 相关测试,见 `docs/testing.md`。
|
7. **改后必验证 Verify after change** — 每次改动跑 lint + 相关测试,见 `docs/testing.md`。
|
||||||
|
|
||||||
@@ -66,9 +75,9 @@ harmony-client/
|
|||||||
|
|
||||||
## Common Tasks
|
## Common Tasks
|
||||||
|
|
||||||
- 加页面 Add a page → `entry/src/main/ets/pages/` + 在 `main_pages.json` 注册
|
- 加页面 Add a page → `entry/src/main/ets/pages/`(Navigation 路由则加 `router/RouteMap.ets`)
|
||||||
- 加复用组件 Reusable component → `entry/src/main/ets/components/`
|
- 加复用组件 Reusable component → `entry/src/main/ets/view/`(跨模块通用 → `uikit/src/main/ets/components/`)
|
||||||
- 写业务逻辑 Business logic → `entry/src/main/ets/service/`
|
- 写业务逻辑 Business logic → `entry/src/main/ets/viewmodel/` + `entry/src/main/ets/data/`
|
||||||
- 加数据模型 Data model → `entry/src/main/ets/model/`
|
- 加数据模型 Data model → `entry/src/main/ets/model/`
|
||||||
- 跑单测 Run tests → `docs/testing.md`
|
- 跑单测 Run tests → `docs/testing.md`
|
||||||
|
|
||||||
|
|||||||
@@ -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
17
common/BuildProfile.ets
Normal 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
1
common/Index.ets
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './src/main/ets/Index.ets';
|
||||||
10
common/build-profile.json5
Normal file
10
common/build-profile.json5
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"apiType": "stageMode",
|
||||||
|
"buildOption": {
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"name": "default"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
6
common/hvigorfile.ts
Normal file
6
common/hvigorfile.ts
Normal 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
9
common/oh-package.json5
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "common",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "基础设施层:网络 / 日志 / 状态 / 工具",
|
||||||
|
"main": "Index.ets",
|
||||||
|
"author": "",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {}
|
||||||
|
}
|
||||||
8
common/src/main/ets/Index.ets
Normal file
8
common/src/main/ets/Index.ets
Normal 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';
|
||||||
24
common/src/main/ets/logging/Logger.ets
Normal file
24
common/src/main/ets/logging/Logger.ets
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
common/src/main/ets/network/ApiError.ets
Normal file
26
common/src/main/ets/network/ApiError.ets
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
common/src/main/ets/network/ApiResult.ets
Normal file
18
common/src/main/ets/network/ApiResult.ets
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
common/src/main/ets/network/AuthInterceptor.ets
Normal file
24
common/src/main/ets/network/AuthInterceptor.ets
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
65
common/src/main/ets/network/HttpClient.ets
Normal file
65
common/src/main/ets/network/HttpClient.ets
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
common/src/main/ets/network/HttpInterceptor.ets
Normal file
10
common/src/main/ets/network/HttpInterceptor.ets
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* 请求/响应拦截器接口。
|
||||||
|
* 实现类可注入请求头(鉴权)、统一处理响应(401/错误码)等。
|
||||||
|
*/
|
||||||
|
export interface HttpInterceptor {
|
||||||
|
/** 请求发出前,返回(可修改的)请求头。 */
|
||||||
|
onRequest(header: Record<string, string>): Record<string, string>;
|
||||||
|
/** 响应返回后,可据此抛错中断(如 401 触发登出)。 */
|
||||||
|
onResponse(responseCode: number, result: string): void;
|
||||||
|
}
|
||||||
56
common/src/main/ets/state/SessionStore.ets
Normal file
56
common/src/main/ets/state/SessionStore.ets
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
common/src/main/ets/utils/FormatUtil.ets
Normal file
15
common/src/main/ets/utils/FormatUtil.ets
Normal 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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
common/src/main/module.json5
Normal file
10
common/src/main/module.json5
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"module": {
|
||||||
|
"name": "common",
|
||||||
|
"type": "har",
|
||||||
|
"description": "$string:module_desc",
|
||||||
|
"deviceTypes": [
|
||||||
|
"phone"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
8
common/src/main/resources/base/element/string.json
Normal file
8
common/src/main/resources/base/element/string.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"string": [
|
||||||
|
{
|
||||||
|
"name": "module_desc",
|
||||||
|
"value": "common module"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,58 +1,209 @@
|
|||||||
# Architecture(架构)
|
# HarmonyOS NEXT 客户端 · 架构设计文档
|
||||||
|
|
||||||
HarmonyOS NEXT 单 HAP 应用,API 22(HarmonyOS 6.0)。技术栈:ArkTS + ArkUI 声明式 UI + hvigor。
|
> 项目:`harmony-client`(ArkTS + ArkUI,SDK 6.0.2 / API 22,仅 phone)
|
||||||
Single-HAP HarmonyOS NEXT app, API 22 (HarmonyOS 6.0). Stack: ArkTS + ArkUI declarative UI + hvigor.
|
> 业务形态:对接自有后端的通用业务客户端(HTTP + 鉴权 + CRUD)
|
||||||
|
|
||||||
## 模块结构 Module Structure
|
---
|
||||||
|
|
||||||
- **AppScope/** — 应用级信息:`app.json5` 里定义 `bundleName`(`com.mingyuan.harmony_client`)、应用图标/标签(`$media:layered_image` / `$string:app_name`)。
|
## 1. 背景与目标
|
||||||
- **entry/** — 唯一主模块(HAP)。`module.json5` 声明 Ability、权限、入口。
|
|
||||||
|
|
||||||
## Ability 生命周期 Ability Lifecycle
|
本工程从 DevEco 空脚手架起步,目标是建立一套**可持续演进、可测试、低耦合**的客户端架构骨架,为后续业务开发打底。设计依据来自一轮 deep-research 调研(交叉验证华为官方文档与社区实践,9 条结论全部通过对抗式验证)。
|
||||||
|
|
||||||
**EntryAbility**(`entry/src/main/ets/entryability/EntryAbility.ets`)是应用入口,继承 `UIAbility`。关键回调:
|
核心决策:
|
||||||
|
|
||||||
| 回调 Callback | 用途 Purpose |
|
| 决策点 | 选择 | 理由 |
|
||||||
|--------------|-------------|
|
|---|---|---|
|
||||||
| `onCreate` | Ability 创建,初始化状态(**此时不要做 UI 操作**) |
|
| 工程结构 | 官方三层 commons / features / products | 单向依赖、官方白皮书推荐 |
|
||||||
| `onWindowStageCreate` | 窗口创建,在这里 `loadContent('pages/Index')` 加载首页 |
|
| 模块化 | **单 HAP + 多 HAR** | HAR 编译期打包、可裁剪;HSP 仅按需加载 |
|
||||||
| `onForeground` / `onBackground` | 前后台切换 |
|
| 代码分层 | **MVVM + 独立 data 层** | 官方指南 + 网络/数据解耦 |
|
||||||
| `onDestroy` | 销毁,释放资源 |
|
| 状态管理 | **全量 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/
|
entry/src/main/ets/
|
||||||
├── entryability/ # Ability 入口(生命周期)
|
├── entryability/
|
||||||
├── entrybackupability/ # 备份扩展
|
│ └── EntryAbility.ets # 生命周期装配:Logger / ServiceLocator / SessionStore
|
||||||
├── pages/ # 页面(@Entry 组件,只做 UI 编排)
|
├── router/
|
||||||
├── components/ # 可复用 UI 组件(@Component / @Reusable)
|
│ ├── RouteMap.ets # 路由名常量 + name→页面 分发
|
||||||
├── viewmodel/ # 状态管理(连接 model 与 UI)
|
│ └── AppRouter.ets # Navigation 导航封装(push/replace/pop/...)
|
||||||
├── service/ # 业务逻辑、数据访问(网络/存储)
|
├── di/
|
||||||
├── model/ # 数据模型、类型定义
|
│ └── ServiceLocator.ets # 轻量服务定位器(ArkTS 无反射,手动注册/解析)
|
||||||
└── common/ # 公共常量、工具函数
|
├── 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)。
|
||||||
|
|||||||
@@ -17,6 +17,20 @@ hvigorw assembleHap --mode module -p product=default -p buildMode=release
|
|||||||
|
|
||||||
产物通常在 `entry/build/default/outputs/` 下(`default` 为 product 名)。
|
产物通常在 `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
|
## 签名 Signing
|
||||||
|
|
||||||
`build-profile.json5` 里 `signingConfigs` 目前为**空**,且 `products[].signingConfig` 引用了 `"default"` —— **签名尚未配置**。
|
`build-profile.json5` 里 `signingConfigs` 目前为**空**,且 `products[].signingConfig` 引用了 `"default"` —— **签名尚未配置**。
|
||||||
|
|||||||
@@ -21,25 +21,33 @@ ArkTS(TypeScript 的超集,HarmonyOS 严格类型方言)+ ArkUI 声明式
|
|||||||
## 声明式 UI 与状态 Declarative UI & State
|
## 声明式 UI 与状态 Declarative UI & State
|
||||||
|
|
||||||
- UI 由状态驱动:改状态 → 自动重渲染,禁止命令式直接改 UI。
|
- UI 由状态驱动:改状态 → 自动重渲染,禁止命令式直接改 UI。
|
||||||
- 状态装饰器选择:
|
- 状态管理**全量使用 StateManagement V2**(禁用 V1 装饰器):
|
||||||
- 局部状态 `@State`
|
- 组件/页面 `@ComponentV2`
|
||||||
- 父子传递 `@Prop`(单向)/ `@Link`(双向)
|
- 局部状态 `@Local`
|
||||||
- 跨层 `@Provide` / `@Consume`
|
- 父子传递 `@Param`(单向输入)/ `@Event`(子→父回调,**不用 `@Link`**)
|
||||||
- 避免不必要的重渲染:状态粒度要细,不要一个 `@State` 装一整个大对象。
|
- 类可观察 `@ObservedV2` + `@Trace`(字段级精准刷新)
|
||||||
|
- 跨层共享 `@Provider` / `@Consumer`;变化监听 `@Monitor`
|
||||||
|
- 全局/持久化 `AppStorageV2` / `PersistenceV2`
|
||||||
|
- 避免不必要的重渲染:`@Trace` 字段粒度要细,不要一个大对象整体刷新。
|
||||||
|
|
||||||
## 资源 Resources
|
## 资源 Resources
|
||||||
|
|
||||||
- 字符串、颜色、媒体一律 `$r('app.xxx')`(定义在 `resources/base/element/`)。
|
- 颜色 / 间距 / 字号统一走 `uikit/theme/Theme.ets` 的 token(`AppColor` / `AppSpacing` / `AppFontSize`),禁止在组件里散落颜色/尺寸魔法值。
|
||||||
- 禁止在 `.ets` 里硬编码字符串、颜色值、路径。
|
- 用户可见文案:优先 `$r('app.string.xxx')`(便于国际化);临时/演示文案可内联,正式功能应收口到资源。
|
||||||
|
- 图片/图标等媒体:`$r('app.media.xxx')`。
|
||||||
|
|
||||||
## 组件化 Components
|
## 组件化 Components
|
||||||
|
|
||||||
- 页面:`@Entry` 组件,放 `pages/`。
|
- 页面:`@Entry @ComponentV2`,放 `pages/`,仅编排(组合 view + viewmodel,不含业务逻辑)。
|
||||||
- 复用组件:`@Component` 或 `@Reusable`(长列表复用),放 `components/`,通过 `@Prop`/事件回调与外界通信,不直接依赖业务层。
|
- 业务复用组件:`@ComponentV2`,放 `view/`,通过 `@Param`(输入)/ `@Event`(回调)与外界通信,不直接依赖业务层。
|
||||||
|
- 跨模块通用组件:下沉到 `uikit/`(`uikit/src/main/ets/components/`)。
|
||||||
|
- 长列表复用用 `@Reusable`。
|
||||||
|
|
||||||
## 分层与依赖方向 Layering
|
## 分层与依赖方向 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)。
|
架构越界由约定 + 人工审查把关(ArkTS 侧暂无现成的依赖方向 linter)。
|
||||||
|
|
||||||
## 安全 Security
|
## 安全 Security
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
| 类型 Kind | 目录 Dir | 说明 Notes |
|
| 类型 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/系统能力 |
|
| 设备集成测试 Device test | `entry/src/ohosTest/` | 依赖真机/模拟器,跑 Ability/UI/系统能力 |
|
||||||
|
|
||||||
已有示例文件:`LocalUnit.test.ets`、`List.test.ets`(单测);`Ability.test.ets`、`List.test.ets`(设备测试)。
|
已有示例文件:`LocalUnit.test.ets`、`List.test.ets`(单测);`Ability.test.ets`、`List.test.ets`(设备测试)。
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
import { describe, it, expect } from '@ohos/hypium';
|
import { describe, it, expect } from '@ohos/hypium';
|
||||||
|
|
||||||
export default function exampleTest() {
|
export default function exampleTest() {
|
||||||
describe('UserService', () => {
|
describe('UserRepository', () => {
|
||||||
it('should_return_trimmed_name', 0, () => {
|
it('should_return_trimmed_name', 0, () => {
|
||||||
expect(formatName(' alice ')).assertEqual('alice');
|
expect(formatName(' alice ')).assertEqual('alice');
|
||||||
});
|
});
|
||||||
@@ -37,7 +37,7 @@ export default function exampleTest() {
|
|||||||
|
|
||||||
## 约定 Conventions
|
## 约定 Conventions
|
||||||
|
|
||||||
- 每个 `service/`、`model/` 的新逻辑都要有对应的本地单测。
|
- 每个 `viewmodel/`、`data/repository/`、`model/` 的新逻辑都要有对应的本地单测。
|
||||||
- 涉及 UI/系统能力/Ability 生命周期 → 设备测试(`ohosTest/`)。
|
- 涉及 UI/系统能力/Ability 生命周期 → 设备测试(`ohosTest/`)。
|
||||||
- mock 用 `@ohos/hamock`,不要为了测试改生产代码结构。
|
- mock 用 `@ohos/hamock`,不要为了测试改生产代码结构。
|
||||||
- 改动后至少跑受影响的测试再提交。
|
- 改动后至少跑受影响的测试再提交。
|
||||||
|
|||||||
26
entry/oh-package-lock.json5
Normal file
26
entry/oh-package-lock.json5
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@
|
|||||||
"main": "",
|
"main": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "",
|
"license": "",
|
||||||
"dependencies": {}
|
"dependencies": {
|
||||||
|
"common": "file:../common",
|
||||||
|
"uikit": "file:../uikit"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
33
entry/src/main/ets/data/api/UserApi.ets
Normal file
33
entry/src/main/ets/data/api/UserApi.ets
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
entry/src/main/ets/data/repository/UserRepository.ets
Normal file
35
entry/src/main/ets/data/repository/UserRepository.ets
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
entry/src/main/ets/di/ServiceLocator.ets
Normal file
23
entry/src/main/ets/di/ServiceLocator.ets
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,48 +1,62 @@
|
|||||||
import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit';
|
import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit';
|
||||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
|
||||||
import { window } from '@kit.ArkUI';
|
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 {
|
export default class EntryAbility extends UIAbility {
|
||||||
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
|
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
|
||||||
try {
|
try {
|
||||||
this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
|
this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
|
||||||
} catch (err) {
|
} 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 {
|
onDestroy(): void {
|
||||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onDestroy');
|
Logger.info(TAG, 'onDestroy');
|
||||||
}
|
}
|
||||||
|
|
||||||
onWindowStageCreate(windowStage: window.WindowStage): void {
|
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) => {
|
windowStage.loadContent('pages/Index', (err) => {
|
||||||
if (err.code) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.');
|
Logger.info(TAG, 'Succeeded in loading the content.');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onWindowStageDestroy(): void {
|
onWindowStageDestroy(): void {
|
||||||
// Main window is destroyed, release UI related resources
|
Logger.info(TAG, 'onWindowStageDestroy');
|
||||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageDestroy');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onForeground(): void {
|
onForeground(): void {
|
||||||
// Ability has brought to foreground
|
Logger.info(TAG, 'onForeground');
|
||||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onForeground');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onBackground(): void {
|
onBackground(): void {
|
||||||
// Ability has back to background
|
Logger.info(TAG, 'onBackground');
|
||||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
9
entry/src/main/ets/model/User.ets
Normal file
9
entry/src/main/ets/model/User.ets
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* 领域模型示例:@ObservedV2 + @Trace 实现字段级可观察。
|
||||||
|
*/
|
||||||
|
@ObservedV2
|
||||||
|
export class User {
|
||||||
|
@Trace id: string = '';
|
||||||
|
@Trace name: string = '';
|
||||||
|
@Trace email: string = '';
|
||||||
|
}
|
||||||
35
entry/src/main/ets/pages/DetailPage.ets
Normal file
35
entry/src/main/ets/pages/DetailPage.ets
Normal 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('详情')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
@Entry
|
||||||
@Component
|
@ComponentV2
|
||||||
struct Index {
|
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() {
|
build() {
|
||||||
RelativeContainer() {
|
Navigation(this.pathStack) {
|
||||||
Text(this.message)
|
Column({ space: AppSpacing.md }) {
|
||||||
.id('HelloWorld')
|
Text(this.title)
|
||||||
.fontSize($r('app.float.page_text_font_size'))
|
.fontSize(24)
|
||||||
.fontWeight(FontWeight.Bold)
|
.fontWeight(FontWeight.Bold)
|
||||||
.alignRules({
|
.fontColor(AppColor.textPrimary)
|
||||||
center: { anchor: '__container__', align: VerticalAlign.Center },
|
|
||||||
middle: { anchor: '__container__', align: HorizontalAlign.Center }
|
if (this.viewModel.loading) {
|
||||||
})
|
LoadingView({ text: '加载中...' })
|
||||||
.onClick(() => {
|
} else if (this.viewModel.error.length > 0) {
|
||||||
this.message = 'Welcome';
|
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, '来自首页的参数');
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
.height('100%')
|
|
||||||
.width('100%')
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.padding(AppSpacing.lg)
|
||||||
|
.backgroundColor(AppColor.background)
|
||||||
|
}
|
||||||
|
.navDestination(this.routeMap)
|
||||||
|
.mode(NavigationMode.Stack)
|
||||||
|
.hideTitleBar(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
routeMap(name: string, param: Object) {
|
||||||
|
RouteMap(name, param)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
30
entry/src/main/ets/router/AppRouter.ets
Normal file
30
entry/src/main/ets/router/AppRouter.ets
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
18
entry/src/main/ets/router/RouteMap.ets
Normal file
18
entry/src/main/ets/router/RouteMap.ets
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
25
entry/src/main/ets/view/StatusCard.ets
Normal file
25
entry/src/main/ets/view/StatusCard.ets
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
41
entry/src/main/ets/viewmodel/HomeViewModel.ets
Normal file
41
entry/src/main/ets/viewmodel/HomeViewModel.ets
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,11 @@
|
|||||||
],
|
],
|
||||||
"deliveryWithInstall": true,
|
"deliveryWithInstall": true,
|
||||||
"installationFree": false,
|
"installationFree": false,
|
||||||
|
"requestPermissions": [
|
||||||
|
{
|
||||||
|
"name": "ohos.permission.INTERNET"
|
||||||
|
}
|
||||||
|
],
|
||||||
"pages": "$profile:main_pages",
|
"pages": "$profile:main_pages",
|
||||||
"abilities": [
|
"abilities": [
|
||||||
{
|
{
|
||||||
|
|||||||
17
uikit/BuildProfile.ets
Normal file
17
uikit/BuildProfile.ets
Normal 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
1
uikit/Index.ets
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './src/main/ets/Index.ets';
|
||||||
10
uikit/build-profile.json5
Normal file
10
uikit/build-profile.json5
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"apiType": "stageMode",
|
||||||
|
"buildOption": {
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"name": "default"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
6
uikit/hvigorfile.ts
Normal file
6
uikit/hvigorfile.ts
Normal 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
9
uikit/oh-package.json5
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "uikit",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "通用 UI 组件库与主题",
|
||||||
|
"main": "Index.ets",
|
||||||
|
"author": "",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {}
|
||||||
|
}
|
||||||
4
uikit/src/main/ets/Index.ets
Normal file
4
uikit/src/main/ets/Index.ets
Normal 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';
|
||||||
33
uikit/src/main/ets/components/AppButton.ets
Normal file
33
uikit/src/main/ets/components/AppButton.ets
Normal 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();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
20
uikit/src/main/ets/components/EmptyView.ets
Normal file
20
uikit/src/main/ets/components/EmptyView.ets
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
uikit/src/main/ets/components/LoadingView.ets
Normal file
21
uikit/src/main/ets/components/LoadingView.ets
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
uikit/src/main/ets/theme/Theme.ets
Normal file
29
uikit/src/main/ets/theme/Theme.ets
Normal 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;
|
||||||
|
}
|
||||||
10
uikit/src/main/module.json5
Normal file
10
uikit/src/main/module.json5
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"module": {
|
||||||
|
"name": "uikit",
|
||||||
|
"type": "har",
|
||||||
|
"description": "$string:module_desc",
|
||||||
|
"deviceTypes": [
|
||||||
|
"phone"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
8
uikit/src/main/resources/base/element/string.json
Normal file
8
uikit/src/main/resources/base/element/string.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"string": [
|
||||||
|
{
|
||||||
|
"name": "module_desc",
|
||||||
|
"value": "uikit module"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user