Compare commits
9 Commits
3ceaa9188b
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fd3107369 | ||
|
|
b62e2c0a5b | ||
|
|
f7fb81e7b4 | ||
|
|
58a91134a1 | ||
|
|
d9f1893f67 | ||
|
|
28fc0a1026 | ||
|
|
b390dbf214 | ||
|
|
8321991b45 | ||
|
|
35a70f5091 |
3
.gitignore
vendored
@@ -16,3 +16,6 @@
|
|||||||
*.cer
|
*.cer
|
||||||
*.p7b
|
*.p7b
|
||||||
**/signing/
|
**/signing/
|
||||||
|
# 技能内容(本地安装,经 skills-lock.json + npx skills 重新生成)
|
||||||
|
/.agents
|
||||||
|
/.claude/skills
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ harmony-client/
|
|||||||
| 测试 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` |
|
| 分支模型与 Git 规范 Git Workflow | `docs/git-workflow.md` |
|
||||||
|
| UI 主题与图标 Theme & Icons | `docs/ui-theme.md` |
|
||||||
|
|
||||||
## Common Tasks
|
## Common Tasks
|
||||||
|
|
||||||
|
|||||||
34
docs/ui-theme.md
Normal 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` 完全一致。
|
||||||
78
entry/src/main/ets/data/repository/CheckinRepository.ets
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { CheckinRecord, CheckinStatus, GroupMember, PersonalCheckinRecord } from '../../model/CheckinModels';
|
||||||
|
import { mockDelay } from './MockUtil';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打卡数据仓库契约。
|
||||||
|
*/
|
||||||
|
export abstract class CheckinRepository {
|
||||||
|
abstract observeCheckin(): Promise<CheckinRecord>;
|
||||||
|
abstract submitCheckin(status: string): Promise<CheckinRecord>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本地 mock 实现:内存 seed,提交更新自身状态。
|
||||||
|
*/
|
||||||
|
export class FakeCheckinRepository extends CheckinRepository {
|
||||||
|
private record: CheckinRecord = FakeCheckinRepository.seed();
|
||||||
|
|
||||||
|
async observeCheckin(): Promise<CheckinRecord> {
|
||||||
|
await mockDelay(250);
|
||||||
|
return this.record;
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitCheckin(status: string): Promise<CheckinRecord> {
|
||||||
|
await mockDelay(300);
|
||||||
|
this.record.checked = true;
|
||||||
|
this.record.status = status;
|
||||||
|
// 更新"我"(第一个成员)在小组成员列表里的状态
|
||||||
|
if (this.record.groupMembers.length > 0) {
|
||||||
|
const me = this.record.groupMembers[0];
|
||||||
|
me.checkedIn = true;
|
||||||
|
me.status = status;
|
||||||
|
me.checkinTime = '今天 09:12';
|
||||||
|
}
|
||||||
|
return this.record;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static seed(): CheckinRecord {
|
||||||
|
const r = new CheckinRecord();
|
||||||
|
r.classDate = '2026-09-06';
|
||||||
|
r.weekday = '周日';
|
||||||
|
r.dayNum = '06';
|
||||||
|
r.checked = false;
|
||||||
|
r.status = '';
|
||||||
|
r.groupName = '进阶一组';
|
||||||
|
r.groupMembers = [
|
||||||
|
FakeCheckinRepository.member('u-1001', '张三', false, '', ''),
|
||||||
|
FakeCheckinRepository.member('u-1002', '李四', true, CheckinStatus.NORMAL, '今天 09:05'),
|
||||||
|
FakeCheckinRepository.member('u-1003', '王五', true, CheckinStatus.LATE, '今天 09:18'),
|
||||||
|
FakeCheckinRepository.member('u-1004', '赵六', false, '', ''),
|
||||||
|
FakeCheckinRepository.member('u-1005', '孙七', true, CheckinStatus.LEAVE, '今天 08:40')
|
||||||
|
];
|
||||||
|
r.personalHistory = [
|
||||||
|
FakeCheckinRepository.history('h-1', '2026-08-30', CheckinStatus.NORMAL, '08:52'),
|
||||||
|
FakeCheckinRepository.history('h-2', '2026-08-23', CheckinStatus.LATE, '09:21'),
|
||||||
|
FakeCheckinRepository.history('h-3', '2026-08-16', CheckinStatus.ABSENT, '--')
|
||||||
|
];
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static member(id: string, name: string, checkedIn: boolean, status: string, checkinTime: string): GroupMember {
|
||||||
|
const m = new GroupMember();
|
||||||
|
m.id = id;
|
||||||
|
m.name = name;
|
||||||
|
m.checkedIn = checkedIn;
|
||||||
|
m.status = status;
|
||||||
|
m.checkinTime = checkinTime;
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static history(id: string, date: string, status: string, time: string): PersonalCheckinRecord {
|
||||||
|
const h = new PersonalCheckinRecord();
|
||||||
|
h.id = id;
|
||||||
|
h.date = date;
|
||||||
|
h.status = status;
|
||||||
|
h.time = time;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
}
|
||||||
93
entry/src/main/ets/data/repository/HomeRepository.ets
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { HomeData, HomeModule, IntroCard } from '../../model/HomeModels';
|
||||||
|
import { mockDelay } from './MockUtil';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首页数据仓库契约。
|
||||||
|
*/
|
||||||
|
export abstract class HomeRepository {
|
||||||
|
abstract observeHome(): Promise<HomeData>;
|
||||||
|
abstract observeGuestHome(): Promise<HomeData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本地 mock 实现:登录态与游客态两套 seed。
|
||||||
|
*/
|
||||||
|
export class FakeHomeRepository extends HomeRepository {
|
||||||
|
async observeHome(): Promise<HomeData> {
|
||||||
|
await mockDelay(250);
|
||||||
|
return FakeHomeRepository.seed(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async observeGuestHome(): Promise<HomeData> {
|
||||||
|
await mockDelay(250);
|
||||||
|
return FakeHomeRepository.seed(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static seed(loggedIn: boolean): HomeData {
|
||||||
|
const data = new HomeData();
|
||||||
|
if (loggedIn) {
|
||||||
|
data.greeting = '晚上好';
|
||||||
|
data.displayName = '张三';
|
||||||
|
data.encouragement = '每日精进,持续成长';
|
||||||
|
} else {
|
||||||
|
data.greeting = '欢迎来到明缘云道场';
|
||||||
|
data.displayName = '游客';
|
||||||
|
data.encouragement = '登录后即可打卡、提交计划';
|
||||||
|
}
|
||||||
|
data.modules = FakeHomeRepository.buildModules();
|
||||||
|
data.intros = FakeHomeRepository.buildIntros();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static buildModules(): HomeModule[] {
|
||||||
|
const checkin = new HomeModule();
|
||||||
|
checkin.id = 'checkin';
|
||||||
|
checkin.emoji = '📋';
|
||||||
|
checkin.title = '打卡介绍';
|
||||||
|
checkin.description = '每日打卡记录';
|
||||||
|
|
||||||
|
const plan = new HomeModule();
|
||||||
|
plan.id = 'plan';
|
||||||
|
plan.emoji = '📝';
|
||||||
|
plan.title = '计划介绍';
|
||||||
|
plan.description = '周/月/年计划';
|
||||||
|
|
||||||
|
const about = new HomeModule();
|
||||||
|
about.id = 'about';
|
||||||
|
about.emoji = 'ℹ️';
|
||||||
|
about.title = '关于我们';
|
||||||
|
about.description = '了解明缘云道场';
|
||||||
|
|
||||||
|
return [checkin, plan, about];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static buildIntros(): IntroCard[] {
|
||||||
|
const list: IntroCard[] = [];
|
||||||
|
|
||||||
|
const learn = new IntroCard();
|
||||||
|
learn.emoji = '📚';
|
||||||
|
learn.heading = '学习成长工具';
|
||||||
|
learn.body = '明缘云道场是一款专注于个人成长的效率工具,通过每周打卡与计划管理,帮助你养成良好的学习习惯。';
|
||||||
|
list.push(learn);
|
||||||
|
|
||||||
|
const checkin = new IntroCard();
|
||||||
|
checkin.emoji = '✅';
|
||||||
|
checkin.heading = '周打卡';
|
||||||
|
checkin.body = '每周打卡,支持多种状态记录。查看小组打卡情况,与同学们互相监督、共同进步。';
|
||||||
|
list.push(checkin);
|
||||||
|
|
||||||
|
const plan = new IntroCard();
|
||||||
|
plan.emoji = '📝';
|
||||||
|
plan.heading = '计划管理';
|
||||||
|
plan.body = '提供周计划、月计划、年计划三级管理,支持文字与图片上传,让成长有迹可循。';
|
||||||
|
list.push(plan);
|
||||||
|
|
||||||
|
const group = new IntroCard();
|
||||||
|
group.emoji = '👥';
|
||||||
|
group.heading = '小组共学';
|
||||||
|
group.body = '加入学习小组,查看组员的打卡与计划完成情况,与志同道合的伙伴一起成长。';
|
||||||
|
list.push(group);
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
entry/src/main/ets/data/repository/MockUtil.ets
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* mock 延迟工具:模拟网络往返,让加载态可见。
|
||||||
|
*/
|
||||||
|
export function mockDelay(ms: number): Promise<void> {
|
||||||
|
return new Promise<void>((resolve: () => void) => {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
56
entry/src/main/ets/data/repository/PlanRepository.ets
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { PlanGroup, PlanRecord, PlanType } from '../../model/PlanModels';
|
||||||
|
import { mockDelay } from './MockUtil';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计划数据仓库契约。
|
||||||
|
*/
|
||||||
|
export abstract class PlanRepository {
|
||||||
|
abstract observePlan(type: string): Promise<PlanRecord>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本地 mock 实现:按类型(周/月/年)缓存 seed。
|
||||||
|
*/
|
||||||
|
export class FakePlanRepository extends PlanRepository {
|
||||||
|
private cache: Map<string, PlanRecord> = new Map<string, PlanRecord>();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.cache.set(PlanType.WEEKLY, FakePlanRepository.seed(PlanType.WEEKLY, '2026-W37', '周四截止,请及时提交'));
|
||||||
|
this.cache.set(PlanType.MONTHLY, FakePlanRepository.seed(PlanType.MONTHLY, '2026-09', ''));
|
||||||
|
this.cache.set(PlanType.YEARLY, FakePlanRepository.seed(PlanType.YEARLY, '2026', ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
async observePlan(type: string): Promise<PlanRecord> {
|
||||||
|
await mockDelay(250);
|
||||||
|
const rec = this.cache.get(type);
|
||||||
|
return rec ? rec : FakePlanRepository.seed(type, '', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static seed(type: string, periodKey: string, message: string): PlanRecord {
|
||||||
|
const r = new PlanRecord();
|
||||||
|
r.type = type;
|
||||||
|
r.periodKey = periodKey;
|
||||||
|
r.canSubmit = true;
|
||||||
|
r.message = message;
|
||||||
|
r.myChecked = false;
|
||||||
|
r.myContent = '';
|
||||||
|
r.mySubmittedTime = '';
|
||||||
|
r.unassignedCount = 1;
|
||||||
|
r.groups = [
|
||||||
|
FakePlanRepository.group('g-1', '进阶一组', 3, 5),
|
||||||
|
FakePlanRepository.group('g-2', '进阶二组', 4, 5),
|
||||||
|
FakePlanRepository.group('g-3', '进阶三组', 2, 4)
|
||||||
|
];
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static group(id: string, name: string, checkedCount: number, memberCount: number): PlanGroup {
|
||||||
|
const g = new PlanGroup();
|
||||||
|
g.id = id;
|
||||||
|
g.name = name;
|
||||||
|
g.checkedCount = checkedCount;
|
||||||
|
g.memberCount = memberCount;
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
entry/src/main/ets/data/repository/ProfileRepository.ets
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { ClassGroup, UserProfile } from '../../model/ProfileModels';
|
||||||
|
import { mockDelay } from './MockUtil';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我的资料数据仓库契约。
|
||||||
|
*/
|
||||||
|
export abstract class ProfileRepository {
|
||||||
|
abstract observeProfile(): Promise<UserProfile>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本地 mock 实现。
|
||||||
|
*/
|
||||||
|
export class FakeProfileRepository extends ProfileRepository {
|
||||||
|
async observeProfile(): Promise<UserProfile> {
|
||||||
|
await mockDelay(250);
|
||||||
|
return FakeProfileRepository.seed();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static seed(): UserProfile {
|
||||||
|
const p = new UserProfile();
|
||||||
|
p.displayName = '张三';
|
||||||
|
p.name = '张三';
|
||||||
|
p.studentId = '20260001';
|
||||||
|
p.phone = '138****1234';
|
||||||
|
p.role = '成员';
|
||||||
|
p.group = '进阶一组';
|
||||||
|
p.loginType = '微信授权';
|
||||||
|
p.avatarUrl = '';
|
||||||
|
p.classGroups = [
|
||||||
|
FakeProfileRepository.classGroup('2026-W37', '进阶一组', '成员')
|
||||||
|
];
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static classGroup(period: string, group: string, role: string): ClassGroup {
|
||||||
|
const c = new ClassGroup();
|
||||||
|
c.period = period;
|
||||||
|
c.group = group;
|
||||||
|
c.role = role;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
entry/src/main/ets/data/repository/SessionRepository.ets
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,11 @@ import { HttpClient, AuthInterceptor, SessionManager, Logger } from 'common';
|
|||||||
import { ServiceLocator } from '../di/ServiceLocator';
|
import { ServiceLocator } from '../di/ServiceLocator';
|
||||||
import { UserApi } from '../data/api/UserApi';
|
import { UserApi } from '../data/api/UserApi';
|
||||||
import { UserRepositoryImpl } from '../data/repository/UserRepository';
|
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 TAG: string = 'EntryAbility';
|
const TAG: string = 'EntryAbility';
|
||||||
|
|
||||||
@@ -29,9 +34,16 @@ export default class EntryAbility extends UIAbility {
|
|||||||
httpClient.addInterceptor(new AuthInterceptor());
|
httpClient.addInterceptor(new AuthInterceptor());
|
||||||
ServiceLocator.register('httpClient', httpClient);
|
ServiceLocator.register('httpClient', httpClient);
|
||||||
|
|
||||||
// 数据层
|
// 数据层(真实示例)
|
||||||
const userApi = new UserApi(httpClient);
|
const userApi = new UserApi(httpClient);
|
||||||
ServiceLocator.register('userRepository', new UserRepositoryImpl(userApi));
|
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 {
|
||||||
|
|||||||
56
entry/src/main/ets/model/CheckinModels.ets
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* 打卡状态常量(值即中文状态名,与小程序一致)。
|
||||||
|
*/
|
||||||
|
export class CheckinStatus {
|
||||||
|
static readonly NORMAL: string = '正常';
|
||||||
|
static readonly LATE: string = '迟到';
|
||||||
|
static readonly LEAVE: string = '请假';
|
||||||
|
static readonly ABSENT: string = '不参加讨论';
|
||||||
|
static readonly EARLY_LEAVE: string = '早退';
|
||||||
|
|
||||||
|
static readonly ALL: string[] = [
|
||||||
|
CheckinStatus.NORMAL,
|
||||||
|
CheckinStatus.LATE,
|
||||||
|
CheckinStatus.LEAVE,
|
||||||
|
CheckinStatus.ABSENT,
|
||||||
|
CheckinStatus.EARLY_LEAVE
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小组成员(打卡视图)。
|
||||||
|
*/
|
||||||
|
@ObservedV2
|
||||||
|
export class GroupMember {
|
||||||
|
@Trace id: string = '';
|
||||||
|
@Trace name: string = '';
|
||||||
|
@Trace checkedIn: boolean = false;
|
||||||
|
@Trace checkinTime: string = '';
|
||||||
|
@Trace status: string = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 个人打卡历史记录。
|
||||||
|
*/
|
||||||
|
@ObservedV2
|
||||||
|
export class PersonalCheckinRecord {
|
||||||
|
@Trace id: string = '';
|
||||||
|
@Trace date: string = '';
|
||||||
|
@Trace status: string = '';
|
||||||
|
@Trace time: string = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打卡页数据。
|
||||||
|
*/
|
||||||
|
@ObservedV2
|
||||||
|
export class CheckinRecord {
|
||||||
|
@Trace classDate: string = '';
|
||||||
|
@Trace weekday: string = '';
|
||||||
|
@Trace dayNum: string = '';
|
||||||
|
@Trace checked: boolean = false;
|
||||||
|
@Trace status: string = '';
|
||||||
|
@Trace groupName: string = '';
|
||||||
|
@Trace groupMembers: GroupMember[] = [];
|
||||||
|
@Trace personalHistory: PersonalCheckinRecord[] = [];
|
||||||
|
}
|
||||||
32
entry/src/main/ets/model/HomeModels.ets
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* 首页功能模块。
|
||||||
|
*/
|
||||||
|
@ObservedV2
|
||||||
|
export class HomeModule {
|
||||||
|
@Trace id: string = '';
|
||||||
|
@Trace emoji: string = '';
|
||||||
|
@Trace title: string = '';
|
||||||
|
@Trace description: string = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 产品介绍卡片。
|
||||||
|
*/
|
||||||
|
@ObservedV2
|
||||||
|
export class IntroCard {
|
||||||
|
@Trace emoji: string = '';
|
||||||
|
@Trace heading: string = '';
|
||||||
|
@Trace body: string = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首页数据(登录态与游客态共用结构)。
|
||||||
|
*/
|
||||||
|
@ObservedV2
|
||||||
|
export class HomeData {
|
||||||
|
@Trace greeting: string = '';
|
||||||
|
@Trace displayName: string = '';
|
||||||
|
@Trace encouragement: string = '';
|
||||||
|
@Trace modules: HomeModule[] = [];
|
||||||
|
@Trace intros: IntroCard[] = [];
|
||||||
|
}
|
||||||
53
entry/src/main/ets/model/PlanModels.ets
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* 计划类型常量(值即中文名,与小程序一致)。
|
||||||
|
*/
|
||||||
|
export class PlanType {
|
||||||
|
static readonly WEEKLY: string = '周计划';
|
||||||
|
static readonly MONTHLY: string = '月计划';
|
||||||
|
static readonly YEARLY: string = '年计划';
|
||||||
|
|
||||||
|
static readonly ALL: string[] = [
|
||||||
|
PlanType.WEEKLY,
|
||||||
|
PlanType.MONTHLY,
|
||||||
|
PlanType.YEARLY
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计划小组成员。
|
||||||
|
*/
|
||||||
|
@ObservedV2
|
||||||
|
export class PlanMember {
|
||||||
|
@Trace id: string = '';
|
||||||
|
@Trace name: string = '';
|
||||||
|
@Trace filled: boolean = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计划小组(含进度与成员)。
|
||||||
|
*/
|
||||||
|
@ObservedV2
|
||||||
|
export class PlanGroup {
|
||||||
|
@Trace id: string = '';
|
||||||
|
@Trace name: string = '';
|
||||||
|
@Trace checkedCount: number = 0;
|
||||||
|
@Trace memberCount: number = 0;
|
||||||
|
@Trace members: PlanMember[] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计划页数据。
|
||||||
|
*/
|
||||||
|
@ObservedV2
|
||||||
|
export class PlanRecord {
|
||||||
|
@Trace type: string = PlanType.WEEKLY;
|
||||||
|
@Trace periodKey: string = '';
|
||||||
|
@Trace canSubmit: boolean = true;
|
||||||
|
@Trace message: string = '';
|
||||||
|
@Trace myChecked: boolean = false;
|
||||||
|
@Trace myContent: string = '';
|
||||||
|
@Trace mySubmittedTime: string = '';
|
||||||
|
@Trace groups: PlanGroup[] = [];
|
||||||
|
@Trace members: PlanMember[] = [];
|
||||||
|
@Trace unassignedCount: number = 0;
|
||||||
|
}
|
||||||
25
entry/src/main/ets/model/ProfileModels.ets
Normal file
@@ -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[] = [];
|
||||||
|
}
|
||||||
232
entry/src/main/ets/pages/CheckinPage.ets
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { AppColor, AppSpacing, AppFontSize, AppRadius, LoadingView, ErrorState, LoggedOutState, EmptyState, SegmentedControl, Avatar, StatusBadge, StatusPill } from 'uikit';
|
||||||
|
import { SessionManager, SessionStore } from 'common';
|
||||||
|
import { CheckinViewModel } from '../viewmodel/CheckinViewModel';
|
||||||
|
import { CheckinStatus, GroupMember, PersonalCheckinRecord } from '../model/CheckinModels';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打卡状态 -> 颜色映射(领域逻辑放在页面层,保持 uikit 通用)。
|
||||||
|
*/
|
||||||
|
function checkinStatusColor(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case CheckinStatus.NORMAL:
|
||||||
|
return AppColor.statusNormal;
|
||||||
|
case CheckinStatus.LATE:
|
||||||
|
return AppColor.statusLate;
|
||||||
|
case CheckinStatus.LEAVE:
|
||||||
|
return AppColor.statusLeave;
|
||||||
|
case CheckinStatus.EARLY_LEAVE:
|
||||||
|
return AppColor.statusEarlyLeave;
|
||||||
|
case CheckinStatus.ABSENT:
|
||||||
|
return AppColor.statusAbsent;
|
||||||
|
default:
|
||||||
|
return AppColor.gold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打卡页:顶部打卡卡 + 小组/个人记录。
|
||||||
|
*/
|
||||||
|
@ComponentV2
|
||||||
|
export struct CheckinPage {
|
||||||
|
@Local viewModel: CheckinViewModel = CheckinViewModel.create();
|
||||||
|
@Local session: SessionStore = SessionManager.get();
|
||||||
|
@Local checkinTab: number = 0;
|
||||||
|
@Event onGoLogin: () => void;
|
||||||
|
|
||||||
|
aboutToAppear(): void {
|
||||||
|
this.viewModel.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Monitor('session.token')
|
||||||
|
onSessionChange(): void {
|
||||||
|
this.viewModel.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
Column() {
|
||||||
|
if (!this.viewModel.loggedIn) {
|
||||||
|
LoggedOutState({ message: '登录后即可打卡', onLogin: () => { this.onGoLogin(); } })
|
||||||
|
} else if (this.viewModel.loading) {
|
||||||
|
Column() {
|
||||||
|
LoadingView({ text: '加载中...' })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.justifyContent(FlexAlign.Center)
|
||||||
|
} else if (this.viewModel.error.length > 0) {
|
||||||
|
ErrorState({ message: this.viewModel.error, onRetry: () => { this.viewModel.load(); } })
|
||||||
|
} else {
|
||||||
|
Scroll() {
|
||||||
|
Column({ space: AppSpacing.md }) {
|
||||||
|
this.statusCard()
|
||||||
|
this.records()
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(AppSpacing.lg)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.scrollBar(BarState.Off)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.backgroundColor(AppColor.background)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
statusCard() {
|
||||||
|
Column({ space: AppSpacing.md }) {
|
||||||
|
Row({ space: AppSpacing.md }) {
|
||||||
|
Column({ space: 2 }) {
|
||||||
|
Text(this.viewModel.record.weekday)
|
||||||
|
.fontSize(AppFontSize.caption)
|
||||||
|
.fontColor(AppColor.goldLight)
|
||||||
|
Text(this.viewModel.record.dayNum)
|
||||||
|
.fontSize(24)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor(AppColor.white)
|
||||||
|
}
|
||||||
|
.width(64)
|
||||||
|
.padding({ top: 12, bottom: 12 })
|
||||||
|
.backgroundColor(AppColor.deepBrown)
|
||||||
|
.borderRadius(AppRadius.md)
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
|
||||||
|
Column({ space: 4 }) {
|
||||||
|
Text(this.viewModel.record.checked ? '已打卡' : '未打卡')
|
||||||
|
.fontSize(22)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor(this.viewModel.record.checked ? AppColor.statusNormal : AppColor.gold)
|
||||||
|
Text(this.viewModel.record.checked ? '已完成今日打卡' : '请选择状态完成打卡')
|
||||||
|
.fontSize(AppFontSize.caption)
|
||||||
|
.fontColor(AppColor.textMuted)
|
||||||
|
}
|
||||||
|
.layoutWeight(1)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
ForEach(CheckinStatus.ALL.slice(0, 3), (status: string) => {
|
||||||
|
StatusPill({
|
||||||
|
label: status,
|
||||||
|
color: checkinStatusColor(status),
|
||||||
|
selected: this.viewModel.record.status === status,
|
||||||
|
onTap: () => { this.viewModel.submit(status); }
|
||||||
|
})
|
||||||
|
}, (status: string) => status)
|
||||||
|
}
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
ForEach(CheckinStatus.ALL.slice(3), (status: string) => {
|
||||||
|
StatusPill({
|
||||||
|
label: status,
|
||||||
|
color: checkinStatusColor(status),
|
||||||
|
selected: this.viewModel.record.status === status,
|
||||||
|
onTap: () => { this.viewModel.submit(status); }
|
||||||
|
})
|
||||||
|
}, (status: string) => status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(AppSpacing.md)
|
||||||
|
.backgroundColor(AppColor.white)
|
||||||
|
.borderRadius(AppRadius.lg)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
records() {
|
||||||
|
Column({ space: AppSpacing.md }) {
|
||||||
|
SegmentedControl({
|
||||||
|
items: ['小组', '个人'],
|
||||||
|
selectedIndex: this.checkinTab,
|
||||||
|
onSelect: (index: number) => { this.checkinTab = index; }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (this.checkinTab === 0) {
|
||||||
|
this.groupTab()
|
||||||
|
} else {
|
||||||
|
this.personalTab()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
groupTab() {
|
||||||
|
Column() {
|
||||||
|
if (this.viewModel.record.groupMembers.length === 0) {
|
||||||
|
EmptyState({ emoji: '📭', message: '暂无成员数据' })
|
||||||
|
} else {
|
||||||
|
ForEach(this.viewModel.record.groupMembers, (m: GroupMember) => {
|
||||||
|
Row({ space: AppSpacing.md }) {
|
||||||
|
Avatar({ name: m.name, diameter: 40 })
|
||||||
|
Column({ space: 2 }) {
|
||||||
|
Text(m.name)
|
||||||
|
.fontSize(AppFontSize.body)
|
||||||
|
.fontWeight(FontWeight.Medium)
|
||||||
|
.fontColor(AppColor.textPrimary)
|
||||||
|
if (m.checkedIn) {
|
||||||
|
Text('打卡 ' + m.checkinTime)
|
||||||
|
.fontSize(AppFontSize.caption)
|
||||||
|
.fontColor(AppColor.textMuted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.layoutWeight(1)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
|
||||||
|
if (m.checkedIn) {
|
||||||
|
StatusBadge({ label: m.status, color: checkinStatusColor(m.status) })
|
||||||
|
} else {
|
||||||
|
Text('未打卡')
|
||||||
|
.fontSize(AppFontSize.caption)
|
||||||
|
.fontColor(AppColor.textMuted)
|
||||||
|
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
|
||||||
|
.backgroundColor(AppColor.neutralChip)
|
||||||
|
.borderRadius(AppRadius.pill)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ top: 12, bottom: 12 })
|
||||||
|
}, (m: GroupMember) => m.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.backgroundColor(AppColor.white)
|
||||||
|
.borderRadius(AppRadius.lg)
|
||||||
|
.padding({ left: 16, right: 16, top: 4, bottom: 4 })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
personalTab() {
|
||||||
|
Column({ space: AppSpacing.sm }) {
|
||||||
|
if (this.viewModel.record.personalHistory.length === 0) {
|
||||||
|
EmptyState({ emoji: '📒', message: '暂无个人打卡记录' })
|
||||||
|
} else {
|
||||||
|
ForEach(this.viewModel.record.personalHistory, (h: PersonalCheckinRecord) => {
|
||||||
|
Row() {
|
||||||
|
Column({ space: 2 }) {
|
||||||
|
Text(h.date)
|
||||||
|
.fontSize(AppFontSize.body)
|
||||||
|
.fontWeight(FontWeight.Medium)
|
||||||
|
.fontColor(AppColor.textPrimary)
|
||||||
|
Text(h.time)
|
||||||
|
.fontSize(AppFontSize.caption)
|
||||||
|
.fontColor(AppColor.textMutedLight)
|
||||||
|
}
|
||||||
|
.layoutWeight(1)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
|
||||||
|
StatusBadge({ label: h.status, color: checkinStatusColor(h.status) })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(AppSpacing.md)
|
||||||
|
.backgroundColor(AppColor.white)
|
||||||
|
.borderRadius(AppRadius.md)
|
||||||
|
}, (h: PersonalCheckinRecord) => h.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
}
|
||||||
155
entry/src/main/ets/pages/HomePage.ets
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { AppColor, AppSpacing, AppFontSize, AppRadius, LoadingView, ErrorState } from 'uikit';
|
||||||
|
import { SessionManager, SessionStore } from 'common';
|
||||||
|
import { HomeViewModel } from '../viewmodel/HomeViewModel';
|
||||||
|
import { HomeModule, IntroCard } from '../model/HomeModels';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首页:未登录走游客介绍页,已登录走原生首页(Hero + 模块 + 产品介绍)。
|
||||||
|
*/
|
||||||
|
@ComponentV2
|
||||||
|
export struct HomePage {
|
||||||
|
@Local viewModel: HomeViewModel = HomeViewModel.create();
|
||||||
|
@Local session: SessionStore = SessionManager.get();
|
||||||
|
@Event onGoLogin: () => void;
|
||||||
|
|
||||||
|
aboutToAppear(): void {
|
||||||
|
this.viewModel.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Monitor('session.token')
|
||||||
|
onSessionChange(): void {
|
||||||
|
this.viewModel.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
Column() {
|
||||||
|
if (this.viewModel.loading) {
|
||||||
|
LoadingView({ text: '加载中...' })
|
||||||
|
} else if (this.viewModel.error.length > 0) {
|
||||||
|
ErrorState({ message: this.viewModel.error, onRetry: () => { this.viewModel.load(); } })
|
||||||
|
} else {
|
||||||
|
Scroll() {
|
||||||
|
Column({ space: AppSpacing.md }) {
|
||||||
|
this.hero()
|
||||||
|
this.moduleGrid()
|
||||||
|
this.introSection()
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(AppSpacing.lg)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.scrollBar(BarState.Off)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.backgroundColor(AppColor.background)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
hero() {
|
||||||
|
Column({ space: AppSpacing.sm }) {
|
||||||
|
Text(this.viewModel.data.greeting)
|
||||||
|
.fontSize(AppFontSize.caption)
|
||||||
|
.fontColor(AppColor.goldLight)
|
||||||
|
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
|
||||||
|
.backgroundColor(AppColor.goldChip)
|
||||||
|
.borderRadius(AppRadius.pill)
|
||||||
|
|
||||||
|
Text(this.viewModel.data.displayName)
|
||||||
|
.fontSize(32)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor(AppColor.white)
|
||||||
|
|
||||||
|
Text(this.viewModel.data.encouragement)
|
||||||
|
.fontSize(AppFontSize.body)
|
||||||
|
.fontColor(AppColor.goldLight)
|
||||||
|
|
||||||
|
if (this.viewModel.isGuest) {
|
||||||
|
Text('登录账号解锁完整功能 ›')
|
||||||
|
.fontSize(AppFontSize.caption)
|
||||||
|
.fontColor(AppColor.goldLight)
|
||||||
|
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
|
||||||
|
.backgroundColor(AppColor.goldChipStrong)
|
||||||
|
.borderRadius(AppRadius.pill)
|
||||||
|
.onClick(() => {
|
||||||
|
this.onGoLogin();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
.padding(AppSpacing.lg)
|
||||||
|
.borderRadius(AppRadius.xl)
|
||||||
|
.linearGradient({ angle: 135, colors: [[AppColor.heroStart, 0.0], [AppColor.heroMid, 0.55], [AppColor.heroEnd, 1.0]] })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
moduleGrid() {
|
||||||
|
Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
|
||||||
|
ForEach(this.viewModel.data.modules, (m: HomeModule) => {
|
||||||
|
Column({ space: AppSpacing.sm }) {
|
||||||
|
Stack({ alignContent: Alignment.Center }) {
|
||||||
|
Text(m.emoji)
|
||||||
|
.fontSize(20)
|
||||||
|
}
|
||||||
|
.width(48)
|
||||||
|
.height(48)
|
||||||
|
.borderRadius(24)
|
||||||
|
.backgroundColor(AppColor.neutralChip)
|
||||||
|
|
||||||
|
Text(m.title)
|
||||||
|
.fontSize(AppFontSize.body)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor(AppColor.textPrimary)
|
||||||
|
|
||||||
|
Text(m.description)
|
||||||
|
.fontSize(AppFontSize.caption)
|
||||||
|
.fontColor(AppColor.textMuted)
|
||||||
|
}
|
||||||
|
.width('31%')
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
.padding({ top: 20, bottom: 20, left: 8, right: 8 })
|
||||||
|
.backgroundColor(AppColor.white)
|
||||||
|
.borderRadius(AppRadius.lg)
|
||||||
|
}, (m: HomeModule) => m.id)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
introSection() {
|
||||||
|
Column({ space: AppSpacing.md }) {
|
||||||
|
Text('产品介绍')
|
||||||
|
.fontSize(AppFontSize.title)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor(AppColor.textPrimary)
|
||||||
|
|
||||||
|
ForEach(this.viewModel.data.intros, (intro: IntroCard) => {
|
||||||
|
Row({ space: AppSpacing.md }) {
|
||||||
|
Text(intro.emoji)
|
||||||
|
.fontSize(22)
|
||||||
|
Column({ space: AppSpacing.xs }) {
|
||||||
|
Text(intro.heading)
|
||||||
|
.fontSize(AppFontSize.body)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor(AppColor.gold)
|
||||||
|
Text(intro.body)
|
||||||
|
.fontSize(AppFontSize.caption)
|
||||||
|
.fontColor(AppColor.textSecondary)
|
||||||
|
.lineHeight(20)
|
||||||
|
}
|
||||||
|
.layoutWeight(1)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(AppSpacing.md)
|
||||||
|
.backgroundColor(AppColor.white)
|
||||||
|
.borderRadius(AppRadius.lg)
|
||||||
|
}, (intro: IntroCard) => intro.heading)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,63 +1,62 @@
|
|||||||
import { AppColor, AppSpacing, AppButton, LoadingView, EmptyView } from 'uikit';
|
import { AppColor, AppFontSize } from 'uikit';
|
||||||
import { HomeViewModel } from '../viewmodel/HomeViewModel';
|
import { HomePage } from './HomePage';
|
||||||
import { User } from '../model/User';
|
import { CheckinPage } from './CheckinPage';
|
||||||
import { AppRouter } from '../router/AppRouter';
|
import { PlanPage } from './PlanPage';
|
||||||
import { RouteMap, RouteNames } from '../router/RouteMap';
|
import { ProfilePage } from './ProfilePage';
|
||||||
import { StatusCard } from '../view/StatusCard';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 首页(@Entry):Navigation 根容器,仅编排。
|
* 应用入口:底部四 tab 壳(首页 / 打卡 / 计划 / 我的)。
|
||||||
* 演示「页面 -> ViewModel -> Repository -> Api -> HttpClient」完整链路。
|
|
||||||
*/
|
*/
|
||||||
@Entry
|
@Entry
|
||||||
@ComponentV2
|
@ComponentV2
|
||||||
struct Index {
|
struct Index {
|
||||||
@Local pathStack: NavPathStack = new NavPathStack();
|
@Local currentIndex: number = 0;
|
||||||
@Local viewModel: HomeViewModel = HomeViewModel.create();
|
|
||||||
@Local title: string = 'Harmony Client';
|
|
||||||
|
|
||||||
aboutToAppear(): void {
|
|
||||||
AppRouter.init(this.pathStack);
|
|
||||||
this.viewModel.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
build() {
|
build() {
|
||||||
Navigation(this.pathStack) {
|
Tabs({ barPosition: BarPosition.End, index: this.currentIndex }) {
|
||||||
Column({ space: AppSpacing.md }) {
|
TabContent() {
|
||||||
Text(this.title)
|
HomePage({ onGoLogin: () => { this.currentIndex = 3; } })
|
||||||
.fontSize(24)
|
|
||||||
.fontWeight(FontWeight.Bold)
|
|
||||||
.fontColor(AppColor.textPrimary)
|
|
||||||
|
|
||||||
if (this.viewModel.loading) {
|
|
||||||
LoadingView({ text: '加载中...' })
|
|
||||||
} else if (this.viewModel.error.length > 0) {
|
|
||||||
EmptyView({ text: this.viewModel.error })
|
|
||||||
} else {
|
|
||||||
ForEach(this.viewModel.users, (user: User) => {
|
|
||||||
StatusCard({ title: user.name, subtitle: user.email })
|
|
||||||
}, (user: User) => user.id)
|
|
||||||
}
|
}
|
||||||
|
.tabBar(this.tabBarBuilder(0, '首页', $r('app.media.home'), $r('app.media.home_active')))
|
||||||
|
|
||||||
AppButton({
|
TabContent() {
|
||||||
text: '进入详情页',
|
CheckinPage({ onGoLogin: () => { this.currentIndex = 3; } })
|
||||||
onTap: () => {
|
|
||||||
AppRouter.push(RouteNames.DETAIL, '来自首页的参数');
|
|
||||||
}
|
}
|
||||||
|
.tabBar(this.tabBarBuilder(1, '打卡', $r('app.media.checkin'), $r('app.media.checkin_active')))
|
||||||
|
|
||||||
|
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')))
|
||||||
|
}
|
||||||
|
.onChange((index: number) => {
|
||||||
|
this.currentIndex = index;
|
||||||
})
|
})
|
||||||
}
|
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.height('100%')
|
.height('100%')
|
||||||
.padding(AppSpacing.lg)
|
|
||||||
.backgroundColor(AppColor.background)
|
.backgroundColor(AppColor.background)
|
||||||
}
|
}
|
||||||
.navDestination(this.routeMap)
|
|
||||||
.mode(NavigationMode.Stack)
|
|
||||||
.hideTitleBar(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Builder
|
@Builder
|
||||||
routeMap(name: string, param: Object) {
|
tabBarBuilder(index: number, title: string, normalIcon: Resource, activeIcon: Resource) {
|
||||||
RouteMap(name, param)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
183
entry/src/main/ets/pages/PlanPage.ets
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { AppColor, AppSpacing, AppFontSize, AppRadius, LoadingView, ErrorState, LoggedOutState, SegmentedControl, ProgressBar } from 'uikit';
|
||||||
|
import { SessionManager, SessionStore } from 'common';
|
||||||
|
import { PlanViewModel } from '../viewmodel/PlanViewModel';
|
||||||
|
import { PlanType, PlanGroup } from '../model/PlanModels';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计划页:周/月/年切换 + 我的计划状态 + 小组进度。
|
||||||
|
*/
|
||||||
|
@ComponentV2
|
||||||
|
export struct PlanPage {
|
||||||
|
@Local viewModel: PlanViewModel = PlanViewModel.create();
|
||||||
|
@Local session: SessionStore = SessionManager.get();
|
||||||
|
@Event onGoLogin: () => void;
|
||||||
|
|
||||||
|
aboutToAppear(): void {
|
||||||
|
this.viewModel.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Monitor('session.token')
|
||||||
|
onSessionChange(): void {
|
||||||
|
this.viewModel.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
Column() {
|
||||||
|
if (!this.viewModel.loggedIn) {
|
||||||
|
LoggedOutState({ message: '登录后即可提交计划', onLogin: () => { this.onGoLogin(); } })
|
||||||
|
} else if (this.viewModel.loading) {
|
||||||
|
Column() {
|
||||||
|
LoadingView({ text: '加载中...' })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.justifyContent(FlexAlign.Center)
|
||||||
|
} else if (this.viewModel.error.length > 0) {
|
||||||
|
ErrorState({ message: this.viewModel.error, onRetry: () => { this.viewModel.load(); } })
|
||||||
|
} else {
|
||||||
|
Scroll() {
|
||||||
|
Column({ space: AppSpacing.md }) {
|
||||||
|
SegmentedControl({
|
||||||
|
items: PlanType.ALL,
|
||||||
|
selectedIndex: PlanType.ALL.indexOf(this.viewModel.selectedType),
|
||||||
|
onSelect: (index: number) => { this.viewModel.selectType(PlanType.ALL[index]); }
|
||||||
|
})
|
||||||
|
this.head()
|
||||||
|
this.myStatusCard()
|
||||||
|
if (this.viewModel.record.message.length > 0) {
|
||||||
|
Text(this.viewModel.record.message)
|
||||||
|
.fontSize(AppFontSize.caption)
|
||||||
|
.fontColor(AppColor.danger)
|
||||||
|
.width('100%')
|
||||||
|
.padding({ top: 10, bottom: 10 })
|
||||||
|
.textAlign(TextAlign.Center)
|
||||||
|
.backgroundColor(AppColor.warningBg)
|
||||||
|
.borderRadius(AppRadius.md)
|
||||||
|
}
|
||||||
|
this.groupList()
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(AppSpacing.lg)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.scrollBar(BarState.Off)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.backgroundColor(AppColor.creamDark)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
head() {
|
||||||
|
Row() {
|
||||||
|
Column({ space: 2 }) {
|
||||||
|
Text(this.viewModel.selectedType)
|
||||||
|
.fontSize(24)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor(AppColor.textPrimary)
|
||||||
|
Text(this.viewModel.record.periodKey)
|
||||||
|
.fontSize(AppFontSize.caption)
|
||||||
|
.fontColor(AppColor.textMutedLight)
|
||||||
|
}
|
||||||
|
.layoutWeight(1)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
|
||||||
|
Text(this.viewModel.record.myChecked ? '已提交' : '待提交')
|
||||||
|
.fontSize(AppFontSize.caption)
|
||||||
|
.fontColor(this.viewModel.record.myChecked ? AppColor.statusNormal : AppColor.gold)
|
||||||
|
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
|
||||||
|
.backgroundColor(this.viewModel.record.myChecked ? AppColor.successBg : AppColor.warningBg)
|
||||||
|
.borderRadius(AppRadius.pill)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 4, right: 4 })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
myStatusCard() {
|
||||||
|
Row({ space: AppSpacing.md }) {
|
||||||
|
Stack({ alignContent: Alignment.Center }) {
|
||||||
|
Text(this.viewModel.record.myChecked ? '✓' : '●')
|
||||||
|
.fontSize(20)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor(AppColor.white)
|
||||||
|
}
|
||||||
|
.width(48)
|
||||||
|
.height(48)
|
||||||
|
.borderRadius(24)
|
||||||
|
.backgroundColor(this.viewModel.record.myChecked ? AppColor.statusNormal : AppColor.danger)
|
||||||
|
|
||||||
|
Column({ space: 4 }) {
|
||||||
|
Text(this.viewModel.record.myChecked ? '已完成' : '未完成')
|
||||||
|
.fontSize(AppFontSize.caption)
|
||||||
|
.fontColor(AppColor.textMuted)
|
||||||
|
Text('我的' + this.viewModel.selectedType)
|
||||||
|
.fontSize(AppFontSize.title)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor(AppColor.textPrimary)
|
||||||
|
}
|
||||||
|
.layoutWeight(1)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
|
||||||
|
Text(this.viewModel.record.myChecked ? '修改' : '提交计划')
|
||||||
|
.fontSize(AppFontSize.body)
|
||||||
|
.fontColor(AppColor.white)
|
||||||
|
.padding({ left: 20, right: 20, top: 10, bottom: 10 })
|
||||||
|
.backgroundColor(AppColor.gold)
|
||||||
|
.borderRadius(AppRadius.pill)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(AppSpacing.md)
|
||||||
|
.backgroundColor(AppColor.white)
|
||||||
|
.borderRadius(AppRadius.lg)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
groupList() {
|
||||||
|
Column({ space: AppSpacing.sm }) {
|
||||||
|
Text('小组统计')
|
||||||
|
.fontSize(AppFontSize.title)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor(AppColor.textPrimary)
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
ForEach(this.viewModel.record.groups, (g: PlanGroup, index: number) => {
|
||||||
|
Column({ space: AppSpacing.sm }) {
|
||||||
|
Row({ space: AppSpacing.sm }) {
|
||||||
|
Text((index + 1).toString())
|
||||||
|
.fontSize(AppFontSize.caption)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor(AppColor.gold)
|
||||||
|
.width(36)
|
||||||
|
.height(36)
|
||||||
|
.borderRadius(10)
|
||||||
|
.textAlign(TextAlign.Center)
|
||||||
|
.backgroundColor(AppColor.neutralChip)
|
||||||
|
|
||||||
|
Text(g.name)
|
||||||
|
.fontSize(AppFontSize.body)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor(AppColor.textPrimary)
|
||||||
|
|
||||||
|
Blank()
|
||||||
|
|
||||||
|
Text('已提交 ' + g.checkedCount + ' · ' + g.memberCount + '人')
|
||||||
|
.fontSize(AppFontSize.caption)
|
||||||
|
.fontColor(AppColor.textMuted)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
ProgressBar({ percent: g.memberCount > 0 ? Math.round(g.checkedCount / g.memberCount * 100) : 0 })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(AppSpacing.md)
|
||||||
|
.backgroundColor(AppColor.white)
|
||||||
|
.borderRadius(AppRadius.lg)
|
||||||
|
}, (g: PlanGroup) => g.id)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
}
|
||||||
184
entry/src/main/ets/pages/ProfilePage.ets
Normal file
@@ -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();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
62
entry/src/main/ets/viewmodel/CheckinViewModel.ets
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { CheckinRecord } from '../model/CheckinModels';
|
||||||
|
import { CheckinRepository } from '../data/repository/CheckinRepository';
|
||||||
|
import { SessionRepository } from '../data/repository/SessionRepository';
|
||||||
|
import { ServiceLocator } from '../di/ServiceLocator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打卡视图模型:加载打卡数据 + 提交打卡状态。
|
||||||
|
*/
|
||||||
|
@ObservedV2
|
||||||
|
export class CheckinViewModel {
|
||||||
|
@Trace record: CheckinRecord = new CheckinRecord();
|
||||||
|
@Trace loggedIn: boolean = false;
|
||||||
|
@Trace loading: boolean = false;
|
||||||
|
@Trace submitting: boolean = false;
|
||||||
|
@Trace error: string = '';
|
||||||
|
|
||||||
|
private readonly repository: CheckinRepository;
|
||||||
|
private readonly session: SessionRepository;
|
||||||
|
|
||||||
|
constructor(repository: CheckinRepository, session: SessionRepository) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.session = session;
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(): CheckinViewModel {
|
||||||
|
return new CheckinViewModel(
|
||||||
|
ServiceLocator.get('checkinRepository') as CheckinRepository,
|
||||||
|
ServiceLocator.get('sessionRepository') as SessionRepository
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async load(): Promise<void> {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.loggedIn = this.session.isLoggedIn();
|
||||||
|
if (!this.loggedIn) {
|
||||||
|
this.loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.record = await this.repository.observeCheckin();
|
||||||
|
} catch (err) {
|
||||||
|
this.error = JSON.stringify(err);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async submit(status: string): Promise<void> {
|
||||||
|
if (this.submitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.submitting = true;
|
||||||
|
try {
|
||||||
|
this.record = await this.repository.submitCheckin(status);
|
||||||
|
} catch (err) {
|
||||||
|
this.error = JSON.stringify(err);
|
||||||
|
} finally {
|
||||||
|
this.submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,37 +1,41 @@
|
|||||||
import { User } from '../model/User';
|
import { HomeData } from '../model/HomeModels';
|
||||||
import { UserRepository } from '../data/repository/UserRepository';
|
import { HomeRepository } from '../data/repository/HomeRepository';
|
||||||
|
import { SessionRepository } from '../data/repository/SessionRepository';
|
||||||
import { ServiceLocator } from '../di/ServiceLocator';
|
import { ServiceLocator } from '../di/ServiceLocator';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 首页视图模型:@ObservedV2 三态(loading / error / data)。
|
* 首页视图模型:@ObservedV2 三态(loading / error / data)。
|
||||||
* 只依赖 UserRepository 契约,不直接接触网络层。
|
* 根据登录态分流到游客首页 / 原生首页。
|
||||||
*/
|
*/
|
||||||
@ObservedV2
|
@ObservedV2
|
||||||
export class HomeViewModel {
|
export class HomeViewModel {
|
||||||
@Trace users: User[] = [];
|
@Trace data: HomeData = new HomeData();
|
||||||
|
@Trace isGuest: boolean = true;
|
||||||
@Trace loading: boolean = false;
|
@Trace loading: boolean = false;
|
||||||
@Trace error: string = '';
|
@Trace error: string = '';
|
||||||
|
|
||||||
private readonly repository: UserRepository;
|
private readonly repository: HomeRepository;
|
||||||
|
private readonly session: SessionRepository;
|
||||||
|
|
||||||
constructor(repository: UserRepository) {
|
constructor(repository: HomeRepository, session: SessionRepository) {
|
||||||
this.repository = repository;
|
this.repository = repository;
|
||||||
|
this.session = session;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 工厂:从 DI 容器解析依赖并装配,避免页面直接触碰 data 层类型。
|
|
||||||
*/
|
|
||||||
static create(): HomeViewModel {
|
static create(): HomeViewModel {
|
||||||
return new HomeViewModel(ServiceLocator.get('userRepository') as UserRepository);
|
return new HomeViewModel(
|
||||||
|
ServiceLocator.get('homeRepository') as HomeRepository,
|
||||||
|
ServiceLocator.get('sessionRepository') as SessionRepository
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async load(): Promise<void> {
|
async load(): Promise<void> {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = '';
|
this.error = '';
|
||||||
try {
|
try {
|
||||||
// 演示:拉取一个示例用户;真实业务可替换为列表接口。
|
const loggedIn = this.session.isLoggedIn();
|
||||||
const user = await this.repository.getUser('demo-user');
|
this.isGuest = !loggedIn;
|
||||||
this.users = [user];
|
this.data = loggedIn ? await this.repository.observeHome() : await this.repository.observeGuestHome();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.error = JSON.stringify(err);
|
this.error = JSON.stringify(err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
56
entry/src/main/ets/viewmodel/PlanViewModel.ets
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { PlanRecord, PlanType } from '../model/PlanModels';
|
||||||
|
import { PlanRepository } from '../data/repository/PlanRepository';
|
||||||
|
import { SessionRepository } from '../data/repository/SessionRepository';
|
||||||
|
import { ServiceLocator } from '../di/ServiceLocator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计划视图模型:周/月/年类型切换 + 计划数据加载。
|
||||||
|
*/
|
||||||
|
@ObservedV2
|
||||||
|
export class PlanViewModel {
|
||||||
|
@Trace record: PlanRecord = new PlanRecord();
|
||||||
|
@Trace selectedType: string = PlanType.WEEKLY;
|
||||||
|
@Trace loggedIn: boolean = false;
|
||||||
|
@Trace loading: boolean = false;
|
||||||
|
@Trace error: string = '';
|
||||||
|
|
||||||
|
private readonly repository: PlanRepository;
|
||||||
|
private readonly session: SessionRepository;
|
||||||
|
|
||||||
|
constructor(repository: PlanRepository, session: SessionRepository) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.session = session;
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(): PlanViewModel {
|
||||||
|
return new PlanViewModel(
|
||||||
|
ServiceLocator.get('planRepository') as PlanRepository,
|
||||||
|
ServiceLocator.get('sessionRepository') as SessionRepository
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async load(): Promise<void> {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.loggedIn = this.session.isLoggedIn();
|
||||||
|
if (!this.loggedIn) {
|
||||||
|
this.loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.record = await this.repository.observePlan(this.selectedType);
|
||||||
|
} catch (err) {
|
||||||
|
this.error = JSON.stringify(err);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectType(type: string): Promise<void> {
|
||||||
|
if (this.selectedType === type) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.selectedType = type;
|
||||||
|
await this.load();
|
||||||
|
}
|
||||||
|
}
|
||||||
58
entry/src/main/ets/viewmodel/ProfileViewModel.ets
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
entry/src/main/resources/base/media/checkin.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
entry/src/main/resources/base/media/checkin_active.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
entry/src/main/resources/base/media/home.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
entry/src/main/resources/base/media/home_active.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
entry/src/main/resources/base/media/plan.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
entry/src/main/resources/base/media/plan_active.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
entry/src/main/resources/base/media/profile.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
entry/src/main/resources/base/media/profile_active.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
89
skills-lock.json
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"skills": {
|
||||||
|
"brainstorming": {
|
||||||
|
"source": "obra/superpowers",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/brainstorming/SKILL.md",
|
||||||
|
"computedHash": "b58d55b64742ecd37923985ec7bdfbe678ffbcb9895562abd28c0a32dcacd7fb"
|
||||||
|
},
|
||||||
|
"dispatching-parallel-agents": {
|
||||||
|
"source": "obra/superpowers",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/dispatching-parallel-agents/SKILL.md",
|
||||||
|
"computedHash": "b011107f17188e0b2898bb1856b5bac8b49537cc86e737c82126558a235bd62a"
|
||||||
|
},
|
||||||
|
"executing-plans": {
|
||||||
|
"source": "obra/superpowers",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/executing-plans/SKILL.md",
|
||||||
|
"computedHash": "78a07800ee99a5861ae10be59d79fad085fdca10444e8aaeb2691597e357cff8"
|
||||||
|
},
|
||||||
|
"finishing-a-development-branch": {
|
||||||
|
"source": "obra/superpowers",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/finishing-a-development-branch/SKILL.md",
|
||||||
|
"computedHash": "4f571e7befb8b0946d9d7770232c818812412e9ab1dfb811f847edfba1fc6275"
|
||||||
|
},
|
||||||
|
"receiving-code-review": {
|
||||||
|
"source": "obra/superpowers",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/receiving-code-review/SKILL.md",
|
||||||
|
"computedHash": "59361179fc9225173e0c145ffd9bafbf9fdbf8b43ce3dc308497a0b4f83a6063"
|
||||||
|
},
|
||||||
|
"requesting-code-review": {
|
||||||
|
"source": "obra/superpowers",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/requesting-code-review/SKILL.md",
|
||||||
|
"computedHash": "004f340ae65811095d6a9bb0a759c87d0a8085c5e705dbb019b011626293d2e9"
|
||||||
|
},
|
||||||
|
"subagent-driven-development": {
|
||||||
|
"source": "obra/superpowers",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/subagent-driven-development/SKILL.md",
|
||||||
|
"computedHash": "367cc1d3216d6672b947c8c6583dbd6440ea407beb6da75a97d3a8ee1a23640a"
|
||||||
|
},
|
||||||
|
"systematic-debugging": {
|
||||||
|
"source": "obra/superpowers",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/systematic-debugging/SKILL.md",
|
||||||
|
"computedHash": "a2750dfbaed1690da73b120254892c4f926b4576cb82eb16fc877783941fe39b"
|
||||||
|
},
|
||||||
|
"test-driven-development": {
|
||||||
|
"source": "obra/superpowers",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/test-driven-development/SKILL.md",
|
||||||
|
"computedHash": "a4126a9c5f17be3c228aa9e5755898ade283448b86099f56f40852e8c14532be"
|
||||||
|
},
|
||||||
|
"using-git-worktrees": {
|
||||||
|
"source": "obra/superpowers",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/using-git-worktrees/SKILL.md",
|
||||||
|
"computedHash": "48c8a1040e378e0cb7208107294e3afa9067b9370c3f33b49a443eb6b7528b0d"
|
||||||
|
},
|
||||||
|
"using-superpowers": {
|
||||||
|
"source": "obra/superpowers",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/using-superpowers/SKILL.md",
|
||||||
|
"computedHash": "c7abe8bdc2099bbe4f245454d32c7563ccd2bb41dcc218b02b461c2acc3c6ae6"
|
||||||
|
},
|
||||||
|
"verification-before-completion": {
|
||||||
|
"source": "obra/superpowers",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/verification-before-completion/SKILL.md",
|
||||||
|
"computedHash": "b529822bdac4b197b180fe12225cff86b930b65910435103c10622df7f041651"
|
||||||
|
},
|
||||||
|
"writing-plans": {
|
||||||
|
"source": "obra/superpowers",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/writing-plans/SKILL.md",
|
||||||
|
"computedHash": "27c75044e444a4fc68798c3f799dc23eaff3a6bfabff32e6ad75bb20f881c094"
|
||||||
|
},
|
||||||
|
"writing-skills": {
|
||||||
|
"source": "obra/superpowers",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/writing-skills/SKILL.md",
|
||||||
|
"computedHash": "e5dfc1a942f56dc757421b0d87ae1f38ab693ebc71138e65268fd408bb5c93d3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
export { AppColor, AppSpacing, AppFontSize } from './theme/Theme';
|
export { AppColor, AppSpacing, AppFontSize, AppRadius } from './theme/Theme';
|
||||||
export { AppButton, ButtonVariant } from './components/AppButton';
|
export { AppButton, ButtonVariant } from './components/AppButton';
|
||||||
export { LoadingView } from './components/LoadingView';
|
export { LoadingView } from './components/LoadingView';
|
||||||
export { EmptyView } from './components/EmptyView';
|
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';
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { AppColor } from '../theme/Theme';
|
import { AppColor, AppFontSize, AppRadius } from '../theme/Theme';
|
||||||
|
|
||||||
export enum ButtonVariant {
|
export enum ButtonVariant {
|
||||||
Primary = 'primary',
|
Primary = 'primary',
|
||||||
Secondary = 'secondary'
|
Secondary = 'secondary',
|
||||||
|
Danger = 'danger',
|
||||||
|
Outline = 'outline'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,16 +15,19 @@ export struct AppButton {
|
|||||||
@Param text: string = '';
|
@Param text: string = '';
|
||||||
@Param variant: ButtonVariant = ButtonVariant.Primary;
|
@Param variant: ButtonVariant = ButtonVariant.Primary;
|
||||||
@Param disabled: boolean = false;
|
@Param disabled: boolean = false;
|
||||||
|
@Param radius: number = AppRadius.md;
|
||||||
@Event onTap: () => void;
|
@Event onTap: () => void;
|
||||||
|
|
||||||
build() {
|
build() {
|
||||||
Button(this.text)
|
Button(this.text)
|
||||||
.width('100%')
|
.width('100%')
|
||||||
.height(48)
|
.height(48)
|
||||||
.fontSize(16)
|
.fontSize(AppFontSize.title)
|
||||||
.fontColor(this.variant === ButtonVariant.Primary ? AppColor.white : AppColor.primary)
|
.fontWeight(FontWeight.Medium)
|
||||||
.backgroundColor(this.variant === ButtonVariant.Primary ? AppColor.primary : AppColor.white)
|
.fontColor(this.fontColor())
|
||||||
.borderRadius(8)
|
.backgroundColor(this.bgColor())
|
||||||
|
.border({ width: this.variant === ButtonVariant.Outline ? 1 : 0, color: AppColor.gold })
|
||||||
|
.borderRadius(this.radius)
|
||||||
.enabled(!this.disabled)
|
.enabled(!this.disabled)
|
||||||
.onClick(() => {
|
.onClick(() => {
|
||||||
if (!this.disabled) {
|
if (!this.disabled) {
|
||||||
@@ -30,4 +35,24 @@ export struct AppButton {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fontColor(): string {
|
||||||
|
if (this.variant === ButtonVariant.Primary || this.variant === ButtonVariant.Danger) {
|
||||||
|
return AppColor.white;
|
||||||
|
}
|
||||||
|
return AppColor.gold;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bgColor(): string {
|
||||||
|
if (this.disabled) {
|
||||||
|
return AppColor.disabled;
|
||||||
|
}
|
||||||
|
if (this.variant === ButtonVariant.Primary) {
|
||||||
|
return AppColor.gold;
|
||||||
|
}
|
||||||
|
if (this.variant === ButtonVariant.Danger) {
|
||||||
|
return AppColor.danger;
|
||||||
|
}
|
||||||
|
return AppColor.white;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
23
uikit/src/main/ets/components/Avatar.ets
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
27
uikit/src/main/ets/components/LabeledRow.ets
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { AppColor, AppFontSize } from '../theme/Theme';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 左标签右值的信息行(用于「我的」页个人信息)。
|
||||||
|
*/
|
||||||
|
@ComponentV2
|
||||||
|
export struct LabeledRow {
|
||||||
|
@Param label: string = '';
|
||||||
|
@Param value: string = '';
|
||||||
|
|
||||||
|
build() {
|
||||||
|
Row() {
|
||||||
|
Text(this.label)
|
||||||
|
.fontSize(AppFontSize.body)
|
||||||
|
.fontColor(AppColor.textMuted)
|
||||||
|
Blank()
|
||||||
|
Text(this.value)
|
||||||
|
.fontSize(AppFontSize.body)
|
||||||
|
.fontColor(AppColor.textPrimary)
|
||||||
|
.textAlign(TextAlign.End)
|
||||||
|
.maxLines(1)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ top: 14, bottom: 14 })
|
||||||
|
}
|
||||||
|
}
|
||||||
25
uikit/src/main/ets/components/ProgressBar.ets
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { AppColor, AppRadius } from '../theme/Theme';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 金色进度条:0-100 百分比。
|
||||||
|
*/
|
||||||
|
@ComponentV2
|
||||||
|
export struct ProgressBar {
|
||||||
|
@Param percent: number = 0;
|
||||||
|
|
||||||
|
build() {
|
||||||
|
Stack({ alignContent: Alignment.Start }) {
|
||||||
|
Row()
|
||||||
|
.width('100%')
|
||||||
|
.height(10)
|
||||||
|
.backgroundColor(AppColor.divider)
|
||||||
|
.borderRadius(AppRadius.pill)
|
||||||
|
Row()
|
||||||
|
.width(this.percent + '%')
|
||||||
|
.height(10)
|
||||||
|
.linearGradient({ angle: 90, colors: [[AppColor.gold, 0.0], [AppColor.goldBright, 1.0]] })
|
||||||
|
.borderRadius(AppRadius.pill)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
}
|
||||||
35
uikit/src/main/ets/components/SegmentedControl.ets
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { AppColor, AppFontSize, AppRadius } from '../theme/Theme';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分段切换控件:多个等宽选项,选中项白底金字。
|
||||||
|
* 用于「周/月/年计划」类型切换、「小组/个人」tab 切换。
|
||||||
|
*/
|
||||||
|
@ComponentV2
|
||||||
|
export struct SegmentedControl {
|
||||||
|
@Param items: string[] = [];
|
||||||
|
@Param selectedIndex: number = 0;
|
||||||
|
@Event onSelect: (index: number) => void;
|
||||||
|
|
||||||
|
build() {
|
||||||
|
Row() {
|
||||||
|
ForEach(this.items, (item: string, index: number) => {
|
||||||
|
Text(item)
|
||||||
|
.fontSize(AppFontSize.body)
|
||||||
|
.fontWeight(index === this.selectedIndex ? FontWeight.Bold : FontWeight.Normal)
|
||||||
|
.fontColor(index === this.selectedIndex ? AppColor.gold : AppColor.textMuted)
|
||||||
|
.backgroundColor(index === this.selectedIndex ? AppColor.white : Color.Transparent)
|
||||||
|
.borderRadius(AppRadius.pill)
|
||||||
|
.textAlign(TextAlign.Center)
|
||||||
|
.height(36)
|
||||||
|
.layoutWeight(1)
|
||||||
|
.onClick(() => {
|
||||||
|
this.onSelect(index);
|
||||||
|
})
|
||||||
|
}, (item: string) => item)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(4)
|
||||||
|
.backgroundColor(AppColor.segmentedTrack)
|
||||||
|
.borderRadius(AppRadius.pill)
|
||||||
|
}
|
||||||
|
}
|
||||||
103
uikit/src/main/ets/components/StateViews.ets
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { AppColor, AppFontSize, AppRadius, AppSpacing } from '../theme/Theme';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 空态:emoji + 说明。
|
||||||
|
*/
|
||||||
|
@ComponentV2
|
||||||
|
export struct EmptyState {
|
||||||
|
@Param emoji: string = '📭';
|
||||||
|
@Param message: string = '暂无数据';
|
||||||
|
|
||||||
|
build() {
|
||||||
|
Column({ space: AppSpacing.md }) {
|
||||||
|
Text(this.emoji)
|
||||||
|
.fontSize(40)
|
||||||
|
Text(this.message)
|
||||||
|
.fontSize(AppFontSize.body)
|
||||||
|
.fontColor(AppColor.textFaint)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ top: 48, bottom: 48 })
|
||||||
|
.justifyContent(FlexAlign.Center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 错误态:圆形「!」+ 文案 + 重新加载。
|
||||||
|
*/
|
||||||
|
@ComponentV2
|
||||||
|
export struct ErrorState {
|
||||||
|
@Param message: string = '加载失败,请重试';
|
||||||
|
@Event onRetry: () => void;
|
||||||
|
|
||||||
|
build() {
|
||||||
|
Column({ space: AppSpacing.md }) {
|
||||||
|
Stack({ alignContent: Alignment.Center }) {
|
||||||
|
Text('!')
|
||||||
|
.fontSize(28)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor(AppColor.danger)
|
||||||
|
}
|
||||||
|
.width(56)
|
||||||
|
.height(56)
|
||||||
|
.borderRadius(28)
|
||||||
|
.backgroundColor(AppColor.errorBg)
|
||||||
|
|
||||||
|
Text(this.message)
|
||||||
|
.fontSize(AppFontSize.body)
|
||||||
|
.fontColor(AppColor.textMuted)
|
||||||
|
.textAlign(TextAlign.Center)
|
||||||
|
|
||||||
|
Text('重新加载')
|
||||||
|
.fontSize(AppFontSize.body)
|
||||||
|
.fontColor(AppColor.white)
|
||||||
|
.padding({ left: 32, right: 32, top: 10, bottom: 10 })
|
||||||
|
.backgroundColor(AppColor.gold)
|
||||||
|
.borderRadius(AppRadius.pill)
|
||||||
|
.onClick(() => {
|
||||||
|
this.onRetry();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.padding({ top: 80, bottom: 80 })
|
||||||
|
.justifyContent(FlexAlign.Center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 未登录占位:🔒 + 说明 + 去登录。
|
||||||
|
*/
|
||||||
|
@ComponentV2
|
||||||
|
export struct LoggedOutState {
|
||||||
|
@Param message: string = '登录后即可查看';
|
||||||
|
@Event onLogin: () => void;
|
||||||
|
|
||||||
|
build() {
|
||||||
|
Column({ space: AppSpacing.md }) {
|
||||||
|
Text('🔒')
|
||||||
|
.fontSize(40)
|
||||||
|
Text('未登录')
|
||||||
|
.fontSize(AppFontSize.title)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor(AppColor.textPrimary)
|
||||||
|
Text(this.message)
|
||||||
|
.fontSize(AppFontSize.body)
|
||||||
|
.fontColor(AppColor.textMuted)
|
||||||
|
.textAlign(TextAlign.Center)
|
||||||
|
Text('去登录')
|
||||||
|
.fontSize(AppFontSize.body)
|
||||||
|
.fontColor(AppColor.white)
|
||||||
|
.padding({ left: 32, right: 32, top: 10, bottom: 10 })
|
||||||
|
.backgroundColor(AppColor.gold)
|
||||||
|
.borderRadius(AppRadius.pill)
|
||||||
|
.onClick(() => {
|
||||||
|
this.onLogin();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.padding({ top: 80, bottom: 80 })
|
||||||
|
.justifyContent(FlexAlign.Center)
|
||||||
|
}
|
||||||
|
}
|
||||||
26
uikit/src/main/ets/components/StatusBadge.ets
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { AppColor, AppFontSize, AppRadius } from '../theme/Theme';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态徽标:彩色圆点 + 文字(浅底)。label/color 由调用方提供,保持组件领域无关。
|
||||||
|
*/
|
||||||
|
@ComponentV2
|
||||||
|
export struct StatusBadge {
|
||||||
|
@Param label: string = '';
|
||||||
|
@Param color: string = AppColor.gold;
|
||||||
|
|
||||||
|
build() {
|
||||||
|
Row({ space: 6 }) {
|
||||||
|
Row()
|
||||||
|
.width(8)
|
||||||
|
.height(8)
|
||||||
|
.borderRadius(4)
|
||||||
|
.backgroundColor(this.color)
|
||||||
|
Text(this.label)
|
||||||
|
.fontSize(AppFontSize.caption)
|
||||||
|
.fontColor(this.color)
|
||||||
|
}
|
||||||
|
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
|
||||||
|
.backgroundColor(AppColor.neutralChip)
|
||||||
|
.borderRadius(AppRadius.pill)
|
||||||
|
}
|
||||||
|
}
|
||||||
33
uikit/src/main/ets/components/StatusPill.ets
Normal file
@@ -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();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,66 @@
|
|||||||
/**
|
/**
|
||||||
* 主题 token:颜色 / 间距 / 字号。
|
* 主题 token:颜色 / 间距 / 字号 / 圆角。
|
||||||
* 全 App 统一从这里取值,避免散落魔法值。
|
* 全 App 统一从这里取值,避免散落魔法值。
|
||||||
|
* 品牌色板对齐「明缘云道场」小程序(深棕 / 金 / 米白)。
|
||||||
*/
|
*/
|
||||||
export class AppColor {
|
export class AppColor {
|
||||||
static readonly primary: string = '#0A59F7';
|
// 品牌主色(金)
|
||||||
static readonly primaryPressed: string = '#094CCB';
|
static readonly primary: string = '#C67B30';
|
||||||
static readonly textPrimary: string = '#182431';
|
static readonly primaryPressed: string = '#A8611F';
|
||||||
static readonly textSecondary: string = '#66182431';
|
|
||||||
static readonly background: string = '#F1F3F5';
|
// 文本阶梯
|
||||||
|
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 white: string = '#FFFFFF';
|
||||||
static readonly divider: string = '#0D182431';
|
static readonly divider: string = '#F2EDE7';
|
||||||
static readonly danger: string = '#E84026';
|
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 {
|
export class AppSpacing {
|
||||||
@@ -26,4 +76,13 @@ export class AppFontSize {
|
|||||||
static readonly body: number = 14;
|
static readonly body: number = 14;
|
||||||
static readonly title: number = 16;
|
static readonly title: number = 16;
|
||||||
static readonly headline: number = 20;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||