3 Commits
dev ... main

Author SHA1 Message Date
AndyChen1024
30331f7d89 Merge branch 'dev' into main 2026-09-11 17:30:49 +08:00
AndyChen1024
84233fa0b8 Merge branch 'dev' into main 2026-09-11 17:08:07 +08:00
AndyChen1024
1c98772253 Merge branch 'dev' into main 2026-09-11 16:54:58 +08:00
42 changed files with 75 additions and 1984 deletions

3
.gitignore vendored
View File

@@ -16,6 +16,3 @@
*.cer *.cer
*.p7b *.p7b
**/signing/ **/signing/
# 技能内容(本地安装,经 skills-lock.json + npx skills 重新生成)
/.agents
/.claude/skills

View File

@@ -73,7 +73,6 @@ 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

View File

@@ -1,34 +0,0 @@
# 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

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

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

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

View File

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

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

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

@@ -4,11 +4,6 @@ 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';
@@ -34,16 +29,9 @@ 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 {

View File

@@ -1,56 +0,0 @@
/**
* 打卡状态常量(值即中文状态名,与小程序一致)。
*/
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

@@ -1,32 +0,0 @@
/**
* 首页功能模块。
*/
@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

@@ -1,53 +0,0 @@
/**
* 计划类型常量(值即中文名,与小程序一致)。
*/
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

@@ -1,25 +0,0 @@
/**
* 班级分组(我的页)。
*/
@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

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

@@ -1,155 +0,0 @@
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,62 +1,63 @@
import { AppColor, AppFontSize } from 'uikit'; import { AppColor, AppSpacing, AppButton, LoadingView, EmptyView } from 'uikit';
import { HomePage } from './HomePage'; import { HomeViewModel } from '../viewmodel/HomeViewModel';
import { CheckinPage } from './CheckinPage'; import { User } from '../model/User';
import { PlanPage } from './PlanPage'; import { AppRouter } from '../router/AppRouter';
import { ProfilePage } from './ProfilePage'; import { RouteMap, RouteNames } from '../router/RouteMap';
import { StatusCard } from '../view/StatusCard';
/** /**
* 应用入口:底部四 tab 壳(首页 / 打卡 / 计划 / 我的)。 * 首页(@Entry):Navigation 根容器,仅编排。
* 演示「页面 -> ViewModel -> Repository -> Api -> HttpClient」完整链路。
*/ */
@Entry @Entry
@ComponentV2 @ComponentV2
struct Index { struct Index {
@Local currentIndex: number = 0; @Local pathStack: NavPathStack = new NavPathStack();
@Local viewModel: HomeViewModel = HomeViewModel.create();
@Local title: string = 'Harmony Client';
aboutToAppear(): void {
AppRouter.init(this.pathStack);
this.viewModel.load();
}
build() { build() {
Tabs({ barPosition: BarPosition.End, index: this.currentIndex }) { Navigation(this.pathStack) {
TabContent() { Column({ space: AppSpacing.md }) {
HomePage({ onGoLogin: () => { this.currentIndex = 3; } }) Text(this.title)
} .fontSize(24)
.tabBar(this.tabBarBuilder(0, '首页', $r('app.media.home'), $r('app.media.home_active'))) .fontWeight(FontWeight.Bold)
.fontColor(AppColor.textPrimary)
TabContent() { if (this.viewModel.loading) {
CheckinPage({ onGoLogin: () => { this.currentIndex = 3; } }) LoadingView({ text: '加载中...' })
} } else if (this.viewModel.error.length > 0) {
.tabBar(this.tabBarBuilder(1, '打卡', $r('app.media.checkin'), $r('app.media.checkin_active'))) EmptyView({ text: this.viewModel.error })
} else {
ForEach(this.viewModel.users, (user: User) => {
StatusCard({ title: user.name, subtitle: user.email })
}, (user: User) => user.id)
}
TabContent() { AppButton({
PlanPage({ onGoLogin: () => { this.currentIndex = 3; } }) text: '进入详情页',
onTap: () => {
AppRouter.push(RouteNames.DETAIL, '来自首页的参数');
}
})
} }
.tabBar(this.tabBarBuilder(2, '计划', $r('app.media.plan'), $r('app.media.plan_active'))) .width('100%')
.height('100%')
TabContent() { .padding(AppSpacing.lg)
ProfilePage() .backgroundColor(AppColor.background)
}
.tabBar(this.tabBarBuilder(3, '我的', $r('app.media.profile'), $r('app.media.profile_active')))
} }
.onChange((index: number) => { .navDestination(this.routeMap)
this.currentIndex = index; .mode(NavigationMode.Stack)
}) .hideTitleBar(true)
.width('100%')
.height('100%')
.backgroundColor(AppColor.background)
} }
@Builder @Builder
tabBarBuilder(index: number, title: string, normalIcon: Resource, activeIcon: Resource) { routeMap(name: string, param: Object) {
Column({ space: 2 }) { RouteMap(name, param)
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

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

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

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

@@ -1,41 +1,37 @@
import { HomeData } from '../model/HomeModels'; import { User } from '../model/User';
import { HomeRepository } from '../data/repository/HomeRepository'; import { UserRepository } from '../data/repository/UserRepository';
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 data: HomeData = new HomeData(); @Trace users: User[] = [];
@Trace isGuest: boolean = true;
@Trace loading: boolean = false; @Trace loading: boolean = false;
@Trace error: string = ''; @Trace error: string = '';
private readonly repository: HomeRepository; private readonly repository: UserRepository;
private readonly session: SessionRepository;
constructor(repository: HomeRepository, session: SessionRepository) { constructor(repository: UserRepository) {
this.repository = repository; this.repository = repository;
this.session = session;
} }
/**
* 工厂:从 DI 容器解析依赖并装配,避免页面直接触碰 data 层类型。
*/
static create(): HomeViewModel { static create(): HomeViewModel {
return new HomeViewModel( return new HomeViewModel(ServiceLocator.get('userRepository') as UserRepository);
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(); // 演示:拉取一个示例用户;真实业务可替换为列表接口。
this.isGuest = !loggedIn; const user = await this.repository.getUser('demo-user');
this.data = loggedIn ? await this.repository.observeHome() : await this.repository.observeGuestHome(); this.users = [user];
} catch (err) { } catch (err) {
this.error = JSON.stringify(err); this.error = JSON.stringify(err);
} finally { } finally {

View File

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

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -1,89 +0,0 @@
{
"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"
}
}
}

View File

@@ -1,11 +1,4 @@
export { AppColor, AppSpacing, AppFontSize, AppRadius } from './theme/Theme'; export { AppColor, AppSpacing, AppFontSize } 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';

View File

@@ -1,10 +1,8 @@
import { AppColor, AppFontSize, AppRadius } from '../theme/Theme'; import { AppColor } from '../theme/Theme';
export enum ButtonVariant { export enum ButtonVariant {
Primary = 'primary', Primary = 'primary',
Secondary = 'secondary', Secondary = 'secondary'
Danger = 'danger',
Outline = 'outline'
} }
/** /**
@@ -15,19 +13,16 @@ 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(AppFontSize.title) .fontSize(16)
.fontWeight(FontWeight.Medium) .fontColor(this.variant === ButtonVariant.Primary ? AppColor.white : AppColor.primary)
.fontColor(this.fontColor()) .backgroundColor(this.variant === ButtonVariant.Primary ? AppColor.primary : AppColor.white)
.backgroundColor(this.bgColor()) .borderRadius(8)
.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) {
@@ -35,24 +30,4 @@ 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;
}
} }

View File

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

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

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

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

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

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

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

@@ -1,66 +1,16 @@
/** /**
* 主题 token:颜色 / 间距 / 字号 / 圆角。 * 主题 token:颜色 / 间距 / 字号。
* 全 App 统一从这里取值,避免散落魔法值。 * 全 App 统一从这里取值,避免散落魔法值。
* 品牌色板对齐「明缘云道场」小程序(深棕 / 金 / 米白)。
*/ */
export class AppColor { export class AppColor {
// 品牌主色(金) static readonly primary: string = '#0A59F7';
static readonly primary: string = '#C67B30'; static readonly primaryPressed: string = '#094CCB';
static readonly primaryPressed: string = '#A8611F'; static readonly textPrimary: string = '#182431';
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 = '#F2EDE7'; static readonly divider: string = '#0D182431';
static readonly dividerDark: string = '#E4D9CB'; static readonly danger: string = '#E84026';
// 品牌深棕 / 金系
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 {
@@ -76,13 +26,4 @@ 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;
} }