21 Commits

Author SHA1 Message Date
AndyChen1024
9fd3107369 docs: 新增 UI 主题与图标资源文档 2026-09-15 14:49:57 +08:00
AndyChen1024
b62e2c0a5b feat(entry): 底部导航图标改用小程序 PNG 2026-09-15 14:49:51 +08:00
AndyChen1024
f7fb81e7b4 feat(entry): 新增底部导航图标资源 2026-09-15 14:49:45 +08:00
AndyChen1024
58a91134a1 feat(entry): 复刻四大主页面与底部导航 2026-09-11 20:48:15 +08:00
AndyChen1024
d9f1893f67 feat(entry): 新增四入口 ViewModel 2026-09-11 20:48:04 +08:00
AndyChen1024
28fc0a1026 feat(entry): 新增本地 mock 数据层 2026-09-11 20:47:51 +08:00
AndyChen1024
b390dbf214 feat(entry): 新增四入口领域模型 2026-09-11 20:47:37 +08:00
AndyChen1024
8321991b45 feat(uikit): 引入明缘主题色板与通用组件 2026-09-11 20:47:22 +08:00
AndyChen1024
35a70f5091 chore: 新增 skills-lock.json 技能版本清单,忽略本地技能内容 2026-09-11 18:26:12 +08:00
AndyChen1024
3ceaa9188b Merge branch 'feature/signing-guard' into dev 2026-09-11 17:30:41 +08:00
AndyChen1024
77ee00070e chore: 忽略本地临时目录 tmp/ 2026-09-11 17:28:43 +08:00
AndyChen1024
a0e0758b6b chore: 新增签名凭据 pre-commit 拦截钩子,防止 signingConfigs 入库 2026-09-11 17:26:33 +08:00
AndyChen1024
a25eab2d61 Merge branch 'feature/git-workflow' into dev 2026-09-11 17:08:01 +08:00
AndyChen1024
c4f678e299 docs: 新增 Git 分支模型与提交规范(docs/git-workflow.md,同步 AGENTS.md 文档地图与权限) 2026-09-11 17:07:36 +08:00
AndyChen1024
9fb87d2d08 Merge branch 'feature/architecture-scaffold' into dev 2026-09-11 16:54:51 +08:00
AndyChen1024
a91b4afdf2 docs: harness 文档对齐新架构,忽略签名材料 2026-09-11 16:22:45 +08:00
AndyChen1024
d179073e53 docs: 新增架构设计文档(多 HAR + MVVM + V2 状态管理) 2026-09-11 16:22:45 +08:00
AndyChen1024
058b978f25 feat(entry): 建立 MVVM+data 分层骨架,接入 V2 状态管理与 Navigation 路由 2026-09-11 16:22:45 +08:00
AndyChen1024
3a116df871 chore(build): 注册 common/uikit 模块,接线依赖与 INTERNET 权限 2026-09-11 16:22:45 +08:00
AndyChen1024
d5e2d88c4c feat(uikit): 新增通用 UI 组件库与主题 token 2026-09-11 16:22:45 +08:00
AndyChen1024
b73e74fa4a feat(common): 新增基础设施 HAR(网络/鉴权/日志/状态/工具) 2026-09-11 16:22:45 +08:00
85 changed files with 3201 additions and 105 deletions

11
.gitignore vendored
View File

@@ -1,5 +1,6 @@
/node_modules /node_modules
/oh_modules /oh_modules
/tmp
/local.properties /local.properties
/.idea /.idea
**/build **/build
@@ -9,4 +10,12 @@
/.clang-format /.clang-format
/.clang-tidy /.clang-tidy
**/.test **/.test
/.appanalyzer /.appanalyzer
# 签名材料(个人 debug 证书,勿提交)
*.p12
*.cer
*.p7b
**/signing/
# 技能内容(本地安装,经 skills-lock.json + npx skills 重新生成)
/.agents
/.claude/skills

View File

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

View File

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

17
common/BuildProfile.ets Normal file
View File

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

1
common/Index.ets Normal file
View File

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

View File

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

