From b73e74fa4a4a801b8c9353e7aa0ea2a832436626 Mon Sep 17 00:00:00 2001 From: AndyChen1024 Date: Fri, 11 Sep 2026 16:22:45 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(common):=20=E6=96=B0=E5=A2=9E=E5=9F=BA?= =?UTF-8?q?=E7=A1=80=E8=AE=BE=E6=96=BD=20HAR=EF=BC=88=E7=BD=91=E7=BB=9C/?= =?UTF-8?q?=E9=89=B4=E6=9D=83/=E6=97=A5=E5=BF=97/=E7=8A=B6=E6=80=81/?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/BuildProfile.ets | 17 +++++ common/Index.ets | 1 + common/build-profile.json5 | 10 +++ common/hvigorfile.ts | 6 ++ common/oh-package.json5 | 9 +++ common/src/main/ets/Index.ets | 8 +++ common/src/main/ets/logging/Logger.ets | 24 +++++++ common/src/main/ets/network/ApiError.ets | 26 ++++++++ common/src/main/ets/network/ApiResult.ets | 18 +++++ .../src/main/ets/network/AuthInterceptor.ets | 24 +++++++ common/src/main/ets/network/HttpClient.ets | 65 +++++++++++++++++++ .../src/main/ets/network/HttpInterceptor.ets | 10 +++ common/src/main/ets/state/SessionStore.ets | 56 ++++++++++++++++ common/src/main/ets/utils/FormatUtil.ets | 15 +++++ common/src/main/module.json5 | 10 +++ .../main/resources/base/element/string.json | 8 +++ 16 files changed, 307 insertions(+) create mode 100644 common/BuildProfile.ets create mode 100644 common/Index.ets create mode 100644 common/build-profile.json5 create mode 100644 common/hvigorfile.ts create mode 100644 common/oh-package.json5 create mode 100644 common/src/main/ets/Index.ets create mode 100644 common/src/main/ets/logging/Logger.ets create mode 100644 common/src/main/ets/network/ApiError.ets create mode 100644 common/src/main/ets/network/ApiResult.ets create mode 100644 common/src/main/ets/network/AuthInterceptor.ets create mode 100644 common/src/main/ets/network/HttpClient.ets create mode 100644 common/src/main/ets/network/HttpInterceptor.ets create mode 100644 common/src/main/ets/state/SessionStore.ets create mode 100644 common/src/main/ets/utils/FormatUtil.ets create mode 100644 common/src/main/module.json5 create mode 100644 common/src/main/resources/base/element/string.json diff --git a/common/BuildProfile.ets b/common/BuildProfile.ets new file mode 100644 index 0000000..3a501e5 --- /dev/null +++ b/common/BuildProfile.ets @@ -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; +} \ No newline at end of file diff --git a/common/Index.ets b/common/Index.ets new file mode 100644 index 0000000..64530f6 --- /dev/null +++ b/common/Index.ets @@ -0,0 +1 @@ +export * from './src/main/ets/Index.ets'; diff --git a/common/build-profile.json5 b/common/build-profile.json5 new file mode 100644 index 0000000..79961f9 --- /dev/null +++ b/common/build-profile.json5 @@ -0,0 +1,10 @@ +{ + "apiType": "stageMode", + "buildOption": { + }, + "targets": [ + { + "name": "default" + } + ] +} diff --git a/common/hvigorfile.ts b/common/hvigorfile.ts new file mode 100644 index 0000000..08f99a1 --- /dev/null +++ b/common/hvigorfile.ts @@ -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. */ +} diff --git a/common/oh-package.json5 b/common/oh-package.json5 new file mode 100644 index 0000000..16ba476 --- /dev/null +++ b/common/oh-package.json5 @@ -0,0 +1,9 @@ +{ + "name": "common", + "version": "1.0.0", + "description": "基础设施层:网络 / 日志 / 状态 / 工具", + "main": "Index.ets", + "author": "", + "license": "Apache-2.0", + "dependencies": {} +} diff --git a/common/src/main/ets/Index.ets b/common/src/main/ets/Index.ets new file mode 100644 index 0000000..48bf07e --- /dev/null +++ b/common/src/main/ets/Index.ets @@ -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'; diff --git a/common/src/main/ets/logging/Logger.ets b/common/src/main/ets/logging/Logger.ets new file mode 100644 index 0000000..8a8d792 --- /dev/null +++ b/common/src/main/ets/logging/Logger.ets @@ -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); + } +} diff --git a/common/src/main/ets/network/ApiError.ets b/common/src/main/ets/network/ApiError.ets new file mode 100644 index 0000000..e94ee73 --- /dev/null +++ b/common/src/main/ets/network/ApiError.ets @@ -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); + } +} diff --git a/common/src/main/ets/network/ApiResult.ets b/common/src/main/ets/network/ApiResult.ets new file mode 100644 index 0000000..5772e39 --- /dev/null +++ b/common/src/main/ets/network/ApiResult.ets @@ -0,0 +1,18 @@ +/** + * 统一响应包装:与后端约定 code / message / data 结构。 + */ +export class ApiResult { + 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; + } +} diff --git a/common/src/main/ets/network/AuthInterceptor.ets b/common/src/main/ets/network/AuthInterceptor.ets new file mode 100644 index 0000000..715f90e --- /dev/null +++ b/common/src/main/ets/network/AuthInterceptor.ets @@ -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): Record { + 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(); + } + } +} diff --git a/common/src/main/ets/network/HttpClient.ets b/common/src/main/ets/network/HttpClient.ets new file mode 100644 index 0000000..c2a4c84 --- /dev/null +++ b/common/src/main/ets/network/HttpClient.ets @@ -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): Promise { + return this.request(url, http.RequestMethod.GET, undefined, header); + } + + async post(url: string, body: string, header?: Record): Promise { + return this.request(url, http.RequestMethod.POST, body, header); + } + + async put(url: string, body: string, header?: Record): Promise { + return this.request(url, http.RequestMethod.PUT, body, header); + } + + async delete(url: string, header?: Record): Promise { + return this.request(url, http.RequestMethod.DELETE, undefined, header); + } + + private async request(url: string, method: http.RequestMethod, body?: string, + header?: Record): Promise { + let headers: Record = {}; + 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)); + } + } +} diff --git a/common/src/main/ets/network/HttpInterceptor.ets b/common/src/main/ets/network/HttpInterceptor.ets new file mode 100644 index 0000000..acb9978 --- /dev/null +++ b/common/src/main/ets/network/HttpInterceptor.ets @@ -0,0 +1,10 @@ +/** + * 请求/响应拦截器接口。 + * 实现类可注入请求头(鉴权)、统一处理响应(401/错误码)等。 + */ +export interface HttpInterceptor { + /** 请求发出前,返回(可修改的)请求头。 */ + onRequest(header: Record): Record; + /** 响应返回后,可据此抛错中断(如 401 触发登出)。 */ + onResponse(responseCode: number, result: string): void; +} diff --git a/common/src/main/ets/state/SessionStore.ets b/common/src/main/ets/state/SessionStore.ets new file mode 100644 index 0000000..4ef336a --- /dev/null +++ b/common/src/main/ets/state/SessionStore.ets @@ -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; + } +} diff --git a/common/src/main/ets/utils/FormatUtil.ets b/common/src/main/ets/utils/FormatUtil.ets new file mode 100644 index 0000000..052a0af --- /dev/null +++ b/common/src/main/ets/utils/FormatUtil.ets @@ -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}`; + } +} diff --git a/common/src/main/module.json5 b/common/src/main/module.json5 new file mode 100644 index 0000000..0cb5da6 --- /dev/null +++ b/common/src/main/module.json5 @@ -0,0 +1,10 @@ +{ + "module": { + "name": "common", + "type": "har", + "description": "$string:module_desc", + "deviceTypes": [ + "phone" + ] + } +} diff --git a/common/src/main/resources/base/element/string.json b/common/src/main/resources/base/element/string.json new file mode 100644 index 0000000..3e3e3c6 --- /dev/null +++ b/common/src/main/resources/base/element/string.json @@ -0,0 +1,8 @@ +{ + "string": [ + { + "name": "module_desc", + "value": "common module" + } + ] +} From d5e2d88c4c800c27ef0dac7a78875f166050b19a Mon Sep 17 00:00:00 2001 From: AndyChen1024 Date: Fri, 11 Sep 2026 16:22:45 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat(uikit):=20=E6=96=B0=E5=A2=9E=E9=80=9A?= =?UTF-8?q?=E7=94=A8=20UI=20=E7=BB=84=E4=BB=B6=E5=BA=93=E4=B8=8E=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- uikit/BuildProfile.ets | 17 ++++++++++ uikit/Index.ets | 1 + uikit/build-profile.json5 | 10 ++++++ uikit/hvigorfile.ts | 6 ++++ uikit/oh-package.json5 | 9 +++++ uikit/src/main/ets/Index.ets | 4 +++ uikit/src/main/ets/components/AppButton.ets | 33 +++++++++++++++++++ uikit/src/main/ets/components/EmptyView.ets | 20 +++++++++++ uikit/src/main/ets/components/LoadingView.ets | 21 ++++++++++++ uikit/src/main/ets/theme/Theme.ets | 29 ++++++++++++++++ uikit/src/main/module.json5 | 10 ++++++ .../main/resources/base/element/string.json | 8 +++++ 12 files changed, 168 insertions(+) create mode 100644 uikit/BuildProfile.ets create mode 100644 uikit/Index.ets create mode 100644 uikit/build-profile.json5 create mode 100644 uikit/hvigorfile.ts create mode 100644 uikit/oh-package.json5 create mode 100644 uikit/src/main/ets/Index.ets create mode 100644 uikit/src/main/ets/components/AppButton.ets create mode 100644 uikit/src/main/ets/components/EmptyView.ets create mode 100644 uikit/src/main/ets/components/LoadingView.ets create mode 100644 uikit/src/main/ets/theme/Theme.ets create mode 100644 uikit/src/main/module.json5 create mode 100644 uikit/src/main/resources/base/element/string.json diff --git a/uikit/BuildProfile.ets b/uikit/BuildProfile.ets new file mode 100644 index 0000000..3a501e5 --- /dev/null +++ b/uikit/BuildProfile.ets @@ -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; +} \ No newline at end of file diff --git a/uikit/Index.ets b/uikit/Index.ets new file mode 100644 index 0000000..64530f6 --- /dev/null +++ b/uikit/Index.ets @@ -0,0 +1 @@ +export * from './src/main/ets/Index.ets'; diff --git a/uikit/build-profile.json5 b/uikit/build-profile.json5 new file mode 100644 index 0000000..79961f9 --- /dev/null +++ b/uikit/build-profile.json5 @@ -0,0 +1,10 @@ +{ + "apiType": "stageMode", + "buildOption": { + }, + "targets": [ + { + "name": "default" + } + ] +} diff --git a/uikit/hvigorfile.ts b/uikit/hvigorfile.ts new file mode 100644 index 0000000..08f99a1 --- /dev/null +++ b/uikit/hvigorfile.ts @@ -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. */ +} diff --git a/uikit/oh-package.json5 b/uikit/oh-package.json5 new file mode 100644 index 0000000..dfc7789 --- /dev/null +++ b/uikit/oh-package.json5 @@ -0,0 +1,9 @@ +{ + "name": "uikit", + "version": "1.0.0", + "description": "通用 UI 组件库与主题", + "main": "Index.ets", + "author": "", + "license": "Apache-2.0", + "dependencies": {} +} diff --git a/uikit/src/main/ets/Index.ets b/uikit/src/main/ets/Index.ets new file mode 100644 index 0000000..881651c --- /dev/null +++ b/uikit/src/main/ets/Index.ets @@ -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'; diff --git a/uikit/src/main/ets/components/AppButton.ets b/uikit/src/main/ets/components/AppButton.ets new file mode 100644 index 0000000..78ad60b --- /dev/null +++ b/uikit/src/main/ets/components/AppButton.ets @@ -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(); + } + }) + } +} diff --git a/uikit/src/main/ets/components/EmptyView.ets b/uikit/src/main/ets/components/EmptyView.ets new file mode 100644 index 0000000..eca7e45 --- /dev/null +++ b/uikit/src/main/ets/components/EmptyView.ets @@ -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) + } + } +} diff --git a/uikit/src/main/ets/components/LoadingView.ets b/uikit/src/main/ets/components/LoadingView.ets new file mode 100644 index 0000000..072184b --- /dev/null +++ b/uikit/src/main/ets/components/LoadingView.ets @@ -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) + } + } +} diff --git a/uikit/src/main/ets/theme/Theme.ets b/uikit/src/main/ets/theme/Theme.ets new file mode 100644 index 0000000..4733035 --- /dev/null +++ b/uikit/src/main/ets/theme/Theme.ets @@ -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; +} diff --git a/uikit/src/main/module.json5 b/uikit/src/main/module.json5 new file mode 100644 index 0000000..f3a79dd --- /dev/null +++ b/uikit/src/main/module.json5 @@ -0,0 +1,10 @@ +{ + "module": { + "name": "uikit", + "type": "har", + "description": "$string:module_desc", + "deviceTypes": [ + "phone" + ] + } +} diff --git a/uikit/src/main/resources/base/element/string.json b/uikit/src/main/resources/base/element/string.json new file mode 100644 index 0000000..6620c63 --- /dev/null +++ b/uikit/src/main/resources/base/element/string.json @@ -0,0 +1,8 @@ +{ + "string": [ + { + "name": "module_desc", + "value": "uikit module" + } + ] +} From 3a116df871c44920e1b12ef08c1b85500abed409 Mon Sep 17 00:00:00 2001 From: AndyChen1024 Date: Fri, 11 Sep 2026 16:22:45 +0800 Subject: [PATCH 3/6] =?UTF-8?q?chore(build):=20=E6=B3=A8=E5=86=8C=20common?= =?UTF-8?q?/uikit=20=E6=A8=A1=E5=9D=97=EF=BC=8C=E6=8E=A5=E7=BA=BF=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E4=B8=8E=20INTERNET=20=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build-profile.json5 | 24 ++++++++++++++++++++++++ entry/oh-package-lock.json5 | 26 ++++++++++++++++++++++++++ entry/oh-package.json5 | 5 ++++- entry/src/main/module.json5 | 5 +++++ 4 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 entry/oh-package-lock.json5 diff --git a/build-profile.json5 b/build-profile.json5 index 06966fe..e0d07e4 100644 --- a/build-profile.json5 +++ b/build-profile.json5 @@ -37,6 +37,30 @@ ] } ] + }, + { + "name": "common", + "srcPath": "./common", + "targets": [ + { + "name": "default", + "applyToProducts": [ + "default" + ] + } + ] + }, + { + "name": "uikit", + "srcPath": "./uikit", + "targets": [ + { + "name": "default", + "applyToProducts": [ + "default" + ] + } + ] } ] } \ No newline at end of file diff --git a/entry/oh-package-lock.json5 b/entry/oh-package-lock.json5 new file mode 100644 index 0000000..44a087b --- /dev/null +++ b/entry/oh-package-lock.json5 @@ -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" + } + } +} \ No newline at end of file diff --git a/entry/oh-package.json5 b/entry/oh-package.json5 index 248c3b7..74f7477 100644 --- a/entry/oh-package.json5 +++ b/entry/oh-package.json5 @@ -5,6 +5,9 @@ "main": "", "author": "", "license": "", - "dependencies": {} + "dependencies": { + "common": "file:../common", + "uikit": "file:../uikit" + } } diff --git a/entry/src/main/module.json5 b/entry/src/main/module.json5 index 53024e8..d6776f8 100644 --- a/entry/src/main/module.json5 +++ b/entry/src/main/module.json5 @@ -9,6 +9,11 @@ ], "deliveryWithInstall": true, "installationFree": false, + "requestPermissions": [ + { + "name": "ohos.permission.INTERNET" + } + ], "pages": "$profile:main_pages", "abilities": [ { From 058b978f25d2a3dedc1639509795e345b4d8d4d7 Mon Sep 17 00:00:00 2001 From: AndyChen1024 Date: Fri, 11 Sep 2026 16:22:45 +0800 Subject: [PATCH 4/6] =?UTF-8?q?feat(entry):=20=E5=BB=BA=E7=AB=8B=20MVVM+da?= =?UTF-8?q?ta=20=E5=88=86=E5=B1=82=E9=AA=A8=E6=9E=B6=EF=BC=8C=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=20V2=20=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86=E4=B8=8E?= =?UTF-8?q?=20Navigation=20=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- entry/src/main/ets/data/api/UserApi.ets | 33 +++++++++ .../ets/data/repository/UserRepository.ets | 35 +++++++++ entry/src/main/ets/di/ServiceLocator.ets | 23 ++++++ .../main/ets/entryability/EntryAbility.ets | 48 ++++++++----- entry/src/main/ets/model/User.ets | 9 +++ entry/src/main/ets/pages/DetailPage.ets | 35 +++++++++ entry/src/main/ets/pages/Index.ets | 72 ++++++++++++++----- entry/src/main/ets/router/AppRouter.ets | 30 ++++++++ entry/src/main/ets/router/RouteMap.ets | 18 +++++ entry/src/main/ets/view/StatusCard.ets | 25 +++++++ .../src/main/ets/viewmodel/HomeViewModel.ets | 41 +++++++++++ 11 files changed, 336 insertions(+), 33 deletions(-) create mode 100644 entry/src/main/ets/data/api/UserApi.ets create mode 100644 entry/src/main/ets/data/repository/UserRepository.ets create mode 100644 entry/src/main/ets/di/ServiceLocator.ets create mode 100644 entry/src/main/ets/model/User.ets create mode 100644 entry/src/main/ets/pages/DetailPage.ets create mode 100644 entry/src/main/ets/router/AppRouter.ets create mode 100644 entry/src/main/ets/router/RouteMap.ets create mode 100644 entry/src/main/ets/view/StatusCard.ets create mode 100644 entry/src/main/ets/viewmodel/HomeViewModel.ets diff --git a/entry/src/main/ets/data/api/UserApi.ets b/entry/src/main/ets/data/api/UserApi.ets new file mode 100644 index 0000000..e421c17 --- /dev/null +++ b/entry/src/main/ets/data/api/UserApi.ets @@ -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 { + const raw = await this.http.get(`${UserApi.BASE_URL}/api/user/${id}`); + const result = JSON.parse(raw) as ApiResult; + if (!result.isSuccess()) { + throw ApiError.business(result.code, result.message); + } + return result.data; + } +} diff --git a/entry/src/main/ets/data/repository/UserRepository.ets b/entry/src/main/ets/data/repository/UserRepository.ets new file mode 100644 index 0000000..2a24e26 --- /dev/null +++ b/entry/src/main/ets/data/repository/UserRepository.ets @@ -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; +} + +/** + * 真实实现:调用 UserApi,做 DTO -> 领域模型映射。 + */ +export class UserRepositoryImpl extends UserRepository { + private readonly api: UserApi; + + constructor(api: UserApi) { + super(); + this.api = api; + } + + async getUser(id: string): Promise { + 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; + } +} diff --git a/entry/src/main/ets/di/ServiceLocator.ets b/entry/src/main/ets/di/ServiceLocator.ets new file mode 100644 index 0000000..d80de31 --- /dev/null +++ b/entry/src/main/ets/di/ServiceLocator.ets @@ -0,0 +1,23 @@ +/** + * 轻量服务定位器:ArkTS 无反射,用字符串 key + 显式类型转换做手动 DI。 + * 约定:启动时在 EntryAbility.onCreate 中注册单例,业务侧通过 get() 解析后强转。 + */ +export class ServiceLocator { + private static readonly services: Map = new Map(); + + 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(); + } +} diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets index 091797f..3aa22e7 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -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'); } -} \ No newline at end of file +} diff --git a/entry/src/main/ets/model/User.ets b/entry/src/main/ets/model/User.ets new file mode 100644 index 0000000..a5e6e5d --- /dev/null +++ b/entry/src/main/ets/model/User.ets @@ -0,0 +1,9 @@ +/** + * 领域模型示例:@ObservedV2 + @Trace 实现字段级可观察。 + */ +@ObservedV2 +export class User { + @Trace id: string = ''; + @Trace name: string = ''; + @Trace email: string = ''; +} diff --git a/entry/src/main/ets/pages/DetailPage.ets b/entry/src/main/ets/pages/DetailPage.ets new file mode 100644 index 0000000..95c3514 --- /dev/null +++ b/entry/src/main/ets/pages/DetailPage.ets @@ -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('详情') + } +} diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index 8e2d24a..8e56a14 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -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) } -} \ No newline at end of file +} diff --git a/entry/src/main/ets/router/AppRouter.ets b/entry/src/main/ets/router/AppRouter.ets new file mode 100644 index 0000000..ac41dd0 --- /dev/null +++ b/entry/src/main/ets/router/AppRouter.ets @@ -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(); + } +} diff --git a/entry/src/main/ets/router/RouteMap.ets b/entry/src/main/ets/router/RouteMap.ets new file mode 100644 index 0000000..8cca25b --- /dev/null +++ b/entry/src/main/ets/router/RouteMap.ets @@ -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 }) + } +} diff --git a/entry/src/main/ets/view/StatusCard.ets b/entry/src/main/ets/view/StatusCard.ets new file mode 100644 index 0000000..49d9900 --- /dev/null +++ b/entry/src/main/ets/view/StatusCard.ets @@ -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) + } +} diff --git a/entry/src/main/ets/viewmodel/HomeViewModel.ets b/entry/src/main/ets/viewmodel/HomeViewModel.ets new file mode 100644 index 0000000..864a134 --- /dev/null +++ b/entry/src/main/ets/viewmodel/HomeViewModel.ets @@ -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 { + 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; + } + } +} From d179073e53e681a71540b6856223dd118faca1b6 Mon Sep 17 00:00:00 2001 From: AndyChen1024 Date: Fri, 11 Sep 2026 16:22:45 +0800 Subject: [PATCH 5/6] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9E=E6=9E=B6?= =?UTF-8?q?=E6=9E=84=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3=EF=BC=88=E5=A4=9A?= =?UTF-8?q?=20HAR=20+=20MVVM=20+=20V2=20=E7=8A=B6=E6=80=81=E7=AE=A1?= =?UTF-8?q?=E7=90=86=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture.md | 229 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 190 insertions(+), 39 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 992d018..a1551a9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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) +``` + +**单向数据流**: + +``` +pages → viewmodel → repository(契约) → api → HttpClient → 后端 + ↑ 状态回传由 @Trace 触发自动刷新 +``` + +--- + +## 4. 状态管理约定(全量 V2,禁用 V1) + +| 场景 | 使用 | +|---|---| +| 组件/页面 | `@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 `;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`:`{ code, message, data }`,`code === 0` 为成功。 +- `ApiError`:区分网络异常 / HTTP 状态码 / 业务错误码。 + +--- + +## 6. 横切关注点 + +### 6.1 依赖注入 + +`di/ServiceLocator.ets`:`Map` 单例注册表。启动时在 `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//{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` 放宽混合约束。 -依赖方向(只能从右往左引,禁止反向): -Dependency direction (only right→left, never reverse): -`pages → viewmodel → service → model`;`components` 不依赖业务层。 +主要引用: -## 状态管理 State Management +- 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 -- 组件内:`@State`(本地)、`@Prop`(父传子单向)、`@Link`(父子双向)。 -- 跨组件:`@Provide` / `@Consume`,或 `AppStorage`(全局)/ `LocalStorage`(页面级)。 -- 持久化:`PersistentStorage`(简单键值)。 -- 原则:**UI 由状态驱动**,不在组件外手动改 UI。 +--- -## 关键约定速查 Key Contracts +## 10. 边界与后续 -- 所有字符串/颜色/媒体走 `$r()` 资源引用。 -- 页面只做 UI 编排;数据获取/业务判断在 `service/`,类型在 `model/`。 -- 新增页面必须同步注册到 `main_pages.json`。 +- 本次为**骨架级**交付,未实现真实后端对接与具体业务(保留示例链路)。 +- 未新增 feature HAR(无业务领域),仅写入抽取指南。 +- 未做多设备适配(已确认仅 phone)。 +- 后续待办:token 刷新策略、RDB 持久化、自动化测试基建、环境配置注入(BASE_URL)。 From a91b4afdf2b2d89793c77549c32eb8fc8a77221d Mon Sep 17 00:00:00 2001 From: AndyChen1024 Date: Fri, 11 Sep 2026 16:22:45 +0800 Subject: [PATCH 6/6] =?UTF-8?q?docs:=20harness=20=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=E6=96=B0=E6=9E=B6=E6=9E=84=EF=BC=8C=E5=BF=BD?= =?UTF-8?q?=E7=95=A5=E7=AD=BE=E5=90=8D=E6=9D=90=E6=96=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 7 ++++++- AGENTS.md | 39 ++++++++++++++++++++++++--------------- docs/build-deploy.md | 14 ++++++++++++++ docs/conventions.md | 28 ++++++++++++++++++---------- docs/testing.md | 6 +++--- 5 files changed, 65 insertions(+), 29 deletions(-) diff --git a/.gitignore b/.gitignore index d2ff201..1a59487 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,9 @@ /.clang-format /.clang-tidy **/.test -/.appanalyzer \ No newline at end of file +/.appanalyzer +# 签名材料(个人 debug 证书,勿提交) +*.p12 +*.cer +*.p7b +**/signing/ diff --git a/AGENTS.md b/AGENTS.md index d2e0084..8580178 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` diff --git a/docs/build-deploy.md b/docs/build-deploy.md index bad9413..58eb6e5 100644 --- a/docs/build-deploy.md +++ b/docs/build-deploy.md @@ -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"` —— **签名尚未配置**。 diff --git a/docs/conventions.md b/docs/conventions.md index adac4dc..177a25a 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -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 diff --git a/docs/testing.md b/docs/testing.md index 0f36316..4584d0e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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`,不要为了测试改生产代码结构。 - 改动后至少跑受影响的测试再提交。