Compare commits
18 Commits
2dc02b44c7
...
feature/mi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58a91134a1 | ||
|
|
d9f1893f67 | ||
|
|
28fc0a1026 | ||
|
|
b390dbf214 | ||
|
|
8321991b45 | ||
|
|
35a70f5091 | ||
|
|
3ceaa9188b | ||
|
|
77ee00070e | ||
|
|
a0e0758b6b | ||
|
|
a25eab2d61 | ||
|
|
c4f678e299 | ||
|
|
9fb87d2d08 | ||
|
|
a91b4afdf2 | ||
|
|
d179073e53 | ||
|
|
058b978f25 | ||
|
|
3a116df871 | ||
|
|
d5e2d88c4c | ||
|
|
b73e74fa4a |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
/node_modules
|
||||
/oh_modules
|
||||
/tmp
|
||||
/local.properties
|
||||
/.idea
|
||||
**/build
|
||||
@@ -10,3 +11,11 @@
|
||||
/.clang-tidy
|
||||
**/.test
|
||||
/.appanalyzer
|
||||
# 签名材料(个人 debug 证书,勿提交)
|
||||
*.p12
|
||||
*.cer
|
||||
*.p7b
|
||||
**/signing/
|
||||
# 技能内容(本地安装,经 skills-lock.json + npx skills 重新生成)
|
||||
/.agents
|
||||
/.claude/skills
|
||||
|
||||
42
AGENTS.md
42
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`。
|
||||
|
||||
@@ -63,12 +72,13 @@ harmony-client/
|
||||
| 编码规范 Conventions | `docs/conventions.md` |
|
||||
| 测试 Testing | `docs/testing.md` |
|
||||
| 构建/签名/部署 Build & Deploy | `docs/build-deploy.md` |
|
||||
| 分支模型与 Git 规范 Git Workflow | `docs/git-workflow.md` |
|
||||
|
||||
## 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`
|
||||
|
||||
@@ -83,7 +93,7 @@ harmony-client/
|
||||
**禁止 Forbidden**
|
||||
- 修改签名配置(`build-profile.json5` 的 `signingConfigs`)
|
||||
- 删除测试文件
|
||||
- push 到 main/master
|
||||
- push 到 main 或 dev(一律走 PR)
|
||||
- 往 `local.properties` / `.env` 写真实密钥
|
||||
- 未经批准安装生产依赖
|
||||
|
||||
|
||||
@@ -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。
|
||||
Single-HAP HarmonyOS NEXT app, API 22 (HarmonyOS 6.0). Stack: ArkTS + ArkUI declarative UI + hvigor.
|
||||
> 项目:`harmony-client`(ArkTS + ArkUI,SDK 6.0.2 / API 22,仅 phone)
|
||||
> 业务形态:对接自有后端的通用业务客户端(HTTP + 鉴权 + CRUD)
|
||||
|
||||
## 模块结构 Module Structure
|
||||
---
|
||||
|
||||
- **AppScope/** — 应用级信息:`app.json5` 里定义 `bundleName`(`com.mingyuan.harmony_client`)、应用图标/标签(`$media:layered_image` / `$string:app_name`)。
|
||||
- **entry/** — 唯一主模块(HAP)。`module.json5` 声明 Ability、权限、入口。
|
||||
## 1. 背景与目标
|
||||
|
||||
## Ability 生命周期 Ability Lifecycle
|
||||
本工程从 DevEco 空脚手架起步,目标是建立一套**可持续演进、可测试、低耦合**的客户端架构骨架,为后续业务开发打底。设计依据来自一轮 deep-research 调研(交叉验证华为官方文档与社区实践,9 条结论全部通过对抗式验证)。
|
||||
|
||||
**EntryAbility**(`entry/src/main/ets/entryability/EntryAbility.ets`)是应用入口,继承 `UIAbility`。关键回调:
|
||||
核心决策:
|
||||
|
||||
| 回调 Callback | 用途 Purpose |
|
||||
|--------------|-------------|
|
||||
| `onCreate` | Ability 创建,初始化状态(**此时不要做 UI 操作**) |
|
||||
| `onWindowStageCreate` | 窗口创建,在这里 `loadContent('pages/Index')` 加载首页 |
|
||||
| `onForeground` / `onBackground` | 前后台切换 |
|
||||
| `onDestroy` | 销毁,释放资源 |
|
||||
| 决策点 | 选择 | 理由 |
|
||||
|---|---|---|
|
||||
| 工程结构 | 官方三层 commons / features / products | 单向依赖、官方白皮书推荐 |
|
||||
| 模块化 | **单 HAP + 多 HAR** | HAR 编译期打包、可裁剪;HSP 仅按需加载 |
|
||||
| 代码分层 | **MVVM + 独立 data 层** | 官方指南 + 网络/数据解耦 |
|
||||
| 状态管理 | **全量 StateManagement V2** | API 22 下 V2 能力完整,字段级精准刷新 |
|
||||
|
||||
**EntryBackupAbility** — 应用备份扩展 Ability(备份/恢复数据)。
|
||||
---
|
||||
|
||||
## 页面与路由 Pages & Routing
|
||||
## 2. 模块划分与依赖
|
||||
|
||||
- 页面组件用 `@Entry` 装饰,在 `entry/src/main/resources/base/profile/main_pages.json` 注册。
|
||||
- 页面跳转:`router.pushUrl()`(栈式导航),或 `Navigation` 组件(推荐,用于复杂导航)。
|
||||
```
|
||||
harmony-client/
|
||||
├── entry/ HAP 应用壳 + 业务功能(唯一入口,EntryAbility)
|
||||
├── common/ HAR 基础设施层(网络 / 日志 / 状态 / 工具,无业务、无 UI 组件)
|
||||
└── uikit/ HAR 通用 UI 组件库 + 主题(无业务、无网络依赖)
|
||||
```
|
||||
|
||||
## 目录分层 Layering Convention
|
||||
依赖方向严格单向,禁止反向/循环:
|
||||
|
||||
```
|
||||
entry ──依赖──▶ common
|
||||
entry ──依赖──▶ uikit
|
||||
common / uikit 各自独立,互不依赖
|
||||
```
|
||||
|
||||
- **common**:`HttpClient`、鉴权拦截器、统一错误模型、`Logger`、`SessionStore`(PersistenceV2 持久化)、工具函数。
|
||||
- **uikit**:主题 token(`AppColor/AppSpacing/AppFontSize`)、通用组件(`AppButton/LoadingView/EmptyView`)。
|
||||
- **entry**:应用壳装配、路由、DI 容器、MVVM 分层业务代码。
|
||||
|
||||
---
|
||||
|
||||
## 3. 分层与目录结构(entry 内)
|
||||
|
||||
采用官方 MVVM 指南的**层优先**结构,叠加独立 `data/` 层与横切的 `router/`、`di/`:
|
||||
|
||||
```
|
||||
entry/src/main/ets/
|
||||
├── entryability/ # Ability 入口(生命周期)
|
||||
├── entrybackupability/ # 备份扩展
|
||||
├── pages/ # 页面(@Entry 组件,只做 UI 编排)
|
||||
├── components/ # 可复用 UI 组件(@Component / @Reusable)
|
||||
├── viewmodel/ # 状态管理(连接 model 与 UI)
|
||||
├── service/ # 业务逻辑、数据访问(网络/存储)
|
||||
├── model/ # 数据模型、类型定义
|
||||
└── common/ # 公共常量、工具函数
|
||||
├── entryability/
|
||||
│ └── EntryAbility.ets # 生命周期装配:Logger / ServiceLocator / SessionStore
|
||||
├── router/
|
||||
│ ├── RouteMap.ets # 路由名常量 + name→页面 分发
|
||||
│ └── AppRouter.ets # Navigation 导航封装(push/replace/pop/...)
|
||||
├── di/
|
||||
│ └── ServiceLocator.ets # 轻量服务定位器(ArkTS 无反射,手动注册/解析)
|
||||
├── model/ # 领域模型:@ObservedV2 + @Trace 实体
|
||||
├── data/
|
||||
│ ├── api/ # DTO + 请求服务(调 common 的 HttpClient)
|
||||
│ └── repository/ # 数据仓库契约(抽象类)+ 实现
|
||||
├── viewmodel/ # @ObservedV2 状态容器(data/loading/error 三态)
|
||||
├── view/ # 可复用 UI 组件(@ComponentV2)
|
||||
└── pages/ # 页面,仅编排(组合 view + viewmodel)
|
||||
```
|
||||
|
||||
依赖方向(只能从右往左引,禁止反向):
|
||||
Dependency direction (only right→left, never reverse):
|
||||
`pages → viewmodel → service → model`;`components` 不依赖业务层。
|
||||
**单向数据流**:
|
||||
|
||||
## 状态管理 State Management
|
||||
```
|
||||
pages → viewmodel → repository(契约) → api → HttpClient → 后端
|
||||
↑ 状态回传由 @Trace 触发自动刷新
|
||||
```
|
||||
|
||||
- 组件内:`@State`(本地)、`@Prop`(父传子单向)、`@Link`(父子双向)。
|
||||
- 跨组件:`@Provide` / `@Consume`,或 `AppStorage`(全局)/ `LocalStorage`(页面级)。
|
||||
- 持久化:`PersistentStorage`(简单键值)。
|
||||
- 原则:**UI 由状态驱动**,不在组件外手动改 UI。
|
||||
---
|
||||
|
||||
## 关键约定速查 Key Contracts
|
||||
## 4. 状态管理约定(全量 V2,禁用 V1)
|
||||
|
||||
- 所有字符串/颜色/媒体走 `$r()` 资源引用。
|
||||
- 页面只做 UI 编排;数据获取/业务判断在 `service/`,类型在 `model/`。
|
||||
- 新增页面必须同步注册到 `main_pages.json`。
|
||||
| 场景 | 使用 |
|
||||
|---|---|
|
||||
| 组件/页面 | `@ComponentV2` |
|
||||
| 组件内部状态 | `@Local` |
|
||||
| 父→子输入 | `@Param` |
|
||||
| 子→父输出/回调 | `@Event`(**不用 `@Link`**;需双向时用 `@Param` 的 `!!` 语法糖) |
|
||||
| 类/模型可观察 | `@ObservedV2` 类 + `@Trace` 字段 |
|
||||
| 跨层共享 | `@Provider` / `@Consumer` |
|
||||
| 变化监听 | `@Monitor` |
|
||||
| 全局会话 | `SessionStore`(`@ObservedV2` 单例,见 `common/state/SessionStore.ets`) |
|
||||
| 轻量持久化 | `PersistenceV2.globalConnect(...)`;结构化数据后续用 relationalStore(RDB) |
|
||||
|
||||
**核心约定**:ViewModel 是 `@ObservedV2` 类,持有 `@Trace` 的 `data/loading/error` 三态;页面只读状态、调方法,不直接碰 Repository / 网络层。
|
||||
|
||||
---
|
||||
|
||||
## 5. 网络与数据层
|
||||
|
||||
### 5.1 网络层(`common/src/main/ets/network/`)
|
||||
|
||||
- `HttpClient`:封装 `@kit.NetworkKit` 的 http,统一超时/请求头,支持**拦截器链**。
|
||||
- `HttpInterceptor`:拦截器接口(`onRequest` / `onResponse`)。
|
||||
- `AuthInterceptor`:请求前注入 `Authorization: Bearer <token>`;401 时清空会话。
|
||||
|
||||
### 5.2 鉴权流程
|
||||
|
||||
```
|
||||
请求 → AuthInterceptor.onRequest 注入 token → 发出
|
||||
→ AuthInterceptor.onResponse 检查 401
|
||||
→ (当前) 清空 SessionStore,由上层跳登录
|
||||
→ (TODO) token 刷新 + 原请求重放(防并发刷新),需后端 refresh 接口约定后实现
|
||||
```
|
||||
|
||||
### 5.3 Repository 模式
|
||||
|
||||
- `data/repository/` 中定义**抽象类契约**,ViewModel 只依赖契约,实现可替换(真实 / mock)。
|
||||
- 用抽象类而非 interface,是为了在 `ServiceLocator` 中做 `Object → 契约` 强转时兼容 ArkTS。
|
||||
- `data/api/` 返回 DTO,Repository 负责 **DTO → 领域模型** 映射。
|
||||
|
||||
### 5.4 统一响应与错误
|
||||
|
||||
- `ApiResult<T>`:`{ code, message, data }`,`code === 0` 为成功。
|
||||
- `ApiError`:区分网络异常 / HTTP 状态码 / 业务错误码。
|
||||
|
||||
---
|
||||
|
||||
## 6. 横切关注点
|
||||
|
||||
### 6.1 依赖注入
|
||||
|
||||
`di/ServiceLocator.ets`:`Map<string, Object>` 单例注册表。启动时在 `EntryAbility.onCreate` 注册 `HttpClient`、各 Repository、`SessionStore`;业务侧 `get(key)` 后强转。
|
||||
|
||||
> ArkTS 无反射,用字符串 key + 显式强转;若后续规模增大可引入类型安全的 key 常量或轻量注入容器。
|
||||
|
||||
### 6.2 导航
|
||||
|
||||
`Navigation` + `NavPathStack`(官方推荐,替代旧 Router)。`RouteMap.ets` 定义路由名常量与 `name→页面` 分发;`AppRouter` 持有全局 `NavPathStack` 提供 `push/replace/pop/popToName/clear`。HAR 不承载路由。
|
||||
|
||||
### 6.3 日志
|
||||
|
||||
`common/logging/Logger.ets`:封装 `hilog`,统一 DOMAIN,后续可加等级开关/上报。
|
||||
|
||||
### 6.4 持久化
|
||||
|
||||
- `PersistenceV2`:轻量 KV(token / 用户偏好),`@Trace` 字段自动落盘。
|
||||
- `relationalStore(RDB)`:结构化数据(订单、列表缓存等),后续引入。
|
||||
|
||||
---
|
||||
|
||||
## 7. 编码规范
|
||||
|
||||
1. **状态管理只用 V2**:新组件一律 `@ComponentV2`,禁止混入 V1 装饰器。
|
||||
2. **单向数据流**:父→子用 `@Param`,子→父用 `@Event`;页面不直接改 ViewModel 之外的全局状态。
|
||||
3. **页面只编排**:`pages/` 下只做组件组合与 ViewModel 调用,业务逻辑进 ViewModel,数据进 Repository/Api。
|
||||
4. **跨模块引用走 `Index.ets`**:entry 只 `import { ... } from 'common'/'uikit'`,不直接引 HAR 内部路径。
|
||||
5. **魔法值收敛到 `uikit/theme/Theme.ets`**:颜色/间距/字号统一取 token。
|
||||
|
||||
---
|
||||
|
||||
## 8. 演进指南
|
||||
|
||||
### 8.1 抽取 feature HAR(业务领域明确后)
|
||||
|
||||
当某个业务领域(如订单、支付)足够独立时,新增 `feature_xxx` HAR:
|
||||
|
||||
```
|
||||
feature_order/
|
||||
├── src/main/ets/{model,data,viewmodel,view,pages}
|
||||
└── oh-package.json5 (name: feature_order, main: Index.ets)
|
||||
```
|
||||
|
||||
- 根 `build-profile.json5` 的 `modules` 增加该模块。
|
||||
- 依赖它的模块在其 `oh-package.json5` 增加 `"feature_order": "file:../feature_order"`。
|
||||
- 依赖方向:`entry → feature_xxx → common/uikit`,禁止 feature 之间横向依赖(确实需共享时下沉到 `common`)。
|
||||
|
||||
### 8.2 迁移到 feature-first
|
||||
|
||||
当 feature 数量增多、单层目录过深时,可将 `model/data/viewmodel/view/pages` 按 feature 重组:
|
||||
|
||||
```
|
||||
ets/features/<feature>/{model,data,viewmodel,view,pages}
|
||||
ets/shared/… # 跨 feature 共享
|
||||
```
|
||||
|
||||
迁移原则:先把稳定边界抽 HAR,再在 HAR 内部做 feature 分组。
|
||||
|
||||
---
|
||||
|
||||
## 9. 调研结论摘要与引用
|
||||
|
||||
- 官方 MVVM(StateManagement V2)指南给出 `model/viewmodel/view/pages` 分层。
|
||||
- 官方三层工程结构:commons(禁止反向依赖)/ features / products(不可横向调用),打包形态 HAR/HSP/Feature HAP/Entry HAP。
|
||||
- V2 装饰器映射:`@State→@Local`、`@Prop→@Param`、`@Link→@Param+@Event`、`@Observed→@ObservedV2+@Trace`、`@Watch→@Monitor`、`AppStorage→AppStorageV2`、`PersistentStorage→PersistenceV2`。
|
||||
- V1/V2 不可混用(编译错误);API 19+ 提供 `UIUtils.enableV2Compatibility/makeV1Observed` 放宽混合约束。
|
||||
|
||||
主要引用:
|
||||
|
||||
- https://developer.huawei.com/consumer/en/doc/harmonyos-guides/arkts-mvvm-v2
|
||||
- https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/arkts-decorator-overview
|
||||
- https://device.harmonyos.com/en/docs/apiref/harmonyos-guides/arkts-v1-v2-update-difference
|
||||
- https://github.com/openharmony/docs(arkts-v1-v2-migration-application / mixusage / inner-component)
|
||||
- https://github.com/OpeNopEn2007/arkts-patterns
|
||||
|
||||
---
|
||||
|
||||
## 10. 边界与后续
|
||||
|
||||
- 本次为**骨架级**交付,未实现真实后端对接与具体业务(保留示例链路)。
|
||||
- 未新增 feature HAR(无业务领域),仅写入抽取指南。
|
||||
- 未做多设备适配(已确认仅 phone)。
|
||||
- 后续待办:token 刷新策略、RDB 持久化、自动化测试基建、环境配置注入(BASE_URL)。
|
||||
|
||||
@@ -17,14 +17,54 @@ 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"` —— **签名尚未配置**。
|
||||
### 现状与原则
|
||||
|
||||
- `build-profile.json5` 里 `signingConfigs` 在**仓库中必须保持为空 `[]`**(`products[].signingConfig` 仍引用 `"default"`)——签名是机器相关的,禁止入库。
|
||||
- 真正的密钥物料(`.p12` / `.cer` / `.p7b`)由 DevEco 生成在 `~/.ohos/config/` 下,**本来就在仓库外**;`build-profile.json5` 里只有「文件路径 + 加密后的密码」,但同样属敏感信息,不得提交。
|
||||
|
||||
### 本地配置签名(不入库)
|
||||
|
||||
要跑真机/模拟器需先配置签名:
|
||||
- DevEco Studio → File → Project Structure → Signing Configs → 勾选自动签名(登录华为账号)。
|
||||
- 或手动配置 `.p12` + `.cer` / `.p7b`。
|
||||
|
||||
> 该文件的签名部分属于 **Agent 禁止修改** 范围(见 `AGENTS.md`)。
|
||||
DevEco 自动签名会把凭据写进 `build-profile.json5`,这属于**本地未提交状态**,不要 `git add` 它。
|
||||
|
||||
### 防泄露:pre-commit 钩子
|
||||
|
||||
仓库内置了 pre-commit 钩子 `scripts/git-hooks/pre-commit`:只要暂存区的 `build-profile.json5` 出现 `keyPassword` / `storePassword` / `certpath` 等签名字段,就**拒绝提交**。
|
||||
|
||||
每个克隆启用一次:
|
||||
|
||||
```bash
|
||||
git config core.hooksPath scripts/git-hooks
|
||||
```
|
||||
|
||||
验证:临时在 `build-profile.json5` 里塞一个 `"keyPassword"` 再 `git add build-profile.json5 && git commit`,应被拦截。
|
||||
|
||||
> 确需临时绕过时用 `git commit --no-verify`(仅应急,不建议)。
|
||||
|
||||
### 彻底外部化(可选)
|
||||
|
||||
若想连本地也不出现凭据,可走「动态配置签名」:用 `hvigorfile.ts` 从 gitignore 的本地文件或环境变量读取签名注入,让 `build-profile.json5` 始终保持空。参考华为官方《基于动态配置签名的多人协同开发应用签名解决方案》。
|
||||
|
||||
> `build-profile.json5` 的签名部分属于 **Agent 禁止修改** 范围(见 `AGENTS.md`)。
|
||||
|
||||
## 产品与构建模式 Products & Build Modes
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
104
docs/git-workflow.md
Normal file
104
docs/git-workflow.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Git Workflow(分支模型与提交规范)
|
||||
|
||||
本仓库采用简化 Git Flow:`main`(生产)← `dev`(集成)← `feature/*`(功能)。
|
||||
|
||||
## 分支模型 Branch Model
|
||||
|
||||
| 分支 Branch | 用途 Purpose | 从哪拉出 From | 合入 To | 直接 push |
|
||||
|-------------|-------------|---------------|---------|-----------|
|
||||
| `main` | 生产 / 稳定,可发布 | — | — | ❌ 禁止(仅 PR) |
|
||||
| `dev` | 集成 / 开发主线 | `main` | `main` | ❌ 禁止(仅 PR) |
|
||||
| `feature/<name>` | 单个功能 / 改动 | `dev` | `dev` | ✅(推自己的 feature) |
|
||||
| `hotfix/<name>` | 线上紧急修复 | `main` | `main` + `dev` | ❌(仅 PR) |
|
||||
| `release/<version>` | (可选)发布候选 | `dev` | `main` + `dev` | ❌(仅 PR) |
|
||||
|
||||
## 分支命名 Branch Naming
|
||||
|
||||
- 功能:`feature/<kebab-case>`,如 `feature/architecture-scaffold`
|
||||
- 修复:`fix/<kebab-case>` 或 `hotfix/<kebab-case>`
|
||||
- 发布:`release/<semver>`,如 `release/v1.0.0`
|
||||
|
||||
## 提交规范 Commit Convention
|
||||
|
||||
遵循 Conventional Commits:`<type>(<scope>): <subject>`
|
||||
|
||||
- `type`:`feat` / `fix` / `docs` / `chore` / `refactor` / `test` / `build` / `perf` / `style` / `ci`
|
||||
- `scope`:可选,模块名(`entry` / `uikit` / `common`)
|
||||
- `subject`:祈使句,首字母小写,结尾不加句号
|
||||
|
||||
示例:
|
||||
|
||||
```
|
||||
feat(entry): 建立 MVVM+data 分层骨架
|
||||
chore(build): 注册 common/uikit 模块
|
||||
docs: 新增架构设计文档
|
||||
```
|
||||
|
||||
## 开发流程 Development Workflow
|
||||
|
||||
### 新建功能 Feature
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
git pull
|
||||
git checkout -b feature/xxx
|
||||
|
||||
# 开发 + 提交(conventional commits)
|
||||
git add -A
|
||||
git commit -m "feat(entry): ..."
|
||||
|
||||
git push -u origin feature/xxx
|
||||
```
|
||||
|
||||
然后在 Gitea 发起 PR:`feature/xxx → dev`,评审通过后合并(推荐 **squash merge**,保持 `dev` 历史线性干净)。
|
||||
|
||||
合并后删除已合入的 feature 分支:
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
git pull
|
||||
git branch -d feature/xxx
|
||||
git push origin --delete feature/xxx
|
||||
```
|
||||
|
||||
### 发布 Release
|
||||
|
||||
简单场景直接发 PR `dev → main`(用 **merge commit** 保留发布边界),合并后打 tag:
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull
|
||||
git tag v1.0.0
|
||||
git push --tags
|
||||
```
|
||||
|
||||
复杂场景可引入 `release/<version>` 分支隔离发布修复:
|
||||
|
||||
```bash
|
||||
git checkout -b release/v1.0.0 dev
|
||||
# 修复 release 上的问题后,分别合回 main 与 dev 并打 tag
|
||||
```
|
||||
|
||||
### 紧急修复 Hotfix
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull
|
||||
git checkout -b hotfix/xxx
|
||||
# 修复 + 提交
|
||||
git push -u origin hotfix/xxx
|
||||
# PR: hotfix/xxx → main;再 PR: hotfix/xxx → dev(同步回开发主线)
|
||||
```
|
||||
|
||||
## 硬规则 Hard Rules
|
||||
|
||||
1. **禁止直接 push 到 `main` 或 `dev`**,一律走 PR(建议在 Gitea 对这两个分支开启保护)。
|
||||
2. **PR 合入前过 lint + 相关测试**,见 `docs/testing.md` 的「改动后验证」。
|
||||
3. **feature 合并后删除分支**,保持分支列表干净。
|
||||
4. **提交信息必须走 Conventional Commits**,不合规的提交在 PR 中 squash 掉。
|
||||
5. **签名信息永不入库**——`build-profile.json5` 的 `signingConfigs` 必须保持空 `[]`,已由 pre-commit 钩子兜底,见 `docs/build-deploy.md` 的「签名管理」。
|
||||
|
||||
## 仓库设置 Repo Settings(一次性)
|
||||
|
||||
- 默认分支设为 `main`(Gitea → Settings → Branches → Default Branch)。
|
||||
- 建议对 `main` / `dev` 开启分支保护(禁止直接 push,仅允许 PR 合并)。
|
||||
@@ -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`,不要为了测试改生产代码结构。
|
||||
- 改动后至少跑受影响的测试再提交。
|
||||
|
||||
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": "",
|
||||
"author": "",
|
||||
"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;
|
||||
}
|
||||
}
|
||||
78
entry/src/main/ets/data/repository/CheckinRepository.ets
Normal file
78
entry/src/main/ets/data/repository/CheckinRepository.ets
Normal file
@@ -0,0 +1,78 @@
|
||||
import { CheckinRecord, CheckinStatus, GroupMember, PersonalCheckinRecord } from '../../model/CheckinModels';
|
||||
import { mockDelay } from './MockUtil';
|
||||
|
||||
/**
|
||||
* 打卡数据仓库契约。
|
||||
*/
|
||||
export abstract class CheckinRepository {
|
||||
abstract observeCheckin(): Promise<CheckinRecord>;
|
||||
abstract submitCheckin(status: string): Promise<CheckinRecord>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地 mock 实现:内存 seed,提交更新自身状态。
|
||||
*/
|
||||
export class FakeCheckinRepository extends CheckinRepository {
|
||||
private record: CheckinRecord = FakeCheckinRepository.seed();
|
||||
|
||||
async observeCheckin(): Promise<CheckinRecord> {
|
||||
await mockDelay(250);
|
||||
return this.record;
|
||||
}
|
||||
|
||||
async submitCheckin(status: string): Promise<CheckinRecord> {
|
||||
await mockDelay(300);
|
||||
this.record.checked = true;
|
||||
this.record.status = status;
|
||||
// 更新"我"(第一个成员)在小组成员列表里的状态
|
||||
if (this.record.groupMembers.length > 0) {
|
||||
const me = this.record.groupMembers[0];
|
||||
me.checkedIn = true;
|
||||
me.status = status;
|
||||
me.checkinTime = '今天 09:12';
|
||||
}
|
||||
return this.record;
|
||||
}
|
||||
|
||||
private static seed(): CheckinRecord {
|
||||
const r = new CheckinRecord();
|
||||
r.classDate = '2026-09-06';
|
||||
r.weekday = '周日';
|
||||
r.dayNum = '06';
|
||||
r.checked = false;
|
||||
r.status = '';
|
||||
r.groupName = '进阶一组';
|
||||
r.groupMembers = [
|
||||
FakeCheckinRepository.member('u-1001', '张三', false, '', ''),
|
||||
FakeCheckinRepository.member('u-1002', '李四', true, CheckinStatus.NORMAL, '今天 09:05'),
|
||||
FakeCheckinRepository.member('u-1003', '王五', true, CheckinStatus.LATE, '今天 09:18'),
|
||||
FakeCheckinRepository.member('u-1004', '赵六', false, '', ''),
|
||||
FakeCheckinRepository.member('u-1005', '孙七', true, CheckinStatus.LEAVE, '今天 08:40')
|
||||
];
|
||||
r.personalHistory = [
|
||||
FakeCheckinRepository.history('h-1', '2026-08-30', CheckinStatus.NORMAL, '08:52'),
|
||||
FakeCheckinRepository.history('h-2', '2026-08-23', CheckinStatus.LATE, '09:21'),
|
||||
FakeCheckinRepository.history('h-3', '2026-08-16', CheckinStatus.ABSENT, '--')
|
||||
];
|
||||
return r;
|
||||
}
|
||||
|
||||
private static member(id: string, name: string, checkedIn: boolean, status: string, checkinTime: string): GroupMember {
|
||||
const m = new GroupMember();
|
||||
m.id = id;
|
||||
m.name = name;
|
||||
m.checkedIn = checkedIn;
|
||||
m.status = status;
|
||||
m.checkinTime = checkinTime;
|
||||
return m;
|
||||
}
|
||||
|
||||
private static history(id: string, date: string, status: string, time: string): PersonalCheckinRecord {
|
||||
const h = new PersonalCheckinRecord();
|
||||
h.id = id;
|
||||
h.date = date;
|
||||
h.status = status;
|
||||
h.time = time;
|
||||
return h;
|
||||
}
|
||||
}
|
||||
93
entry/src/main/ets/data/repository/HomeRepository.ets
Normal file
93
entry/src/main/ets/data/repository/HomeRepository.ets
Normal file
@@ -0,0 +1,93 @@
|
||||
import { HomeData, HomeModule, IntroCard } from '../../model/HomeModels';
|
||||
import { mockDelay } from './MockUtil';
|
||||
|
||||
/**
|
||||
* 首页数据仓库契约。
|
||||
*/
|
||||
export abstract class HomeRepository {
|
||||
abstract observeHome(): Promise<HomeData>;
|
||||
abstract observeGuestHome(): Promise<HomeData>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地 mock 实现:登录态与游客态两套 seed。
|
||||
*/
|
||||
export class FakeHomeRepository extends HomeRepository {
|
||||
async observeHome(): Promise<HomeData> {
|
||||
await mockDelay(250);
|
||||
return FakeHomeRepository.seed(true);
|
||||
}
|
||||
|
||||
async observeGuestHome(): Promise<HomeData> {
|
||||
await mockDelay(250);
|
||||
return FakeHomeRepository.seed(false);
|
||||
}
|
||||
|
||||
private static seed(loggedIn: boolean): HomeData {
|
||||
const data = new HomeData();
|
||||
if (loggedIn) {
|
||||
data.greeting = '晚上好';
|
||||
data.displayName = '张三';
|
||||
data.encouragement = '每日精进,持续成长';
|
||||
} else {
|
||||
data.greeting = '欢迎来到明缘云道场';
|
||||
data.displayName = '游客';
|
||||
data.encouragement = '登录后即可打卡、提交计划';
|
||||
}
|
||||
data.modules = FakeHomeRepository.buildModules();
|
||||
data.intros = FakeHomeRepository.buildIntros();
|
||||
return data;
|
||||
}
|
||||
|
||||
private static buildModules(): HomeModule[] {
|
||||
const checkin = new HomeModule();
|
||||
checkin.id = 'checkin';
|
||||
checkin.emoji = '📋';
|
||||
checkin.title = '打卡介绍';
|
||||
checkin.description = '每日打卡记录';
|
||||
|
||||
const plan = new HomeModule();
|
||||
plan.id = 'plan';
|
||||
plan.emoji = '📝';
|
||||
plan.title = '计划介绍';
|
||||
plan.description = '周/月/年计划';
|
||||
|
||||
const about = new HomeModule();
|
||||
about.id = 'about';
|
||||
about.emoji = 'ℹ️';
|
||||
about.title = '关于我们';
|
||||
about.description = '了解明缘云道场';
|
||||
|
||||
return [checkin, plan, about];
|
||||
}
|
||||
|
||||
private static buildIntros(): IntroCard[] {
|
||||
const list: IntroCard[] = [];
|
||||
|
||||
const learn = new IntroCard();
|
||||
learn.emoji = '📚';
|
||||
learn.heading = '学习成长工具';
|
||||
learn.body = '明缘云道场是一款专注于个人成长的效率工具,通过每周打卡与计划管理,帮助你养成良好的学习习惯。';
|
||||
list.push(learn);
|
||||
|
||||
const checkin = new IntroCard();
|
||||
checkin.emoji = '✅';
|
||||
checkin.heading = '周打卡';
|
||||
checkin.body = '每周打卡,支持多种状态记录。查看小组打卡情况,与同学们互相监督、共同进步。';
|
||||
list.push(checkin);
|
||||
|
||||
const plan = new IntroCard();
|
||||
plan.emoji = '📝';
|
||||
plan.heading = '计划管理';
|
||||
plan.body = '提供周计划、月计划、年计划三级管理,支持文字与图片上传,让成长有迹可循。';
|
||||
list.push(plan);
|
||||
|
||||
const group = new IntroCard();
|
||||
group.emoji = '👥';
|
||||
group.heading = '小组共学';
|
||||
group.body = '加入学习小组,查看组员的打卡与计划完成情况,与志同道合的伙伴一起成长。';
|
||||
list.push(group);
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
8
entry/src/main/ets/data/repository/MockUtil.ets
Normal file
8
entry/src/main/ets/data/repository/MockUtil.ets
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* mock 延迟工具:模拟网络往返,让加载态可见。
|
||||
*/
|
||||
export function mockDelay(ms: number): Promise<void> {
|
||||
return new Promise<void>((resolve: () => void) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
56
entry/src/main/ets/data/repository/PlanRepository.ets
Normal file
56
entry/src/main/ets/data/repository/PlanRepository.ets
Normal file
@@ -0,0 +1,56 @@
|
||||
import { PlanGroup, PlanRecord, PlanType } from '../../model/PlanModels';
|
||||
import { mockDelay } from './MockUtil';
|
||||
|
||||
/**
|
||||
* 计划数据仓库契约。
|
||||
*/
|
||||
export abstract class PlanRepository {
|
||||
abstract observePlan(type: string): Promise<PlanRecord>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地 mock 实现:按类型(周/月/年)缓存 seed。
|
||||
*/
|
||||
export class FakePlanRepository extends PlanRepository {
|
||||
private cache: Map<string, PlanRecord> = new Map<string, PlanRecord>();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.cache.set(PlanType.WEEKLY, FakePlanRepository.seed(PlanType.WEEKLY, '2026-W37', '周四截止,请及时提交'));
|
||||
this.cache.set(PlanType.MONTHLY, FakePlanRepository.seed(PlanType.MONTHLY, '2026-09', ''));
|
||||
this.cache.set(PlanType.YEARLY, FakePlanRepository.seed(PlanType.YEARLY, '2026', ''));
|
||||
}
|
||||
|
||||
async observePlan(type: string): Promise<PlanRecord> {
|
||||
await mockDelay(250);
|
||||
const rec = this.cache.get(type);
|
||||
return rec ? rec : FakePlanRepository.seed(type, '', '');
|
||||
}
|
||||
|
||||
private static seed(type: string, periodKey: string, message: string): PlanRecord {
|
||||
const r = new PlanRecord();
|
||||
r.type = type;
|
||||
r.periodKey = periodKey;
|
||||
r.canSubmit = true;
|
||||
r.message = message;
|
||||
r.myChecked = false;
|
||||
r.myContent = '';
|
||||
r.mySubmittedTime = '';
|
||||
r.unassignedCount = 1;
|
||||
r.groups = [
|
||||
FakePlanRepository.group('g-1', '进阶一组', 3, 5),
|
||||
FakePlanRepository.group('g-2', '进阶二组', 4, 5),
|
||||
FakePlanRepository.group('g-3', '进阶三组', 2, 4)
|
||||
];
|
||||
return r;
|
||||
}
|
||||
|
||||
private static group(id: string, name: string, checkedCount: number, memberCount: number): PlanGroup {
|
||||
const g = new PlanGroup();
|
||||
g.id = id;
|
||||
g.name = name;
|
||||
g.checkedCount = checkedCount;
|
||||
g.memberCount = memberCount;
|
||||
return g;
|
||||
}
|
||||
}
|
||||
43
entry/src/main/ets/data/repository/ProfileRepository.ets
Normal file
43
entry/src/main/ets/data/repository/ProfileRepository.ets
Normal file
@@ -0,0 +1,43 @@
|
||||
import { ClassGroup, UserProfile } from '../../model/ProfileModels';
|
||||
import { mockDelay } from './MockUtil';
|
||||
|
||||
/**
|
||||
* 我的资料数据仓库契约。
|
||||
*/
|
||||
export abstract class ProfileRepository {
|
||||
abstract observeProfile(): Promise<UserProfile>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地 mock 实现。
|
||||
*/
|
||||
export class FakeProfileRepository extends ProfileRepository {
|
||||
async observeProfile(): Promise<UserProfile> {
|
||||
await mockDelay(250);
|
||||
return FakeProfileRepository.seed();
|
||||
}
|
||||
|
||||
private static seed(): UserProfile {
|
||||
const p = new UserProfile();
|
||||
p.displayName = '张三';
|
||||
p.name = '张三';
|
||||
p.studentId = '20260001';
|
||||
p.phone = '138****1234';
|
||||
p.role = '成员';
|
||||
p.group = '进阶一组';
|
||||
p.loginType = '微信授权';
|
||||
p.avatarUrl = '';
|
||||
p.classGroups = [
|
||||
FakeProfileRepository.classGroup('2026-W37', '进阶一组', '成员')
|
||||
];
|
||||
return p;
|
||||
}
|
||||
|
||||
private static classGroup(period: string, group: string, role: string): ClassGroup {
|
||||
const c = new ClassGroup();
|
||||
c.period = period;
|
||||
c.group = group;
|
||||
c.role = role;
|
||||
return c;
|
||||
}
|
||||
}
|
||||
30
entry/src/main/ets/data/repository/SessionRepository.ets
Normal file
30
entry/src/main/ets/data/repository/SessionRepository.ets
Normal file
@@ -0,0 +1,30 @@
|
||||
import { SessionManager } from 'common';
|
||||
import { mockDelay } from './MockUtil';
|
||||
|
||||
/**
|
||||
* 会话数据仓库契约:ViewModel 只依赖此契约。
|
||||
*/
|
||||
export abstract class SessionRepository {
|
||||
abstract isLoggedIn(): boolean;
|
||||
abstract login(): Promise<void>;
|
||||
abstract logout(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 实现:封装 common 的 SessionManager(持久化 SessionStore)。
|
||||
* M1 为模拟登录(写死 token);M2 起接真实微信/手机号登录。
|
||||
*/
|
||||
export class SessionRepositoryImpl extends SessionRepository {
|
||||
isLoggedIn(): boolean {
|
||||
return SessionManager.get().isLoggedIn;
|
||||
}
|
||||
|
||||
async login(): Promise<void> {
|
||||
await mockDelay(300);
|
||||
SessionManager.get().setSession('mock-token-0001', '', 'u-1001', '张三');
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
SessionManager.get().clear();
|
||||
}
|
||||
}
|
||||
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,74 @@
|
||||
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';
|
||||
import { SessionRepositoryImpl } from '../data/repository/SessionRepository';
|
||||
import { FakeHomeRepository } from '../data/repository/HomeRepository';
|
||||
import { FakeCheckinRepository } from '../data/repository/CheckinRepository';
|
||||
import { FakePlanRepository } from '../data/repository/PlanRepository';
|
||||
import { FakeProfileRepository } from '../data/repository/ProfileRepository';
|
||||
|
||||
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));
|
||||
|
||||
// 数据层(明缘四入口,本地 mock)
|
||||
ServiceLocator.register('sessionRepository', new SessionRepositoryImpl());
|
||||
ServiceLocator.register('homeRepository', new FakeHomeRepository());
|
||||
ServiceLocator.register('checkinRepository', new FakeCheckinRepository());
|
||||
ServiceLocator.register('planRepository', new FakePlanRepository());
|
||||
ServiceLocator.register('profileRepository', new FakeProfileRepository());
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
56
entry/src/main/ets/model/CheckinModels.ets
Normal file
56
entry/src/main/ets/model/CheckinModels.ets
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 打卡状态常量(值即中文状态名,与小程序一致)。
|
||||
*/
|
||||
export class CheckinStatus {
|
||||
static readonly NORMAL: string = '正常';
|
||||
static readonly LATE: string = '迟到';
|
||||
static readonly LEAVE: string = '请假';
|
||||
static readonly ABSENT: string = '不参加讨论';
|
||||
static readonly EARLY_LEAVE: string = '早退';
|
||||
|
||||
static readonly ALL: string[] = [
|
||||
CheckinStatus.NORMAL,
|
||||
CheckinStatus.LATE,
|
||||
CheckinStatus.LEAVE,
|
||||
CheckinStatus.ABSENT,
|
||||
CheckinStatus.EARLY_LEAVE
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 小组成员(打卡视图)。
|
||||
*/
|
||||
@ObservedV2
|
||||
export class GroupMember {
|
||||
@Trace id: string = '';
|
||||
@Trace name: string = '';
|
||||
@Trace checkedIn: boolean = false;
|
||||
@Trace checkinTime: string = '';
|
||||
@Trace status: string = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 个人打卡历史记录。
|
||||
*/
|
||||
@ObservedV2
|
||||
export class PersonalCheckinRecord {
|
||||
@Trace id: string = '';
|
||||
@Trace date: string = '';
|
||||
@Trace status: string = '';
|
||||
@Trace time: string = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 打卡页数据。
|
||||
*/
|
||||
@ObservedV2
|
||||
export class CheckinRecord {
|
||||
@Trace classDate: string = '';
|
||||
@Trace weekday: string = '';
|
||||
@Trace dayNum: string = '';
|
||||
@Trace checked: boolean = false;
|
||||
@Trace status: string = '';
|
||||
@Trace groupName: string = '';
|
||||
@Trace groupMembers: GroupMember[] = [];
|
||||
@Trace personalHistory: PersonalCheckinRecord[] = [];
|
||||
}
|
||||
32
entry/src/main/ets/model/HomeModels.ets
Normal file
32
entry/src/main/ets/model/HomeModels.ets
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 首页功能模块。
|
||||
*/
|
||||
@ObservedV2
|
||||
export class HomeModule {
|
||||
@Trace id: string = '';
|
||||
@Trace emoji: string = '';
|
||||
@Trace title: string = '';
|
||||
@Trace description: string = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 产品介绍卡片。
|
||||
*/
|
||||
@ObservedV2
|
||||
export class IntroCard {
|
||||
@Trace emoji: string = '';
|
||||
@Trace heading: string = '';
|
||||
@Trace body: string = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 首页数据(登录态与游客态共用结构)。
|
||||
*/
|
||||
@ObservedV2
|
||||
export class HomeData {
|
||||
@Trace greeting: string = '';
|
||||
@Trace displayName: string = '';
|
||||
@Trace encouragement: string = '';
|
||||
@Trace modules: HomeModule[] = [];
|
||||
@Trace intros: IntroCard[] = [];
|
||||
}
|
||||
53
entry/src/main/ets/model/PlanModels.ets
Normal file
53
entry/src/main/ets/model/PlanModels.ets
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 计划类型常量(值即中文名,与小程序一致)。
|
||||
*/
|
||||
export class PlanType {
|
||||
static readonly WEEKLY: string = '周计划';
|
||||
static readonly MONTHLY: string = '月计划';
|
||||
static readonly YEARLY: string = '年计划';
|
||||
|
||||
static readonly ALL: string[] = [
|
||||
PlanType.WEEKLY,
|
||||
PlanType.MONTHLY,
|
||||
PlanType.YEARLY
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 计划小组成员。
|
||||
*/
|
||||
@ObservedV2
|
||||
export class PlanMember {
|
||||
@Trace id: string = '';
|
||||
@Trace name: string = '';
|
||||
@Trace filled: boolean = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计划小组(含进度与成员)。
|
||||
*/
|
||||
@ObservedV2
|
||||
export class PlanGroup {
|
||||
@Trace id: string = '';
|
||||
@Trace name: string = '';
|
||||
@Trace checkedCount: number = 0;
|
||||
@Trace memberCount: number = 0;
|
||||
@Trace members: PlanMember[] = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 计划页数据。
|
||||
*/
|
||||
@ObservedV2
|
||||
export class PlanRecord {
|
||||
@Trace type: string = PlanType.WEEKLY;
|
||||
@Trace periodKey: string = '';
|
||||
@Trace canSubmit: boolean = true;
|
||||
@Trace message: string = '';
|
||||
@Trace myChecked: boolean = false;
|
||||
@Trace myContent: string = '';
|
||||
@Trace mySubmittedTime: string = '';
|
||||
@Trace groups: PlanGroup[] = [];
|
||||
@Trace members: PlanMember[] = [];
|
||||
@Trace unassignedCount: number = 0;
|
||||
}
|
||||
25
entry/src/main/ets/model/ProfileModels.ets
Normal file
25
entry/src/main/ets/model/ProfileModels.ets
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 班级分组(我的页)。
|
||||
*/
|
||||
@ObservedV2
|
||||
export class ClassGroup {
|
||||
@Trace period: string = '';
|
||||
@Trace group: string = '';
|
||||
@Trace role: string = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户资料。
|
||||
*/
|
||||
@ObservedV2
|
||||
export class UserProfile {
|
||||
@Trace displayName: string = '';
|
||||
@Trace name: string = '';
|
||||
@Trace studentId: string = '';
|
||||
@Trace phone: string = '';
|
||||
@Trace role: string = '';
|
||||
@Trace group: string = '';
|
||||
@Trace loginType: string = '';
|
||||
@Trace avatarUrl: string = '';
|
||||
@Trace classGroups: ClassGroup[] = [];
|
||||
}
|
||||
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 = '';
|
||||
}
|
||||
232
entry/src/main/ets/pages/CheckinPage.ets
Normal file
232
entry/src/main/ets/pages/CheckinPage.ets
Normal file
@@ -0,0 +1,232 @@
|
||||
import { AppColor, AppSpacing, AppFontSize, AppRadius, LoadingView, ErrorState, LoggedOutState, EmptyState, SegmentedControl, Avatar, StatusBadge, StatusPill } from 'uikit';
|
||||
import { SessionManager, SessionStore } from 'common';
|
||||
import { CheckinViewModel } from '../viewmodel/CheckinViewModel';
|
||||
import { CheckinStatus, GroupMember, PersonalCheckinRecord } from '../model/CheckinModels';
|
||||
|
||||
/**
|
||||
* 打卡状态 -> 颜色映射(领域逻辑放在页面层,保持 uikit 通用)。
|
||||
*/
|
||||
function checkinStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case CheckinStatus.NORMAL:
|
||||
return AppColor.statusNormal;
|
||||
case CheckinStatus.LATE:
|
||||
return AppColor.statusLate;
|
||||
case CheckinStatus.LEAVE:
|
||||
return AppColor.statusLeave;
|
||||
case CheckinStatus.EARLY_LEAVE:
|
||||
return AppColor.statusEarlyLeave;
|
||||
case CheckinStatus.ABSENT:
|
||||
return AppColor.statusAbsent;
|
||||
default:
|
||||
return AppColor.gold;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打卡页:顶部打卡卡 + 小组/个人记录。
|
||||
*/
|
||||
@ComponentV2
|
||||
export struct CheckinPage {
|
||||
@Local viewModel: CheckinViewModel = CheckinViewModel.create();
|
||||
@Local session: SessionStore = SessionManager.get();
|
||||
@Local checkinTab: number = 0;
|
||||
@Event onGoLogin: () => void;
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.viewModel.load();
|
||||
}
|
||||
|
||||
@Monitor('session.token')
|
||||
onSessionChange(): void {
|
||||
this.viewModel.load();
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
if (!this.viewModel.loggedIn) {
|
||||
LoggedOutState({ message: '登录后即可打卡', onLogin: () => { this.onGoLogin(); } })
|
||||
} else if (this.viewModel.loading) {
|
||||
Column() {
|
||||
LoadingView({ text: '加载中...' })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
} else if (this.viewModel.error.length > 0) {
|
||||
ErrorState({ message: this.viewModel.error, onRetry: () => { this.viewModel.load(); } })
|
||||
} else {
|
||||
Scroll() {
|
||||
Column({ space: AppSpacing.md }) {
|
||||
this.statusCard()
|
||||
this.records()
|
||||
}
|
||||
.width('100%')
|
||||
.padding(AppSpacing.lg)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.scrollBar(BarState.Off)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(AppColor.background)
|
||||
}
|
||||
|
||||
@Builder
|
||||
statusCard() {
|
||||
Column({ space: AppSpacing.md }) {
|
||||
Row({ space: AppSpacing.md }) {
|
||||
Column({ space: 2 }) {
|
||||
Text(this.viewModel.record.weekday)
|
||||
.fontSize(AppFontSize.caption)
|
||||
.fontColor(AppColor.goldLight)
|
||||
Text(this.viewModel.record.dayNum)
|
||||
.fontSize(24)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(AppColor.white)
|
||||
}
|
||||
.width(64)
|
||||
.padding({ top: 12, bottom: 12 })
|
||||
.backgroundColor(AppColor.deepBrown)
|
||||
.borderRadius(AppRadius.md)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
|
||||
Column({ space: 4 }) {
|
||||
Text(this.viewModel.record.checked ? '已打卡' : '未打卡')
|
||||
.fontSize(22)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(this.viewModel.record.checked ? AppColor.statusNormal : AppColor.gold)
|
||||
Text(this.viewModel.record.checked ? '已完成今日打卡' : '请选择状态完成打卡')
|
||||
.fontSize(AppFontSize.caption)
|
||||
.fontColor(AppColor.textMuted)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
Column({ space: 8 }) {
|
||||
Row({ space: 8 }) {
|
||||
ForEach(CheckinStatus.ALL.slice(0, 3), (status: string) => {
|
||||
StatusPill({
|
||||
label: status,
|
||||
color: checkinStatusColor(status),
|
||||
selected: this.viewModel.record.status === status,
|
||||
onTap: () => { this.viewModel.submit(status); }
|
||||
})
|
||||
}, (status: string) => status)
|
||||
}
|
||||
Row({ space: 8 }) {
|
||||
ForEach(CheckinStatus.ALL.slice(3), (status: string) => {
|
||||
StatusPill({
|
||||
label: status,
|
||||
color: checkinStatusColor(status),
|
||||
selected: this.viewModel.record.status === status,
|
||||
onTap: () => { this.viewModel.submit(status); }
|
||||
})
|
||||
}, (status: string) => status)
|
||||
}
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(AppSpacing.md)
|
||||
.backgroundColor(AppColor.white)
|
||||
.borderRadius(AppRadius.lg)
|
||||
}
|
||||
|
||||
@Builder
|
||||
records() {
|
||||
Column({ space: AppSpacing.md }) {
|
||||
SegmentedControl({
|
||||
items: ['小组', '个人'],
|
||||
selectedIndex: this.checkinTab,
|
||||
onSelect: (index: number) => { this.checkinTab = index; }
|
||||
})
|
||||
|
||||
if (this.checkinTab === 0) {
|
||||
this.groupTab()
|
||||
} else {
|
||||
this.personalTab()
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
@Builder
|
||||
groupTab() {
|
||||
Column() {
|
||||
if (this.viewModel.record.groupMembers.length === 0) {
|
||||
EmptyState({ emoji: '📭', message: '暂无成员数据' })
|
||||
} else {
|
||||
ForEach(this.viewModel.record.groupMembers, (m: GroupMember) => {
|
||||
Row({ space: AppSpacing.md }) {
|
||||
Avatar({ name: m.name, diameter: 40 })
|
||||
Column({ space: 2 }) {
|
||||
Text(m.name)
|
||||
.fontSize(AppFontSize.body)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(AppColor.textPrimary)
|
||||
if (m.checkedIn) {
|
||||
Text('打卡 ' + m.checkinTime)
|
||||
.fontSize(AppFontSize.caption)
|
||||
.fontColor(AppColor.textMuted)
|
||||
}
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
if (m.checkedIn) {
|
||||
StatusBadge({ label: m.status, color: checkinStatusColor(m.status) })
|
||||
} else {
|
||||
Text('未打卡')
|
||||
.fontSize(AppFontSize.caption)
|
||||
.fontColor(AppColor.textMuted)
|
||||
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
|
||||
.backgroundColor(AppColor.neutralChip)
|
||||
.borderRadius(AppRadius.pill)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 12, bottom: 12 })
|
||||
}, (m: GroupMember) => m.id)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.backgroundColor(AppColor.white)
|
||||
.borderRadius(AppRadius.lg)
|
||||
.padding({ left: 16, right: 16, top: 4, bottom: 4 })
|
||||
}
|
||||
|
||||
@Builder
|
||||
personalTab() {
|
||||
Column({ space: AppSpacing.sm }) {
|
||||
if (this.viewModel.record.personalHistory.length === 0) {
|
||||
EmptyState({ emoji: '📒', message: '暂无个人打卡记录' })
|
||||
} else {
|
||||
ForEach(this.viewModel.record.personalHistory, (h: PersonalCheckinRecord) => {
|
||||
Row() {
|
||||
Column({ space: 2 }) {
|
||||
Text(h.date)
|
||||
.fontSize(AppFontSize.body)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(AppColor.textPrimary)
|
||||
Text(h.time)
|
||||
.fontSize(AppFontSize.caption)
|
||||
.fontColor(AppColor.textMutedLight)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
StatusBadge({ label: h.status, color: checkinStatusColor(h.status) })
|
||||
}
|
||||
.width('100%')
|
||||
.padding(AppSpacing.md)
|
||||
.backgroundColor(AppColor.white)
|
||||
.borderRadius(AppRadius.md)
|
||||
}, (h: PersonalCheckinRecord) => h.id)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
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('详情')
|
||||
}
|
||||
}
|
||||
155
entry/src/main/ets/pages/HomePage.ets
Normal file
155
entry/src/main/ets/pages/HomePage.ets
Normal file
@@ -0,0 +1,155 @@
|
||||
import { AppColor, AppSpacing, AppFontSize, AppRadius, LoadingView, ErrorState } from 'uikit';
|
||||
import { SessionManager, SessionStore } from 'common';
|
||||
import { HomeViewModel } from '../viewmodel/HomeViewModel';
|
||||
import { HomeModule, IntroCard } from '../model/HomeModels';
|
||||
|
||||
/**
|
||||
* 首页:未登录走游客介绍页,已登录走原生首页(Hero + 模块 + 产品介绍)。
|
||||
*/
|
||||
@ComponentV2
|
||||
export struct HomePage {
|
||||
@Local viewModel: HomeViewModel = HomeViewModel.create();
|
||||
@Local session: SessionStore = SessionManager.get();
|
||||
@Event onGoLogin: () => void;
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.viewModel.load();
|
||||
}
|
||||
|
||||
@Monitor('session.token')
|
||||
onSessionChange(): void {
|
||||
this.viewModel.load();
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
if (this.viewModel.loading) {
|
||||
LoadingView({ text: '加载中...' })
|
||||
} else if (this.viewModel.error.length > 0) {
|
||||
ErrorState({ message: this.viewModel.error, onRetry: () => { this.viewModel.load(); } })
|
||||
} else {
|
||||
Scroll() {
|
||||
Column({ space: AppSpacing.md }) {
|
||||
this.hero()
|
||||
this.moduleGrid()
|
||||
this.introSection()
|
||||
}
|
||||
.width('100%')
|
||||
.padding(AppSpacing.lg)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.scrollBar(BarState.Off)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(AppColor.background)
|
||||
}
|
||||
|
||||
@Builder
|
||||
hero() {
|
||||
Column({ space: AppSpacing.sm }) {
|
||||
Text(this.viewModel.data.greeting)
|
||||
.fontSize(AppFontSize.caption)
|
||||
.fontColor(AppColor.goldLight)
|
||||
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
|
||||
.backgroundColor(AppColor.goldChip)
|
||||
.borderRadius(AppRadius.pill)
|
||||
|
||||
Text(this.viewModel.data.displayName)
|
||||
.fontSize(32)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(AppColor.white)
|
||||
|
||||
Text(this.viewModel.data.encouragement)
|
||||
.fontSize(AppFontSize.body)
|
||||
.fontColor(AppColor.goldLight)
|
||||
|
||||
if (this.viewModel.isGuest) {
|
||||
Text('登录账号解锁完整功能 ›')
|
||||
.fontSize(AppFontSize.caption)
|
||||
.fontColor(AppColor.goldLight)
|
||||
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
|
||||
.backgroundColor(AppColor.goldChipStrong)
|
||||
.borderRadius(AppRadius.pill)
|
||||
.onClick(() => {
|
||||
this.onGoLogin();
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.padding(AppSpacing.lg)
|
||||
.borderRadius(AppRadius.xl)
|
||||
.linearGradient({ angle: 135, colors: [[AppColor.heroStart, 0.0], [AppColor.heroMid, 0.55], [AppColor.heroEnd, 1.0]] })
|
||||
}
|
||||
|
||||
@Builder
|
||||
moduleGrid() {
|
||||
Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
|
||||
ForEach(this.viewModel.data.modules, (m: HomeModule) => {
|
||||
Column({ space: AppSpacing.sm }) {
|
||||
Stack({ alignContent: Alignment.Center }) {
|
||||
Text(m.emoji)
|
||||
.fontSize(20)
|
||||
}
|
||||
.width(48)
|
||||
.height(48)
|
||||
.borderRadius(24)
|
||||
.backgroundColor(AppColor.neutralChip)
|
||||
|
||||
Text(m.title)
|
||||
.fontSize(AppFontSize.body)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(AppColor.textPrimary)
|
||||
|
||||
Text(m.description)
|
||||
.fontSize(AppFontSize.caption)
|
||||
.fontColor(AppColor.textMuted)
|
||||
}
|
||||
.width('31%')
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.padding({ top: 20, bottom: 20, left: 8, right: 8 })
|
||||
.backgroundColor(AppColor.white)
|
||||
.borderRadius(AppRadius.lg)
|
||||
}, (m: HomeModule) => m.id)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
@Builder
|
||||
introSection() {
|
||||
Column({ space: AppSpacing.md }) {
|
||||
Text('产品介绍')
|
||||
.fontSize(AppFontSize.title)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(AppColor.textPrimary)
|
||||
|
||||
ForEach(this.viewModel.data.intros, (intro: IntroCard) => {
|
||||
Row({ space: AppSpacing.md }) {
|
||||
Text(intro.emoji)
|
||||
.fontSize(22)
|
||||
Column({ space: AppSpacing.xs }) {
|
||||
Text(intro.heading)
|
||||
.fontSize(AppFontSize.body)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(AppColor.gold)
|
||||
Text(intro.body)
|
||||
.fontSize(AppFontSize.caption)
|
||||
.fontColor(AppColor.textSecondary)
|
||||
.lineHeight(20)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(AppSpacing.md)
|
||||
.backgroundColor(AppColor.white)
|
||||
.borderRadius(AppRadius.lg)
|
||||
}, (intro: IntroCard) => intro.heading)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,60 @@
|
||||
import { AppColor, AppFontSize } from 'uikit';
|
||||
import { HomePage } from './HomePage';
|
||||
import { CheckinPage } from './CheckinPage';
|
||||
import { PlanPage } from './PlanPage';
|
||||
import { ProfilePage } from './ProfilePage';
|
||||
|
||||
/**
|
||||
* 应用入口:底部四 tab 壳(首页 / 打卡 / 计划 / 我的)。
|
||||
*/
|
||||
@Entry
|
||||
@Component
|
||||
@ComponentV2
|
||||
struct Index {
|
||||
@State message: string = 'Hello World';
|
||||
@Local currentIndex: number = 0;
|
||||
|
||||
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';
|
||||
})
|
||||
Tabs({ barPosition: BarPosition.End, index: this.currentIndex }) {
|
||||
TabContent() {
|
||||
HomePage({ onGoLogin: () => { this.currentIndex = 3; } })
|
||||
}
|
||||
.tabBar(this.tabBarBuilder(0, '首页', '🏠'))
|
||||
|
||||
TabContent() {
|
||||
CheckinPage({ onGoLogin: () => { this.currentIndex = 3; } })
|
||||
}
|
||||
.tabBar(this.tabBarBuilder(1, '打卡', '✅'))
|
||||
|
||||
TabContent() {
|
||||
PlanPage({ onGoLogin: () => { this.currentIndex = 3; } })
|
||||
}
|
||||
.tabBar(this.tabBarBuilder(2, '计划', '📝'))
|
||||
|
||||
TabContent() {
|
||||
ProfilePage()
|
||||
}
|
||||
.tabBar(this.tabBarBuilder(3, '我的', '👤'))
|
||||
}
|
||||
.height('100%')
|
||||
.onChange((index: number) => {
|
||||
this.currentIndex = index;
|
||||
})
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(AppColor.background)
|
||||
}
|
||||
|
||||
@Builder
|
||||
tabBarBuilder(index: number, title: string, icon: string) {
|
||||
Column({ space: 2 }) {
|
||||
Text(icon)
|
||||
.fontSize(20)
|
||||
Text(title)
|
||||
.fontSize(AppFontSize.caption)
|
||||
.fontColor(index === this.currentIndex ? AppColor.tabActive : AppColor.tabInactive)
|
||||
.fontWeight(index === this.currentIndex ? FontWeight.Bold : FontWeight.Normal)
|
||||
}
|
||||
.width('100%')
|
||||
.height(52)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.backgroundColor(AppColor.tabBg)
|
||||
}
|
||||
}
|
||||
183
entry/src/main/ets/pages/PlanPage.ets
Normal file
183
entry/src/main/ets/pages/PlanPage.ets
Normal file
@@ -0,0 +1,183 @@
|
||||
import { AppColor, AppSpacing, AppFontSize, AppRadius, LoadingView, ErrorState, LoggedOutState, SegmentedControl, ProgressBar } from 'uikit';
|
||||
import { SessionManager, SessionStore } from 'common';
|
||||
import { PlanViewModel } from '../viewmodel/PlanViewModel';
|
||||
import { PlanType, PlanGroup } from '../model/PlanModels';
|
||||
|
||||
/**
|
||||
* 计划页:周/月/年切换 + 我的计划状态 + 小组进度。
|
||||
*/
|
||||
@ComponentV2
|
||||
export struct PlanPage {
|
||||
@Local viewModel: PlanViewModel = PlanViewModel.create();
|
||||
@Local session: SessionStore = SessionManager.get();
|
||||
@Event onGoLogin: () => void;
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.viewModel.load();
|
||||
}
|
||||
|
||||
@Monitor('session.token')
|
||||
onSessionChange(): void {
|
||||
this.viewModel.load();
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
if (!this.viewModel.loggedIn) {
|
||||
LoggedOutState({ message: '登录后即可提交计划', onLogin: () => { this.onGoLogin(); } })
|
||||
} else if (this.viewModel.loading) {
|
||||
Column() {
|
||||
LoadingView({ text: '加载中...' })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
} else if (this.viewModel.error.length > 0) {
|
||||
ErrorState({ message: this.viewModel.error, onRetry: () => { this.viewModel.load(); } })
|
||||
} else {
|
||||
Scroll() {
|
||||
Column({ space: AppSpacing.md }) {
|
||||
SegmentedControl({
|
||||
items: PlanType.ALL,
|
||||
selectedIndex: PlanType.ALL.indexOf(this.viewModel.selectedType),
|
||||
onSelect: (index: number) => { this.viewModel.selectType(PlanType.ALL[index]); }
|
||||
})
|
||||
this.head()
|
||||
this.myStatusCard()
|
||||
if (this.viewModel.record.message.length > 0) {
|
||||
Text(this.viewModel.record.message)
|
||||
.fontSize(AppFontSize.caption)
|
||||
.fontColor(AppColor.danger)
|
||||
.width('100%')
|
||||
.padding({ top: 10, bottom: 10 })
|
||||
.textAlign(TextAlign.Center)
|
||||
.backgroundColor(AppColor.warningBg)
|
||||
.borderRadius(AppRadius.md)
|
||||
}
|
||||
this.groupList()
|
||||
}
|
||||
.width('100%')
|
||||
.padding(AppSpacing.lg)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.scrollBar(BarState.Off)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(AppColor.creamDark)
|
||||
}
|
||||
|
||||
@Builder
|
||||
head() {
|
||||
Row() {
|
||||
Column({ space: 2 }) {
|
||||
Text(this.viewModel.selectedType)
|
||||
.fontSize(24)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(AppColor.textPrimary)
|
||||
Text(this.viewModel.record.periodKey)
|
||||
.fontSize(AppFontSize.caption)
|
||||
.fontColor(AppColor.textMutedLight)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Text(this.viewModel.record.myChecked ? '已提交' : '待提交')
|
||||
.fontSize(AppFontSize.caption)
|
||||
.fontColor(this.viewModel.record.myChecked ? AppColor.statusNormal : AppColor.gold)
|
||||
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
|
||||
.backgroundColor(this.viewModel.record.myChecked ? AppColor.successBg : AppColor.warningBg)
|
||||
.borderRadius(AppRadius.pill)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 4, right: 4 })
|
||||
}
|
||||
|
||||
@Builder
|
||||
myStatusCard() {
|
||||
Row({ space: AppSpacing.md }) {
|
||||
Stack({ alignContent: Alignment.Center }) {
|
||||
Text(this.viewModel.record.myChecked ? '✓' : '●')
|
||||
.fontSize(20)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(AppColor.white)
|
||||
}
|
||||
.width(48)
|
||||
.height(48)
|
||||
.borderRadius(24)
|
||||
.backgroundColor(this.viewModel.record.myChecked ? AppColor.statusNormal : AppColor.danger)
|
||||
|
||||
Column({ space: 4 }) {
|
||||
Text(this.viewModel.record.myChecked ? '已完成' : '未完成')
|
||||
.fontSize(AppFontSize.caption)
|
||||
.fontColor(AppColor.textMuted)
|
||||
Text('我的' + this.viewModel.selectedType)
|
||||
.fontSize(AppFontSize.title)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(AppColor.textPrimary)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Text(this.viewModel.record.myChecked ? '修改' : '提交计划')
|
||||
.fontSize(AppFontSize.body)
|
||||
.fontColor(AppColor.white)
|
||||
.padding({ left: 20, right: 20, top: 10, bottom: 10 })
|
||||
.backgroundColor(AppColor.gold)
|
||||
.borderRadius(AppRadius.pill)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(AppSpacing.md)
|
||||
.backgroundColor(AppColor.white)
|
||||
.borderRadius(AppRadius.lg)
|
||||
}
|
||||
|
||||
@Builder
|
||||
groupList() {
|
||||
Column({ space: AppSpacing.sm }) {
|
||||
Text('小组统计')
|
||||
.fontSize(AppFontSize.title)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(AppColor.textPrimary)
|
||||
.width('100%')
|
||||
|
||||
ForEach(this.viewModel.record.groups, (g: PlanGroup, index: number) => {
|
||||
Column({ space: AppSpacing.sm }) {
|
||||
Row({ space: AppSpacing.sm }) {
|
||||
Text((index + 1).toString())
|
||||
.fontSize(AppFontSize.caption)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(AppColor.gold)
|
||||
.width(36)
|
||||
.height(36)
|
||||
.borderRadius(10)
|
||||
.textAlign(TextAlign.Center)
|
||||
.backgroundColor(AppColor.neutralChip)
|
||||
|
||||
Text(g.name)
|
||||
.fontSize(AppFontSize.body)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(AppColor.textPrimary)
|
||||
|
||||
Blank()
|
||||
|
||||
Text('已提交 ' + g.checkedCount + ' · ' + g.memberCount + '人')
|
||||
.fontSize(AppFontSize.caption)
|
||||
.fontColor(AppColor.textMuted)
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
ProgressBar({ percent: g.memberCount > 0 ? Math.round(g.checkedCount / g.memberCount * 100) : 0 })
|
||||
}
|
||||
.width('100%')
|
||||
.padding(AppSpacing.md)
|
||||
.backgroundColor(AppColor.white)
|
||||
.borderRadius(AppRadius.lg)
|
||||
}, (g: PlanGroup) => g.id)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
184
entry/src/main/ets/pages/ProfilePage.ets
Normal file
184
entry/src/main/ets/pages/ProfilePage.ets
Normal file
@@ -0,0 +1,184 @@
|
||||
import { AppColor, AppSpacing, AppFontSize, AppRadius, LoadingView, ErrorState, Avatar, LabeledRow } from 'uikit';
|
||||
import { SessionManager, SessionStore } from 'common';
|
||||
import { ProfileViewModel } from '../viewmodel/ProfileViewModel';
|
||||
import { ClassGroup } from '../model/ProfileModels';
|
||||
|
||||
/**
|
||||
* 我的页:资料展示 + 模拟登录 / 退出登录。
|
||||
*/
|
||||
@ComponentV2
|
||||
export struct ProfilePage {
|
||||
@Local viewModel: ProfileViewModel = ProfileViewModel.create();
|
||||
@Local session: SessionStore = SessionManager.get();
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.viewModel.load();
|
||||
}
|
||||
|
||||
@Monitor('session.token')
|
||||
onSessionChange(): void {
|
||||
this.viewModel.load();
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
if (!this.viewModel.loggedIn) {
|
||||
this.loginCard()
|
||||
} else if (this.viewModel.loading) {
|
||||
Column() {
|
||||
LoadingView({ text: '加载中...' })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
} else if (this.viewModel.error.length > 0) {
|
||||
ErrorState({ message: this.viewModel.error, onRetry: () => { this.viewModel.load(); } })
|
||||
} else {
|
||||
Scroll() {
|
||||
Column({ space: AppSpacing.md }) {
|
||||
this.profileHeader()
|
||||
this.infoCard()
|
||||
if (this.viewModel.profile.classGroups.length > 0) {
|
||||
this.classGroupCard()
|
||||
}
|
||||
this.logoutButton()
|
||||
}
|
||||
.width('100%')
|
||||
.padding(AppSpacing.lg)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.scrollBar(BarState.Off)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(AppColor.background)
|
||||
}
|
||||
|
||||
@Builder
|
||||
loginCard() {
|
||||
Column({ space: AppSpacing.md }) {
|
||||
Text('👤')
|
||||
.fontSize(40)
|
||||
Text('未登录')
|
||||
.fontSize(AppFontSize.title)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(AppColor.textPrimary)
|
||||
Text('登录后即可查看个人信息')
|
||||
.fontSize(AppFontSize.body)
|
||||
.fontColor(AppColor.textMuted)
|
||||
Text('模拟登录')
|
||||
.fontSize(AppFontSize.body)
|
||||
.fontColor(AppColor.white)
|
||||
.padding({ left: 32, right: 32, top: 10, bottom: 10 })
|
||||
.backgroundColor(AppColor.gold)
|
||||
.borderRadius(AppRadius.pill)
|
||||
.onClick(() => {
|
||||
this.viewModel.login();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
}
|
||||
|
||||
@Builder
|
||||
profileHeader() {
|
||||
Row({ space: AppSpacing.md }) {
|
||||
Avatar({ name: this.viewModel.profile.displayName, diameter: 56 })
|
||||
Column({ space: 4 }) {
|
||||
Row({ space: 8 }) {
|
||||
Text(this.viewModel.profile.displayName)
|
||||
.fontSize(AppFontSize.headline)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(AppColor.textPrimary)
|
||||
Text(this.viewModel.profile.role)
|
||||
.fontSize(AppFontSize.caption)
|
||||
.fontColor(AppColor.gold)
|
||||
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
|
||||
.backgroundColor(AppColor.neutralChip)
|
||||
.borderRadius(AppRadius.pill)
|
||||
}
|
||||
Text(this.viewModel.profile.studentId)
|
||||
.fontSize(AppFontSize.caption)
|
||||
.fontColor(AppColor.textMuted)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(AppSpacing.md)
|
||||
.backgroundColor(AppColor.white)
|
||||
.borderRadius(AppRadius.lg)
|
||||
}
|
||||
|
||||
@Builder
|
||||
infoCard() {
|
||||
Column({ space: 0 }) {
|
||||
Text('个人信息')
|
||||
.fontSize(AppFontSize.title)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(AppColor.textPrimary)
|
||||
.width('100%')
|
||||
.margin({ bottom: 4 })
|
||||
|
||||
LabeledRow({ label: '姓名', value: this.viewModel.profile.name })
|
||||
LabeledRow({ label: '学号', value: this.viewModel.profile.studentId })
|
||||
LabeledRow({ label: '手机号', value: this.viewModel.profile.phone })
|
||||
LabeledRow({ label: '角色', value: this.viewModel.profile.role })
|
||||
LabeledRow({ label: '小组', value: this.viewModel.profile.group })
|
||||
LabeledRow({ label: '登录方式', value: this.viewModel.profile.loginType })
|
||||
}
|
||||
.width('100%')
|
||||
.padding(AppSpacing.md)
|
||||
.backgroundColor(AppColor.white)
|
||||
.borderRadius(AppRadius.lg)
|
||||
}
|
||||
|
||||
@Builder
|
||||
classGroupCard() {
|
||||
Column({ space: 0 }) {
|
||||
Text('小组分组')
|
||||
.fontSize(AppFontSize.title)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(AppColor.textPrimary)
|
||||
.width('100%')
|
||||
.margin({ bottom: 4 })
|
||||
|
||||
ForEach(this.viewModel.profile.classGroups, (cg: ClassGroup) => {
|
||||
Row() {
|
||||
Text(cg.period)
|
||||
.fontSize(AppFontSize.body)
|
||||
.fontColor(AppColor.textMuted)
|
||||
Blank()
|
||||
Text(cg.group + ' · ' + cg.role)
|
||||
.fontSize(AppFontSize.body)
|
||||
.fontColor(AppColor.textPrimary)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 12, bottom: 12 })
|
||||
}, (cg: ClassGroup) => cg.period + cg.group)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(AppSpacing.md)
|
||||
.backgroundColor(AppColor.white)
|
||||
.borderRadius(AppRadius.lg)
|
||||
}
|
||||
|
||||
@Builder
|
||||
logoutButton() {
|
||||
Text('退出登录')
|
||||
.fontSize(AppFontSize.body)
|
||||
.fontColor(AppColor.danger)
|
||||
.width('100%')
|
||||
.height(48)
|
||||
.textAlign(TextAlign.Center)
|
||||
.backgroundColor(AppColor.white)
|
||||
.borderRadius(AppRadius.md)
|
||||
.border({ width: 1, color: AppColor.danger })
|
||||
.onClick(() => {
|
||||
this.viewModel.logout();
|
||||
})
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
62
entry/src/main/ets/viewmodel/CheckinViewModel.ets
Normal file
62
entry/src/main/ets/viewmodel/CheckinViewModel.ets
Normal file
@@ -0,0 +1,62 @@
|
||||
import { CheckinRecord } from '../model/CheckinModels';
|
||||
import { CheckinRepository } from '../data/repository/CheckinRepository';
|
||||
import { SessionRepository } from '../data/repository/SessionRepository';
|
||||
import { ServiceLocator } from '../di/ServiceLocator';
|
||||
|
||||
/**
|
||||
* 打卡视图模型:加载打卡数据 + 提交打卡状态。
|
||||
*/
|
||||
@ObservedV2
|
||||
export class CheckinViewModel {
|
||||
@Trace record: CheckinRecord = new CheckinRecord();
|
||||
@Trace loggedIn: boolean = false;
|
||||
@Trace loading: boolean = false;
|
||||
@Trace submitting: boolean = false;
|
||||
@Trace error: string = '';
|
||||
|
||||
private readonly repository: CheckinRepository;
|
||||
private readonly session: SessionRepository;
|
||||
|
||||
constructor(repository: CheckinRepository, session: SessionRepository) {
|
||||
this.repository = repository;
|
||||
this.session = session;
|
||||
}
|
||||
|
||||
static create(): CheckinViewModel {
|
||||
return new CheckinViewModel(
|
||||
ServiceLocator.get('checkinRepository') as CheckinRepository,
|
||||
ServiceLocator.get('sessionRepository') as SessionRepository
|
||||
);
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
this.loggedIn = this.session.isLoggedIn();
|
||||
if (!this.loggedIn) {
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.record = await this.repository.observeCheckin();
|
||||
} catch (err) {
|
||||
this.error = JSON.stringify(err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async submit(status: string): Promise<void> {
|
||||
if (this.submitting) {
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
try {
|
||||
this.record = await this.repository.submitCheckin(status);
|
||||
} catch (err) {
|
||||
this.error = JSON.stringify(err);
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
45
entry/src/main/ets/viewmodel/HomeViewModel.ets
Normal file
45
entry/src/main/ets/viewmodel/HomeViewModel.ets
Normal file
@@ -0,0 +1,45 @@
|
||||
import { HomeData } from '../model/HomeModels';
|
||||
import { HomeRepository } from '../data/repository/HomeRepository';
|
||||
import { SessionRepository } from '../data/repository/SessionRepository';
|
||||
import { ServiceLocator } from '../di/ServiceLocator';
|
||||
|
||||
/**
|
||||
* 首页视图模型:@ObservedV2 三态(loading / error / data)。
|
||||
* 根据登录态分流到游客首页 / 原生首页。
|
||||
*/
|
||||
@ObservedV2
|
||||
export class HomeViewModel {
|
||||
@Trace data: HomeData = new HomeData();
|
||||
@Trace isGuest: boolean = true;
|
||||
@Trace loading: boolean = false;
|
||||
@Trace error: string = '';
|
||||
|
||||
private readonly repository: HomeRepository;
|
||||
private readonly session: SessionRepository;
|
||||
|
||||
constructor(repository: HomeRepository, session: SessionRepository) {
|
||||
this.repository = repository;
|
||||
this.session = session;
|
||||
}
|
||||
|
||||
static create(): HomeViewModel {
|
||||
return new HomeViewModel(
|
||||
ServiceLocator.get('homeRepository') as HomeRepository,
|
||||
ServiceLocator.get('sessionRepository') as SessionRepository
|
||||
);
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
try {
|
||||
const loggedIn = this.session.isLoggedIn();
|
||||
this.isGuest = !loggedIn;
|
||||
this.data = loggedIn ? await this.repository.observeHome() : await this.repository.observeGuestHome();
|
||||
} catch (err) {
|
||||
this.error = JSON.stringify(err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
56
entry/src/main/ets/viewmodel/PlanViewModel.ets
Normal file
56
entry/src/main/ets/viewmodel/PlanViewModel.ets
Normal file
@@ -0,0 +1,56 @@
|
||||
import { PlanRecord, PlanType } from '../model/PlanModels';
|
||||
import { PlanRepository } from '../data/repository/PlanRepository';
|
||||
import { SessionRepository } from '../data/repository/SessionRepository';
|
||||
import { ServiceLocator } from '../di/ServiceLocator';
|
||||
|
||||
/**
|
||||
* 计划视图模型:周/月/年类型切换 + 计划数据加载。
|
||||
*/
|
||||
@ObservedV2
|
||||
export class PlanViewModel {
|
||||
@Trace record: PlanRecord = new PlanRecord();
|
||||
@Trace selectedType: string = PlanType.WEEKLY;
|
||||
@Trace loggedIn: boolean = false;
|
||||
@Trace loading: boolean = false;
|
||||
@Trace error: string = '';
|
||||
|
||||
private readonly repository: PlanRepository;
|
||||
private readonly session: SessionRepository;
|
||||
|
||||
constructor(repository: PlanRepository, session: SessionRepository) {
|
||||
this.repository = repository;
|
||||
this.session = session;
|
||||
}
|
||||
|
||||
static create(): PlanViewModel {
|
||||
return new PlanViewModel(
|
||||
ServiceLocator.get('planRepository') as PlanRepository,
|
||||
ServiceLocator.get('sessionRepository') as SessionRepository
|
||||
);
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
this.loggedIn = this.session.isLoggedIn();
|
||||
if (!this.loggedIn) {
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.record = await this.repository.observePlan(this.selectedType);
|
||||
} catch (err) {
|
||||
this.error = JSON.stringify(err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async selectType(type: string): Promise<void> {
|
||||
if (this.selectedType === type) {
|
||||
return;
|
||||
}
|
||||
this.selectedType = type;
|
||||
await this.load();
|
||||
}
|
||||
}
|
||||
58
entry/src/main/ets/viewmodel/ProfileViewModel.ets
Normal file
58
entry/src/main/ets/viewmodel/ProfileViewModel.ets
Normal file
@@ -0,0 +1,58 @@
|
||||
import { UserProfile } from '../model/ProfileModels';
|
||||
import { ProfileRepository } from '../data/repository/ProfileRepository';
|
||||
import { SessionRepository } from '../data/repository/SessionRepository';
|
||||
import { ServiceLocator } from '../di/ServiceLocator';
|
||||
|
||||
/**
|
||||
* 我的视图模型:资料加载 + 模拟登录 / 退出登录。
|
||||
*/
|
||||
@ObservedV2
|
||||
export class ProfileViewModel {
|
||||
@Trace profile: UserProfile = new UserProfile();
|
||||
@Trace loggedIn: boolean = false;
|
||||
@Trace loading: boolean = false;
|
||||
@Trace error: string = '';
|
||||
|
||||
private readonly repository: ProfileRepository;
|
||||
private readonly session: SessionRepository;
|
||||
|
||||
constructor(repository: ProfileRepository, session: SessionRepository) {
|
||||
this.repository = repository;
|
||||
this.session = session;
|
||||
}
|
||||
|
||||
static create(): ProfileViewModel {
|
||||
return new ProfileViewModel(
|
||||
ServiceLocator.get('profileRepository') as ProfileRepository,
|
||||
ServiceLocator.get('sessionRepository') as SessionRepository
|
||||
);
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
this.loggedIn = this.session.isLoggedIn();
|
||||
if (!this.loggedIn) {
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.profile = await this.repository.observeProfile();
|
||||
} catch (err) {
|
||||
this.error = JSON.stringify(err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async login(): Promise<void> {
|
||||
await this.session.login();
|
||||
await this.load();
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.session.logout();
|
||||
this.loggedIn = false;
|
||||
this.profile = new UserProfile();
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,11 @@
|
||||
],
|
||||
"deliveryWithInstall": true,
|
||||
"installationFree": false,
|
||||
"requestPermissions": [
|
||||
{
|
||||
"name": "ohos.permission.INTERNET"
|
||||
}
|
||||
],
|
||||
"pages": "$profile:main_pages",
|
||||
"abilities": [
|
||||
{
|
||||
|
||||
32
scripts/git-hooks/pre-commit
Executable file
32
scripts/git-hooks/pre-commit
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# 拦截提交包含签名凭据的 build-profile.json5。
|
||||
# Block commits that add signing credentials to build-profile.json5.
|
||||
# 详见 docs/build-deploy.md 的「签名管理」章节。
|
||||
|
||||
set -u
|
||||
|
||||
FILE="build-profile.json5"
|
||||
|
||||
# 仅当本次提交要改动 build-profile.json5 时才检查
|
||||
if git diff --cached --quiet -- "$FILE"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 读取暂存区版本,检测签名敏感字段
|
||||
if git show ":$FILE" | grep -qE '"(keyPassword|storePassword|certpath|storeFile|keyAlias|signAlg)"'; then
|
||||
cat >&2 <<'EOF'
|
||||
|
||||
❌ 已拦截提交:build-profile.json5 中含签名凭据(keyPassword / storePassword / certpath / …)。
|
||||
signingConfigs 必须保持为空 [] 才能提交。
|
||||
处理办法:
|
||||
- 若只是本地 DevEco 自动签名产生的:先取消暂存,再还原为空
|
||||
git reset HEAD build-profile.json5
|
||||
git checkout -- build-profile.json5
|
||||
- 确需本地跑真机:保持该改动「未提交」即可,不要 git add 它。
|
||||
如确实要临时绕过(不建议):git commit --no-verify
|
||||
详见 docs/build-deploy.md 的「签名管理」章节。
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
89
skills-lock.json
Normal file
89
skills-lock.json
Normal file
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"brainstorming": {
|
||||
"source": "obra/superpowers",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/brainstorming/SKILL.md",
|
||||
"computedHash": "b58d55b64742ecd37923985ec7bdfbe678ffbcb9895562abd28c0a32dcacd7fb"
|
||||
},
|
||||
"dispatching-parallel-agents": {
|
||||
"source": "obra/superpowers",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/dispatching-parallel-agents/SKILL.md",
|
||||
"computedHash": "b011107f17188e0b2898bb1856b5bac8b49537cc86e737c82126558a235bd62a"
|
||||
},
|
||||
"executing-plans": {
|
||||
"source": "obra/superpowers",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/executing-plans/SKILL.md",
|
||||
"computedHash": "78a07800ee99a5861ae10be59d79fad085fdca10444e8aaeb2691597e357cff8"
|
||||
},
|
||||
"finishing-a-development-branch": {
|
||||
"source": "obra/superpowers",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/finishing-a-development-branch/SKILL.md",
|
||||
"computedHash": "4f571e7befb8b0946d9d7770232c818812412e9ab1dfb811f847edfba1fc6275"
|
||||
},
|
||||
"receiving-code-review": {
|
||||
"source": "obra/superpowers",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/receiving-code-review/SKILL.md",
|
||||
"computedHash": "59361179fc9225173e0c145ffd9bafbf9fdbf8b43ce3dc308497a0b4f83a6063"
|
||||
},
|
||||
"requesting-code-review": {
|
||||
"source": "obra/superpowers",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/requesting-code-review/SKILL.md",
|
||||
"computedHash": "004f340ae65811095d6a9bb0a759c87d0a8085c5e705dbb019b011626293d2e9"
|
||||
},
|
||||
"subagent-driven-development": {
|
||||
"source": "obra/superpowers",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/subagent-driven-development/SKILL.md",
|
||||
"computedHash": "367cc1d3216d6672b947c8c6583dbd6440ea407beb6da75a97d3a8ee1a23640a"
|
||||
},
|
||||
"systematic-debugging": {
|
||||
"source": "obra/superpowers",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/systematic-debugging/SKILL.md",
|
||||
"computedHash": "a2750dfbaed1690da73b120254892c4f926b4576cb82eb16fc877783941fe39b"
|
||||
},
|
||||
"test-driven-development": {
|
||||
"source": "obra/superpowers",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/test-driven-development/SKILL.md",
|
||||
"computedHash": "a4126a9c5f17be3c228aa9e5755898ade283448b86099f56f40852e8c14532be"
|
||||
},
|
||||
"using-git-worktrees": {
|
||||
"source": "obra/superpowers",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/using-git-worktrees/SKILL.md",
|
||||
"computedHash": "48c8a1040e378e0cb7208107294e3afa9067b9370c3f33b49a443eb6b7528b0d"
|
||||
},
|
||||
"using-superpowers": {
|
||||
"source": "obra/superpowers",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/using-superpowers/SKILL.md",
|
||||
"computedHash": "c7abe8bdc2099bbe4f245454d32c7563ccd2bb41dcc218b02b461c2acc3c6ae6"
|
||||
},
|
||||
"verification-before-completion": {
|
||||
"source": "obra/superpowers",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/verification-before-completion/SKILL.md",
|
||||
"computedHash": "b529822bdac4b197b180fe12225cff86b930b65910435103c10622df7f041651"
|
||||
},
|
||||
"writing-plans": {
|
||||
"source": "obra/superpowers",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/writing-plans/SKILL.md",
|
||||
"computedHash": "27c75044e444a4fc68798c3f799dc23eaff3a6bfabff32e6ad75bb20f881c094"
|
||||
},
|
||||
"writing-skills": {
|
||||
"source": "obra/superpowers",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/writing-skills/SKILL.md",
|
||||
"computedHash": "e5dfc1a942f56dc757421b0d87ae1f38ab693ebc71138e65268fd408bb5c93d3"
|
||||
}
|
||||
}
|
||||
}
|
||||
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": {}
|
||||
}
|
||||
11
uikit/src/main/ets/Index.ets
Normal file
11
uikit/src/main/ets/Index.ets
Normal file
@@ -0,0 +1,11 @@
|
||||
export { AppColor, AppSpacing, AppFontSize, AppRadius } from './theme/Theme';
|
||||
export { AppButton, ButtonVariant } from './components/AppButton';
|
||||
export { LoadingView } from './components/LoadingView';
|
||||
export { EmptyView } from './components/EmptyView';
|
||||
export { SegmentedControl } from './components/SegmentedControl';
|
||||
export { ProgressBar } from './components/ProgressBar';
|
||||
export { Avatar } from './components/Avatar';
|
||||
export { LabeledRow } from './components/LabeledRow';
|
||||
export { EmptyState, ErrorState, LoggedOutState } from './components/StateViews';
|
||||
export { StatusBadge } from './components/StatusBadge';
|
||||
export { StatusPill } from './components/StatusPill';
|
||||
58
uikit/src/main/ets/components/AppButton.ets
Normal file
58
uikit/src/main/ets/components/AppButton.ets
Normal file
@@ -0,0 +1,58 @@
|
||||
import { AppColor, AppFontSize, AppRadius } from '../theme/Theme';
|
||||
|
||||
export enum ButtonVariant {
|
||||
Primary = 'primary',
|
||||
Secondary = 'secondary',
|
||||
Danger = 'danger',
|
||||
Outline = 'outline'
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用按钮:@ComponentV2 + @Param 输入 + @Event 输出回调。
|
||||
*/
|
||||
@ComponentV2
|
||||
export struct AppButton {
|
||||
@Param text: string = '';
|
||||
@Param variant: ButtonVariant = ButtonVariant.Primary;
|
||||
@Param disabled: boolean = false;
|
||||
@Param radius: number = AppRadius.md;
|
||||
@Event onTap: () => void;
|
||||
|
||||
build() {
|
||||
Button(this.text)
|
||||
.width('100%')
|
||||
.height(48)
|
||||
.fontSize(AppFontSize.title)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.fontColor())
|
||||
.backgroundColor(this.bgColor())
|
||||
.border({ width: this.variant === ButtonVariant.Outline ? 1 : 0, color: AppColor.gold })
|
||||
.borderRadius(this.radius)
|
||||
.enabled(!this.disabled)
|
||||
.onClick(() => {
|
||||
if (!this.disabled) {
|
||||
this.onTap();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fontColor(): string {
|
||||
if (this.variant === ButtonVariant.Primary || this.variant === ButtonVariant.Danger) {
|
||||
return AppColor.white;
|
||||
}
|
||||
return AppColor.gold;
|
||||
}
|
||||
|
||||
private bgColor(): string {
|
||||
if (this.disabled) {
|
||||
return AppColor.disabled;
|
||||
}
|
||||
if (this.variant === ButtonVariant.Primary) {
|
||||
return AppColor.gold;
|
||||
}
|
||||
if (this.variant === ButtonVariant.Danger) {
|
||||
return AppColor.danger;
|
||||
}
|
||||
return AppColor.white;
|
||||
}
|
||||
}
|
||||
23
uikit/src/main/ets/components/Avatar.ets
Normal file
23
uikit/src/main/ets/components/Avatar.ets
Normal file
@@ -0,0 +1,23 @@
|
||||
import { AppColor } from '../theme/Theme';
|
||||
|
||||
/**
|
||||
* 首字母圆形头像:金色底白字。
|
||||
*/
|
||||
@ComponentV2
|
||||
export struct Avatar {
|
||||
@Param name: string = '';
|
||||
@Param diameter: number = 40;
|
||||
|
||||
build() {
|
||||
Stack({ alignContent: Alignment.Center }) {
|
||||
Text(this.name.length > 0 ? this.name.charAt(0) : '明')
|
||||
.fontSize(this.diameter * 0.42)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(AppColor.white)
|
||||
}
|
||||
.width(this.diameter)
|
||||
.height(this.diameter)
|
||||
.borderRadius(this.diameter / 2)
|
||||
.backgroundColor(AppColor.gold)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
27
uikit/src/main/ets/components/LabeledRow.ets
Normal file
27
uikit/src/main/ets/components/LabeledRow.ets
Normal file
@@ -0,0 +1,27 @@
|
||||
import { AppColor, AppFontSize } from '../theme/Theme';
|
||||
|
||||
/**
|
||||
* 左标签右值的信息行(用于「我的」页个人信息)。
|
||||
*/
|
||||
@ComponentV2
|
||||
export struct LabeledRow {
|
||||
@Param label: string = '';
|
||||
@Param value: string = '';
|
||||
|
||||
build() {
|
||||
Row() {
|
||||
Text(this.label)
|
||||
.fontSize(AppFontSize.body)
|
||||
.fontColor(AppColor.textMuted)
|
||||
Blank()
|
||||
Text(this.value)
|
||||
.fontSize(AppFontSize.body)
|
||||
.fontColor(AppColor.textPrimary)
|
||||
.textAlign(TextAlign.End)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 14, bottom: 14 })
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
25
uikit/src/main/ets/components/ProgressBar.ets
Normal file
25
uikit/src/main/ets/components/ProgressBar.ets
Normal file
@@ -0,0 +1,25 @@
|
||||
import { AppColor, AppRadius } from '../theme/Theme';
|
||||
|
||||
/**
|
||||
* 金色进度条:0-100 百分比。
|
||||
*/
|
||||
@ComponentV2
|
||||
export struct ProgressBar {
|
||||
@Param percent: number = 0;
|
||||
|
||||
build() {
|
||||
Stack({ alignContent: Alignment.Start }) {
|
||||
Row()
|
||||
.width('100%')
|
||||
.height(10)
|
||||
.backgroundColor(AppColor.divider)
|
||||
.borderRadius(AppRadius.pill)
|
||||
Row()
|
||||
.width(this.percent + '%')
|
||||
.height(10)
|
||||
.linearGradient({ angle: 90, colors: [[AppColor.gold, 0.0], [AppColor.goldBright, 1.0]] })
|
||||
.borderRadius(AppRadius.pill)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
35
uikit/src/main/ets/components/SegmentedControl.ets
Normal file
35
uikit/src/main/ets/components/SegmentedControl.ets
Normal file
@@ -0,0 +1,35 @@
|
||||
import { AppColor, AppFontSize, AppRadius } from '../theme/Theme';
|
||||
|
||||
/**
|
||||
* 分段切换控件:多个等宽选项,选中项白底金字。
|
||||
* 用于「周/月/年计划」类型切换、「小组/个人」tab 切换。
|
||||
*/
|
||||
@ComponentV2
|
||||
export struct SegmentedControl {
|
||||
@Param items: string[] = [];
|
||||
@Param selectedIndex: number = 0;
|
||||
@Event onSelect: (index: number) => void;
|
||||
|
||||
build() {
|
||||
Row() {
|
||||
ForEach(this.items, (item: string, index: number) => {
|
||||
Text(item)
|
||||
.fontSize(AppFontSize.body)
|
||||
.fontWeight(index === this.selectedIndex ? FontWeight.Bold : FontWeight.Normal)
|
||||
.fontColor(index === this.selectedIndex ? AppColor.gold : AppColor.textMuted)
|
||||
.backgroundColor(index === this.selectedIndex ? AppColor.white : Color.Transparent)
|
||||
.borderRadius(AppRadius.pill)
|
||||
.textAlign(TextAlign.Center)
|
||||
.height(36)
|
||||
.layoutWeight(1)
|
||||
.onClick(() => {
|
||||
this.onSelect(index);
|
||||
})
|
||||
}, (item: string) => item)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(4)
|
||||
.backgroundColor(AppColor.segmentedTrack)
|
||||
.borderRadius(AppRadius.pill)
|
||||
}
|
||||
}
|
||||
103
uikit/src/main/ets/components/StateViews.ets
Normal file
103
uikit/src/main/ets/components/StateViews.ets
Normal file
@@ -0,0 +1,103 @@
|
||||
import { AppColor, AppFontSize, AppRadius, AppSpacing } from '../theme/Theme';
|
||||
|
||||
/**
|
||||
* 空态:emoji + 说明。
|
||||
*/
|
||||
@ComponentV2
|
||||
export struct EmptyState {
|
||||
@Param emoji: string = '📭';
|
||||
@Param message: string = '暂无数据';
|
||||
|
||||
build() {
|
||||
Column({ space: AppSpacing.md }) {
|
||||
Text(this.emoji)
|
||||
.fontSize(40)
|
||||
Text(this.message)
|
||||
.fontSize(AppFontSize.body)
|
||||
.fontColor(AppColor.textFaint)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 48, bottom: 48 })
|
||||
.justifyContent(FlexAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误态:圆形「!」+ 文案 + 重新加载。
|
||||
*/
|
||||
@ComponentV2
|
||||
export struct ErrorState {
|
||||
@Param message: string = '加载失败,请重试';
|
||||
@Event onRetry: () => void;
|
||||
|
||||
build() {
|
||||
Column({ space: AppSpacing.md }) {
|
||||
Stack({ alignContent: Alignment.Center }) {
|
||||
Text('!')
|
||||
.fontSize(28)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(AppColor.danger)
|
||||
}
|
||||
.width(56)
|
||||
.height(56)
|
||||
.borderRadius(28)
|
||||
.backgroundColor(AppColor.errorBg)
|
||||
|
||||
Text(this.message)
|
||||
.fontSize(AppFontSize.body)
|
||||
.fontColor(AppColor.textMuted)
|
||||
.textAlign(TextAlign.Center)
|
||||
|
||||
Text('重新加载')
|
||||
.fontSize(AppFontSize.body)
|
||||
.fontColor(AppColor.white)
|
||||
.padding({ left: 32, right: 32, top: 10, bottom: 10 })
|
||||
.backgroundColor(AppColor.gold)
|
||||
.borderRadius(AppRadius.pill)
|
||||
.onClick(() => {
|
||||
this.onRetry();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.padding({ top: 80, bottom: 80 })
|
||||
.justifyContent(FlexAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 未登录占位:🔒 + 说明 + 去登录。
|
||||
*/
|
||||
@ComponentV2
|
||||
export struct LoggedOutState {
|
||||
@Param message: string = '登录后即可查看';
|
||||
@Event onLogin: () => void;
|
||||
|
||||
build() {
|
||||
Column({ space: AppSpacing.md }) {
|
||||
Text('🔒')
|
||||
.fontSize(40)
|
||||
Text('未登录')
|
||||
.fontSize(AppFontSize.title)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(AppColor.textPrimary)
|
||||
Text(this.message)
|
||||
.fontSize(AppFontSize.body)
|
||||
.fontColor(AppColor.textMuted)
|
||||
.textAlign(TextAlign.Center)
|
||||
Text('去登录')
|
||||
.fontSize(AppFontSize.body)
|
||||
.fontColor(AppColor.white)
|
||||
.padding({ left: 32, right: 32, top: 10, bottom: 10 })
|
||||
.backgroundColor(AppColor.gold)
|
||||
.borderRadius(AppRadius.pill)
|
||||
.onClick(() => {
|
||||
this.onLogin();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.padding({ top: 80, bottom: 80 })
|
||||
.justifyContent(FlexAlign.Center)
|
||||
}
|
||||
}
|
||||
26
uikit/src/main/ets/components/StatusBadge.ets
Normal file
26
uikit/src/main/ets/components/StatusBadge.ets
Normal file
@@ -0,0 +1,26 @@
|
||||
import { AppColor, AppFontSize, AppRadius } from '../theme/Theme';
|
||||
|
||||
/**
|
||||
* 状态徽标:彩色圆点 + 文字(浅底)。label/color 由调用方提供,保持组件领域无关。
|
||||
*/
|
||||
@ComponentV2
|
||||
export struct StatusBadge {
|
||||
@Param label: string = '';
|
||||
@Param color: string = AppColor.gold;
|
||||
|
||||
build() {
|
||||
Row({ space: 6 }) {
|
||||
Row()
|
||||
.width(8)
|
||||
.height(8)
|
||||
.borderRadius(4)
|
||||
.backgroundColor(this.color)
|
||||
Text(this.label)
|
||||
.fontSize(AppFontSize.caption)
|
||||
.fontColor(this.color)
|
||||
}
|
||||
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
|
||||
.backgroundColor(AppColor.neutralChip)
|
||||
.borderRadius(AppRadius.pill)
|
||||
}
|
||||
}
|
||||
33
uikit/src/main/ets/components/StatusPill.ets
Normal file
33
uikit/src/main/ets/components/StatusPill.ets
Normal file
@@ -0,0 +1,33 @@
|
||||
import { AppColor, AppFontSize, AppRadius } from '../theme/Theme';
|
||||
|
||||
/**
|
||||
* 可点选状态胶囊:选中态 = 状态色底白字,未选中 = 白底灰字。label/color 由调用方提供。
|
||||
*/
|
||||
@ComponentV2
|
||||
export struct StatusPill {
|
||||
@Param label: string = '';
|
||||
@Param color: string = AppColor.gold;
|
||||
@Param selected: boolean = false;
|
||||
@Event onTap: () => void;
|
||||
|
||||
build() {
|
||||
Row({ space: 6 }) {
|
||||
Row()
|
||||
.width(8)
|
||||
.height(8)
|
||||
.borderRadius(4)
|
||||
.backgroundColor(this.selected ? AppColor.white : this.color)
|
||||
Text(this.label)
|
||||
.fontSize(13)
|
||||
.fontColor(this.selected ? AppColor.white : AppColor.textSecondary)
|
||||
}
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.padding({ left: 16, right: 16, top: 10, bottom: 10 })
|
||||
.backgroundColor(this.selected ? this.color : AppColor.white)
|
||||
.border({ width: 1, color: this.selected ? this.color : AppColor.pillBorder })
|
||||
.borderRadius(AppRadius.pill)
|
||||
.onClick(() => {
|
||||
this.onTap();
|
||||
})
|
||||
}
|
||||
}
|
||||
88
uikit/src/main/ets/theme/Theme.ets
Normal file
88
uikit/src/main/ets/theme/Theme.ets
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 主题 token:颜色 / 间距 / 字号 / 圆角。
|
||||
* 全 App 统一从这里取值,避免散落魔法值。
|
||||
* 品牌色板对齐「明缘云道场」小程序(深棕 / 金 / 米白)。
|
||||
*/
|
||||
export class AppColor {
|
||||
// 品牌主色(金)
|
||||
static readonly primary: string = '#C67B30';
|
||||
static readonly primaryPressed: string = '#A8611F';
|
||||
|
||||
// 文本阶梯
|
||||
static readonly textPrimary: string = '#2C1810';
|
||||
static readonly textSecondary: string = '#5A4A3A';
|
||||
static readonly textMuted: string = '#8B7A6A';
|
||||
static readonly textMutedLight: string = '#A99A89';
|
||||
static readonly textFaint: string = '#B6A996';
|
||||
|
||||
// 背景
|
||||
static readonly background: string = '#FDFBF7';
|
||||
static readonly creamDark: string = '#F6F2EC';
|
||||
static readonly white: string = '#FFFFFF';
|
||||
static readonly divider: string = '#F2EDE7';
|
||||
static readonly dividerDark: string = '#E4D9CB';
|
||||
|
||||
// 品牌深棕 / 金系
|
||||
static readonly deepBrown: string = '#2C1810';
|
||||
static readonly gold: string = '#C67B30';
|
||||
static readonly goldLight: string = '#E3B37C';
|
||||
static readonly goldMid: string = '#E4A05E';
|
||||
static readonly goldBright: string = '#EFA45D';
|
||||
static readonly goldChip: string = '#2EC67B30';
|
||||
static readonly goldChipStrong: string = '#40C67B30';
|
||||
|
||||
// Hero 渐变(135°)
|
||||
static readonly heroStart: string = '#2C1810';
|
||||
static readonly heroMid: string = '#4A2B1D';
|
||||
static readonly heroEnd: string = '#6B3F24';
|
||||
|
||||
// 打卡状态五色
|
||||
static readonly statusNormal: string = '#2E7D32';
|
||||
static readonly statusLate: string = '#C67B30';
|
||||
static readonly statusLeave: string = '#3A7BD5';
|
||||
static readonly statusEarlyLeave: string = '#8B7A6A';
|
||||
static readonly statusAbsent: string = '#C0392B';
|
||||
|
||||
// 语义
|
||||
static readonly danger: string = '#C0392B';
|
||||
static readonly errorBg: string = '#FDEAEA';
|
||||
static readonly successBg: string = '#E8F4EB';
|
||||
static readonly warningBg: string = '#FFF3E6';
|
||||
static readonly warningBorder: string = '#F7D9B9';
|
||||
|
||||
// 组件
|
||||
static readonly neutralChip: string = '#F6EFE6';
|
||||
static readonly segmentedTrack: string = '#EFE7DC';
|
||||
static readonly pillBorder: string = '#D8C7B4';
|
||||
static readonly disabled: string = '#D8C7B4';
|
||||
static readonly skeleton: string = '#EFE6D9';
|
||||
|
||||
// 底部导航
|
||||
static readonly tabBg: string = '#FFFFFF';
|
||||
static readonly tabInactive: string = '#5A4A3A';
|
||||
static readonly tabActive: string = '#C67B30';
|
||||
}
|
||||
|
||||
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;
|
||||
static readonly display: number = 28;
|
||||
}
|
||||
|
||||
export class AppRadius {
|
||||
static readonly sm: number = 8;
|
||||
static readonly md: number = 12;
|
||||
static readonly lg: number = 16;
|
||||
static readonly xl: number = 20;
|
||||
static readonly pill: number = 999;
|
||||
}
|
||||
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