6
common/hvigorfile.ts Normal file
View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,14 +17,54 @@ hvigorw assembleHap --mode module -p product=default -p buildMode=release
产物通常在 `entry/build/default/outputs/` 下(`default` 为 product 名)。 产物通常在 `entry/build/default/outputs/` 下(`default` 为 product 名)。
## 多模块构建 Multi-module Build
本工程为「单 HAP + 多 HAR」:`entry/`(HAP)+ `common/`(HAR)+ `uikit/`(HAR)。
- HAR(`common`/`uikit`)作为 `entry` 的依赖,构建 entry 时会**自动先编译**依赖的 HAR,正常开发只需构建 entry。
- 任务类型:HAP 用 `assembleHap`,HAR 用 `assembleHar`。
- 构建整个工程用 `--mode project`(按依赖顺序构建全部模块);构建单个模块用 `--mode module`。
- 模块注册:根 `build-profile.json5` 的 `modules`;模块间依赖:各自 `oh-package.json5` 的 `dependencies` 用 `file:../xxx` 声明。
**HAR 配置要点(易错)**:
- `module.json5` 的 `type` 必须是 `har`(**不是** `shared`——`shared` 对应 HSP,会要求 `hspTasks`)。
- `oh-package.json5` 的 `main: "Index.ets"` 指向**模块根目录**的 `Index.ets`(该文件 `export * from './src/main/ets/Index.ets'` 转发公共 API)。
- 权限(如 `ohos.permission.INTERNET`)只声明在 HAP 的 `module.json5` 的 `requestPermissions`;HAR 不声明权限。
## 签名 Signing ## 签名 Signing
`build-profile.json5` 里 `signingConfigs` 目前为**空**,且 `products[].signingConfig` 引用了 `"default"` —— **签名尚未配置**。 ### 现状与原则
- `build-profile.json5` 里 `signingConfigs` 在**仓库中必须保持为空 `[]`**(`products[].signingConfig` 仍引用 `"default"`)——签名是机器相关的,禁止入库。
- 真正的密钥物料(`.p12` / `.cer` / `.p7b`)由 DevEco 生成在 `~/.ohos/config/` 下,**本来就在仓库外**;`build-profile.json5` 里只有「文件路径 + 加密后的密码」,但同样属敏感信息,不得提交。
### 本地配置签名(不入库)
要跑真机/模拟器需先配置签名: 要跑真机/模拟器需先配置签名:
- DevEco Studio → File → Project Structure → Signing Configs → 勾选自动签名(登录华为账号)。 - DevEco Studio → File → Project Structure → Signing Configs → 勾选自动签名(登录华为账号)。
- 或手动配置 `.p12` + `.cer` / `.p7b`。 - 或手动配置 `.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 ## 产品与构建模式 Products & Build Modes

View File

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

104
docs/git-workflow.md Normal file
View 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 合并)。

View File

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

34
docs/ui-theme.md Normal file
View File

@@ -0,0 +1,34 @@
# UI 主题与图标资源
> 品牌视觉对齐「明缘云道场」微信小程序(`wechat-clock-mini`)。
> 色值 token 定义在 `uikit/src/main/ets/theme/Theme.ets`(`AppColor`),组件内禁止散落魔法值。
## 品牌色板
| Token | 值 | 用途 |
|---|---|---|
| `primary` | `#C67B30` | 品牌主色(金) |
| `textPrimary` | `#2C1810` | 主文本(深棕) |
| `textSecondary` | `#5A4A3A` | 次文本(暖棕) |
| `background` | `#FDFBF7` | 页面背景(米白) |
| `tabBg` | `#FFFFFF` | 底部导航背景 |
| `tabInactive` | `#5A4A3A` | 底部导航未选中 |
| `tabActive` | `#C67B30` | 底部导航选中(金) |
## 底部导航图标
四 tab(首页 / 打卡 / 计划 / 我的)入口在 `entry/src/main/ets/pages/Index.ets`,图标来源是小程序 `wechat-clock-mini/assets/tabbar/` 的 8 张 PNG,已复制到 `entry/src/main/resources/base/media/`(`-active` 重命名为 `_active` 以符合资源名规范):
| tab | 普通 | 选中 |
|---|---|---|
| 首页 | `home.png` | `home_active.png` |
| 打卡 | `checkin.png` | `checkin_active.png` |
| 计划 | `plan.png` | `plan_active.png` |
| 我的 | `profile.png` | `profile_active.png` |
关键决策与实现约定:
- **选中态 = 换文件,不用 `fillColor` 着色。** `-active` 与普通版是同一图形、仅填充色不同(`#5A4A3A` → `#C67B30`);`Image.fillColor` 对 PNG 无效且会压平抗锯齿,故按选中态切换 `Image` 资源。
- **复用小程序的真实 PNG,保证像素级一致**,而非像 `android-client`(另一小程序客户端)那样用 Material 图标近似。`android-client` 可作为结构参考,但非像素级对齐。
- **尺寸**:24×24 vp,`objectFit(ImageFit.Contain)` 保留图标自带透明留白,缩放后与小程序观感一致。
- **主题无需改动**:`tabInactive/tabActive/tabBg` 已与小程序 `app.json` 的 `tabBar.color/selectedColor/backgroundColor` 完全一致。

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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;
}
}

View 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;
}
}

View File

@@ -0,0 +1,8 @@
/**
* mock 延迟工具:模拟网络往返,让加载态可见。
*/
export function mockDelay(ms: number): Promise<void> {
return new Promise<void>((resolve: () => void) => {
setTimeout(resolve, ms);
});
}

View 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;
}
}

View 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;
}
}

View 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();
}
}

View File

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

View File

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

View File

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

View 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[] = [];
}

View 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[] = [];
}

View 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;
}

View 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[] = [];
}

View File

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

View File

@@ -0,0 +1,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%')
}
}

View File

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

View File

@@ -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)
}
}

View File

@@ -1,23 +1,62 @@
import { AppColor, AppFontSize } from 'uikit';
import { HomePage } from './HomePage';
import { CheckinPage } from './CheckinPage';
import { PlanPage } from './PlanPage';
import { ProfilePage } from './ProfilePage';
/**
* 应用入口:底部四 tab 壳(首页 / 打卡 / 计划 / 我的)。
*/
@Entry @Entry
@Component @ComponentV2
struct Index { struct Index {
@State message: string = 'Hello World'; @Local currentIndex: number = 0;
build() { build() {
RelativeContainer() { Tabs({ barPosition: BarPosition.End, index: this.currentIndex }) {
Text(this.message) TabContent() {
.id('HelloWorld') HomePage({ onGoLogin: () => { this.currentIndex = 3; } })
.fontSize($r('app.float.page_text_font_size')) }
.fontWeight(FontWeight.Bold) .tabBar(this.tabBarBuilder(0, '首页', $r('app.media.home'), $r('app.media.home_active')))
.alignRules({
center: { anchor: '__container__', align: VerticalAlign.Center }, TabContent() {
middle: { anchor: '__container__', align: HorizontalAlign.Center } CheckinPage({ onGoLogin: () => { this.currentIndex = 3; } })
}) }
.onClick(() => { .tabBar(this.tabBarBuilder(1, '打卡', $r('app.media.checkin'), $r('app.media.checkin_active')))
this.message = 'Welcome';
}) TabContent() {
PlanPage({ onGoLogin: () => { this.currentIndex = 3; } })
}
.tabBar(this.tabBarBuilder(2, '计划', $r('app.media.plan'), $r('app.media.plan_active')))
TabContent() {
ProfilePage()
}
.tabBar(this.tabBarBuilder(3, '我的', $r('app.media.profile'), $r('app.media.profile_active')))
} }
.height('100%') .onChange((index: number) => {
this.currentIndex = index;
})
.width('100%') .width('100%')
.height('100%')
.backgroundColor(AppColor.background)
} }
}
@Builder
tabBarBuilder(index: number, title: string, normalIcon: Resource, activeIcon: Resource) {
Column({ space: 2 }) {
Image(index === this.currentIndex ? activeIcon : normalIcon)
.width(24)
.height(24)
.objectFit(ImageFit.Contain)
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)
}
}

View 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)
}
}

View 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();
})
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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;
}
}
}

View 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;
}
}
}

View 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();
}
}

View 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();
}
}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

32
scripts/git-hooks/pre-commit Executable file
View 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
View 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
View File

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

1
uikit/Index.ets Normal file
View File

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

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

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

6
uikit/hvigorfile.ts Normal file
View File

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

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

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

View File

@@ -0,0 +1,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';

View 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;
}
}

View 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)
}
}

View File

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

View File

@@ -0,0 +1,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 })
}
}

View File

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

View File

@@ -0,0 +1,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%')
}
}

View 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)
}
}

View 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)
}
}

View 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)
}
}

View 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();
})
}
}

View 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;
}

View File

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

View File

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