v0.1.0 界面框架:抽屉骨架 + 13 个模块页面

对应服务端 FamilyBook(家书)的鸿蒙客户端首版。本轮只做**界面框架**,
数据全部是演示数据 —— 服务端尚无客户端接口,所有增删改只做入口与样式,
不落任何数据。

## 骨架
左侧抽屉 + 右侧内容。抽屉两套形态由窗口短边决定(≥600vp 为宽屏):
宽屏常驻侧栏、窄屏滑出层。判据取「短边」而非「宽度」,因为短边在旋转时
不变,比例接近 1:1 的设备横竖屏布局天然一致。

## 模块(与 Web 端 Module::LIST 一一对应,key 即路由名)
首页、家谱、名册、观影、旅程、日程、资源、任务、纪念日、财务、读书、
导航、笔记;另有通知消息、个人设置两个附加页。

## 与 Web 端保持一致的几处
· 模块清单唯一来源在 model/Modules.ets,加模块只改那一处;
· 模块主题色三档由主色按 HSL 压明度算出,预设色板与 Web 端逐值一致;
· 术语沿用 Web 端固定下来的口径(旅程 ⊃ 行程;观影的「影院」不单独占
  抽屉位,从模块内设置进);
· 页面结构照 Web 端的信息层次(首页信息面板 / 财务概览在账目之前 /
  纪念日「即将到来」在顶 / 任务按状态而非按看板)。

## 编译验证
hvigorw assembleHap --mode module -p product=default
→ BUILD SUCCESSFUL,0 error / 0 warning(含一次删 build 后的干净重建)

过程中修掉的编译期问题:
· Circle().fill() 是 API 26 才有的重载(compatibleSdk 是 24)
  → 换「方块 + 圆角」画圆,零版本风险;
· setWindowBackgroundColor 对 phone/tablet 不可用 → 去掉(页面已铺满同色);
· window.getLastWindow() 只有异步版 → 由 Ability 侧同步取窗存 AppStorage;
· 颜色类 @Param 声明成 string 导致传 Resource 报错 → 统一改 ResourceColor;
· 页面在 pages/<模块>/ 下,公共件 import 相对路径要多退一级。

(首轮编译 95 error / 18 warn,逐条定位到 5 类根因后归零。)
This commit is contained in:
2026-10-11 22:07:48 +08:00
commit 41e3aa3d95
80 changed files with 6985 additions and 0 deletions
+183
View File
@@ -0,0 +1,183 @@
/**
* 家书 FamilyBook · 主界面(鸿蒙端)
*
* 骨架:**左侧抽屉 + 右侧内容**,两套形态由窗口短边决定(见 FbAppState.wideLayout):
* · 宽屏(平板 / 折叠屏展开 / 横屏手机):抽屉常驻,内容区并排;
* · 窄屏:内容铺满,抽屉从左侧滑出(点遮罩或选中模块即收起)。
*
* 内容区 = 顶栏 + 当前模块页。此处只做**模块分发**,各模块的版面在各自文件里。
*
* ⚠️ 当前全部为**演示数据 / 静态界面**:还没有接口,所有「新建 / 编辑 / 删除」
* 只做入口与样式,不落任何数据。接口到位后只要替换各页面里的数据源即可。
*/
import { display, window } from '@kit.ArkUI';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { FbColors, FbMetric } from '../common/Theme';
import { FbAppState, FB_WIDE_BREAKPOINT, fbAppState } from '../common/Store';
import { FbDrawerShell } from '../components/Drawer';
import { FbTopBar } from '../components/TopBar';
import { FbHomePage } from './home/FbHomePage';
import { FbMoviesPage } from './movies/FbMoviesPage';
import { FbTripsPage } from './trips/FbTripsPage';
import { FbCalendarPage } from './calendar/FbCalendarPage';
import { FbTasksPage } from './tasks/FbTasksPage';
import { FbPeoplePage } from './people/FbPeoplePage';
import { FbResourcesPage } from './resources/FbResourcesPage';
import { FbDaysPage } from './days/FbDaysPage';
import { FbLedgerPage } from './ledger/FbLedgerPage';
import { FbBooksPage } from './books/FbBooksPage';
import { FbNavPage, FbNotesPage, FbNotificationsPage } from './misc/FbMiscPages';
import { FbSettingsPage } from './settings/FbSettingsPage';
@Entry
@ComponentV2
struct Index {
/** 全局状态(抽屉 / 当前模块 / 宽窄屏) */
@Local app: FbAppState = fbAppState();
/** 顶部安全区高度(状态栏),由窗口避让信息算出 */
@Local topInset: number = 0;
/** 底部安全区高度(手势条) */
@Local bottomInset: number = 0;
aboutToAppear(): void {
this.measureInsets();
}
build() {
Column() {
// 顶部安全区:把内容推到状态栏下方(状态栏底色由 EntryAbility 设成页面底色)
Column()
.width('100%')
.height(this.topInset)
.backgroundColor(FbColors.surface)
// 主骨架:抽屉 + 内容
Column() {
FbDrawerShell({ app: this.app }) {
this.mainArea()
}
}
.layoutWeight(1)
.width('100%')
// 底部安全区
Column()
.width('100%')
.height(this.bottomInset)
.backgroundColor(FbColors.surface)
}
.width('100%')
.height('100%')
.backgroundColor(FbColors.paper)
}
/** 内容区:顶栏 + 当前模块页 */
@Builder
mainArea() {
Column() {
// ---- 顶栏(右侧插槽由各模块页填充;这里统一留空,
// 因为「新建/搜索」这类操作已经放进各页自己的操作条里了)----
FbTopBar({ app: this.app }) {
}
// ---- 模块页 ----
Column() {
this.moduleContent()
}
.layoutWeight(1)
.width('100%')
}
.width('100%')
.height('100%')
.backgroundColor(FbColors.paper)
}
/** 按当前模块渲染对应页面(用 if/else 分发:ArkUI 的 build 里不允许 switch) */
@Builder
moduleContent() {
if (this.app.activeModule === 'home') {
FbHomePage({ app: this.app }) {
}
} else if (this.app.activeModule === 'people' || this.app.activeModule === 'kin') {
FbPeoplePage({ app: this.app })
} else if (this.app.activeModule === 'movies') {
FbMoviesPage({ app: this.app })
} else if (this.app.activeModule === 'trips') {
FbTripsPage({ app: this.app })
} else if (this.app.activeModule === 'calendar') {
FbCalendarPage({ app: this.app })
} else if (this.app.activeModule === 'tasks') {
FbTasksPage({ app: this.app })
} else if (this.app.activeModule === 'resources') {
FbResourcesPage({ app: this.app })
} else if (this.app.activeModule === 'days') {
FbDaysPage({ app: this.app })
} else if (this.app.activeModule === 'ledger') {
FbLedgerPage({ app: this.app })
} else if (this.app.activeModule === 'books') {
FbBooksPage({ app: this.app })
} else if (this.app.activeModule === 'nav') {
FbNavPage({ app: this.app })
} else if (this.app.activeModule === 'notes') {
FbNotesPage({ app: this.app })
} else if (this.app.activeModule === '__notifications') {
FbNotificationsPage({ app: this.app })
} else if (this.app.activeModule === '__settings') {
FbSettingsPage({ app: this.app })
} else {
// 兜底:模块还没做页面时给一句提示,而不是白屏
Column({ space: 8 }) {
Text('这个模块还没有页面')
.fontSize(FbMetric.fontBody)
.fontColor(FbColors.muted)
Text('当前模块:' + this.app.activeModule)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
}
/**
* 量安全区与宽窄屏。
*
* ⚠️ 状态栏高度**不写死数字**:不同机型(刘海、挖孔、无手势条)差很多,
* 写死必然在某些机型上错位。这里按窗口避让信息取真实值。
*
* ⚠️ 窗口引用从 EntryAbility 放进 AppStorage(`fbMainWindow`)后再取 ——
* `window.getLastWindow()` 只有异步版,在 aboutToAppear 里 await 会把
* 首帧的布局延后一帧;而 `windowStage.getMainWindowSync()` 是同步的,
* 由 Ability 侧取一次存下来最省事。
*/
private measureInsets(): void {
try {
const d: display.Display = display.getDefaultDisplaySync();
const density: number = d.densityPixels;
if (density <= 0) {
return;
}
// 宽窄屏:按窗口短边(短边旋转时不变,故比例接近 1:1 的设备横竖屏布局天然一致)
const shortSide: number = Math.min(d.width, d.height) / density;
this.app.wideLayout = shortSide >= FB_WIDE_BREAKPOINT;
// 安全区:取窗口的真实避让矩形
const win: window.Window | undefined =
AppStorage.get<window.Window>('fbMainWindow');
if (win !== undefined) {
const top: window.AvoidArea = win.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM);
this.topInset = top.topRect.height / density;
const bottom: window.AvoidArea =
win.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR);
this.bottomInset = bottom.bottomRect.height / density;
}
} catch (err) {
// 取不到就退到「只判宽窄屏、安全区按 0」—— 页面自带 12vp 顶部留白,不会贴边
hilog.warn(0x0000, 'FamilyBook', 'measureInsets failed: %{public}s', JSON.stringify(err));
}
}
}
@@ -0,0 +1,195 @@
/**
* 家书 FamilyBook · 读书模块(鸿蒙端)
*
* 对应 Web 端 `templates/books/index.php`(书架 + 上传解析 + 在线读 + 笔记 + 统计)。
* 端上先做**书架**这一层:按状态分组(在读 / 想读 / 读完),
* 每本书显示作者、评分、笔记数、阅读进度。
*
* 「在线读」「上传 EPUB 解析」「笔记导入」这些属于交互+文件能力,留给第二轮。
*/
import { FbColors, FbMetric } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbBookItem, FbGroup } from '../../model/Types';
import { FB_DEMO_BOOKS, fbGroupBook } from '../../model/DemoData';
import { FbEmptyBox, FbIconButton, FbProgressLine, FbRating, FbTag } from '../../components/Widgets';
@ComponentV2
export struct FbBooksPage {
@Param app: FbAppState = fbAppState();
/** 模块主题色(读书 = 藕紫) */
private accent: string = '#6B5B95';
private all: FbBookItem[] = FB_DEMO_BOOKS;
build() {
Column() {
// ---- 页头 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('书架')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.all.length.toString() + ' 本 · '
+ this.readingCount().toString() + ' 本在读 · '
+ this.noteCount().toString() + ' 条笔记')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
// 上传 / 统计(Web 端在设置菜单里)
FbIconButton({ icon: $r('app.media.fb_ic_chart'), tint: FbColors.muted })
FbIconButton({ icon: $r('app.media.fb_ic_gear'), tint: FbColors.muted })
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
// ---- 分组列表 ----
if (this.all.length === 0) {
FbEmptyBox({
title: '书架还是空的',
hint: '传一本进来,或先记下想读的书',
icon: $r('app.media.fb_ic_books')
})
} else {
List({ space: 0 }) {
ForEach(this.groups(), (g: FbGroup<FbBookItem>) => {
ListItemGroup({ header: this.groupHeader(g.title, g.count) }) {
ForEach(g.items, (b: FbBookItem) => {
ListItem() {
this.bookRow(b)
}
}, (b: FbBookItem): string => 'bk_' + b.id.toString())
}
}, (g: FbGroup<FbBookItem>): string => 'bg_' + g.key)
}
.layoutWeight(1)
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 24 })
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
}
.width('100%')
.height('100%')
.backgroundColor(FbColors.paper)
.alignItems(HorizontalAlign.Start)
}
/** 一本书:书脊色块(代替封面)+ 书名/作者 + 评分/笔记/进度 */
@Builder
bookRow(b: FbBookItem) {
Row({ space: 12 }) {
// 书脊:用书名首字 + 模块色底(与 Web 端的 book-cover-empty 占位同思路)
Stack({ alignContent: Alignment.Center }) {
Column()
.width('100%')
.height('100%')
.linearGradient({
angle: 160,
colors: [[this.accent, 0.0], ['#4A3D6B', 1.0]]
})
Text(b.title.substring(0, 1))
.fontSize(20)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFFD9')
}
.width(44)
.height(60)
.borderRadius(6)
.clip(true)
Column({ space: 5 }) {
Text(b.title)
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(b.author)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 10 }) {
if (b.rate > 0) {
FbRating({ score: b.rate, max: 5 })
}
if (b.noteCount > 0) {
Row({ space: 3 }) {
Image($r('app.media.fb_ic_book'))
.width(11)
.height(11)
.renderMode(ImageRenderMode.Template)
.fillColor(FbColors.faint)
Text(b.noteCount.toString())
.fontSize(10)
.fontColor(FbColors.faint)
}
}
if (b.status === 'reading') {
FbTag({ text: b.progress.toString() + '%', tone: 'brand' })
}
}
.alignItems(VerticalAlign.Center)
if (b.status === 'reading') {
FbProgressLine({ percent: b.progress, color: this.accent })
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Image($r('app.media.fb_ic_forward'))
.width(13)
.height(13)
.renderMode(ImageRenderMode.Template)
.fillColor(FbColors.faint)
}
.width('100%')
.padding(14)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ bottom: 8 })
.alignItems(VerticalAlign.Center)
}
@Builder
groupHeader(title: string, count: string) {
Row({ space: 8 }) {
Text(title)
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(count)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Blank()
}
.width('100%')
.padding({ left: 4, right: 4, top: 12, bottom: 8 })
.backgroundColor(FbColors.paper)
}
private groups(): FbGroup<FbBookItem>[] {
return fbGroupBook(this.all);
}
private readingCount(): number {
return this.all.filter((b: FbBookItem): boolean => b.status === 'reading').length;
}
private noteCount(): number {
let n: number = 0;
for (const b of this.all) {
n = n + b.noteCount;
}
return n;
}
}
@@ -0,0 +1,449 @@
/**
* 家书 FamilyBook · 日程模块(鸿蒙端)
*
* 对应 Web 端 `templates/calendar/index.php`。Web 端是「月 / 周 / 天」三视图,
* 左侧的「我的日历本」那一栏已被用户要求撤掉、功能收进右侧设置 —— 端上照此:
* 日历占满宽度,日历本显隐 / 颜色 / 导入都在页头那个齿轮里。
*
* 端上保留了月视图(月历格子)与「选中日」的日程列表上下两块:
* 手机屏幕放不下「月历 + 当天时间轴」并排,上下排列最自然。
*
* ⚠️ 月历格子用 7 列 Grid,每格显示「日号 + 该日条目数的小点」。
* 真正的周/天时间轴视图(按半小时定位)建议放到第二轮做 —— 它需要
* 手势与拖动建日程,属于交互实现,不在本轮「先出界面」的范围。
*/
import { FbColors, FbMetric, fbPalette } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbCalBook, FbCalEvent } from '../../model/Types';
import { FB_DEMO_CAL_BOOKS, FB_DEMO_EVENTS_SOON, FB_DEMO_EVENTS_TODAY } from '../../model/DemoData';
import { FbDot, FbEmptyBox, FbIconButton, FbPrimaryButton } from '../../components/Widgets';
/** 月历上「非本月」日期点的灰(hex 常量:格子数据结构里存的是字符串色,不能用 Resource) */
const FB_FAINT: string = '#A8988A';
const FB_MUTED: string = '#7E6B5A';
/** 月历格子(一个日期) */
interface FbCalCell {
/** 日期数字 */
day: number;
/** 是否属于当月 */
inMonth: boolean;
/** 是否今天 */
isToday: boolean;
/** 该日的事件数(0 不显示点) */
count: number;
/** 该日第一个事件的色(画小点用) */
dotColor: string;
}
@ComponentV2
export struct FbCalendarPage {
@Param app: FbAppState = fbAppState();
/** 模块主题色(日程 = 琥珀) */
private accent: string = '#B0763A';
private books: FbCalBook[] = FB_DEMO_CAL_BOOKS;
private todayEvents: FbCalEvent[] = FB_DEMO_EVENTS_TODAY;
private soonEvents: FbCalEvent[] = FB_DEMO_EVENTS_SOON;
/** 当前显示的月份(首次进入取本月) */
@Local showYear: number = new Date().getFullYear();
@Local showMonth: number = new Date().getMonth() + 1;
/** 选中的日期(0 = 今天) */
@Local pickedDay: number = new Date().getDate();
private weekLabels: string[] = ['一', '二', '三', '四', '五', '六', '日'];
build() {
Column() {
// ---- 页头:月份 + 视图切换 + 新建 + 设置 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text(this.showYear.toString() + ' 年 ' + this.showMonth.toString() + ' 月')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.visibleBooks().length.toString() + ' 本日历可见 · 本月 '
+ this.monthEventCount().toString() + ' 条')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
FbPrimaryButton({
label: '新建',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
FbIconButton({ icon: $r('app.media.fb_ic_gear'), tint: FbColors.muted })
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
Scroll() {
Column({ space: 0 }) {
// ---- 月历卡 ----
Column({ space: 0 }) {
// 月份切换条
Row() {
this.navBtn('上一月', -1)
Blank()
Text('今天')
.fontSize(FbMetric.fontSmall)
.fontColor(this.accent)
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.backgroundColor(fbPalette(this.accent).brand + '14')
.borderRadius(FbMetric.radiusPill)
.onClick(() => {
const d: Date = new Date();
this.showYear = d.getFullYear();
this.showMonth = d.getMonth() + 1;
this.pickedDay = d.getDate();
})
Blank()
this.navBtn('下一月', 1)
}
.width('100%')
.padding({ bottom: 10 })
// 星期表头
Row() {
ForEach(this.weekLabels, (w: string) => {
Text(w)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.layoutWeight(1)
.textAlign(TextAlign.Center)
}, (w: string): string => 'wk_' + w)
}
.width('100%')
.padding({ bottom: 6 })
// 日期网格(7 列)
Grid() {
ForEach(this.cells(), (c: FbCalCell) => {
GridItem() {
this.dayCell(c)
}
}, (c: FbCalCell): string => 'cell_' + c.day.toString() + '_'
+ (c.inMonth ? '1' : '0'))
}
.columnsTemplate('1fr 1fr 1fr 1fr 1fr 1fr 1fr')
.rowsGap(2)
.width('100%')
.height(this.gridHeight())
.scrollBar(BarState.Off)
}
.width('100%')
.padding(FbMetric.cardPad)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad })
// ---- 日历本(可见的几本,横向排列当作图例)----
Column({ space: 10 }) {
Row({ space: 8 }) {
ForEach(this.visibleBooks(), (b: FbCalBook) => {
Row({ space: 5 }) {
FbDot({ dia: 7, fillC: b.color })
Text(b.name)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.maxLines(1)
}
.padding({ right: 10 })
}, (b: FbCalBook): string => 'book_' + b.id.toString())
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
.width('100%')
.padding({ left: FbMetric.pagePad + 4, right: FbMetric.pagePad + 4, top: 12 })
.alignItems(HorizontalAlign.Start)
// ---- 选中日的日程 ----
Column({ space: 0 }) {
Row() {
Text(this.pickedDay.toString() + ' 日')
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Blank()
Text(this.todayEvents.length.toString() + ' 条日程')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.width('100%')
.padding({ left: 4, right: 4, top: 12, bottom: 8 })
Column({ space: 0 }) {
if (this.todayEvents.length === 0) {
FbEmptyBox({
title: '这天没有安排',
hint: '在月历里点一下某天,就能在那天加日程',
icon: $r('app.media.fb_ic_calendar')
})
} else {
ForEach(this.todayEvents, (ev: FbCalEvent, idx: number) => {
this.eventRow(ev, idx === this.todayEvents.length - 1)
}, (ev: FbCalEvent): string => 'cev_' + ev.id.toString())
}
}
.width('100%')
.padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, bottom: 6 })
}
.width('100%')
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 })
// ---- 未来七天 ----
Column({ space: 0 }) {
Row() {
Text('未来七天')
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Blank()
Text('共 ' + this.soonEvents.length.toString() + ' 条')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.width('100%')
.padding({ left: 4, right: 4, top: 12, bottom: 8 })
Column({ space: 0 }) {
ForEach(this.soonEvents, (ev: FbCalEvent, idx: number) => {
this.eventRow(ev, idx === this.soonEvents.length - 1)
}, (ev: FbCalEvent): string => 'sev_' + ev.id.toString())
}
.width('100%')
.padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, bottom: 6 })
}
.width('100%')
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 28 })
}
.width('100%')
}
.layoutWeight(1)
.width('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
}
.width('100%')
.height('100%')
.backgroundColor(FbColors.paper)
.alignItems(HorizontalAlign.Start)
}
/** 上一月 / 下一月按钮 */
@Builder
navBtn(label: string, delta: number) {
Text(label)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
.onClick(() => {
let m: number = this.showMonth + delta;
let y: number = this.showYear;
if (m < 1) {
m = 12;
y = y - 1;
} else if (m > 12) {
m = 1;
y = y + 1;
}
this.showMonth = m;
this.showYear = y;
this.pickedDay = 0;
})
}
/** 一个日期格子 */
@Builder
dayCell(c: FbCalCell) {
Column({ space: 3 }) {
Text(c.day.toString())
.fontSize(FbMetric.fontSmall)
.fontWeight(c.isToday || c.day === this.pickedDay ? FontWeight.Medium : FontWeight.Normal)
.fontColor(c.isToday
? Color.White
: (c.inMonth ? FbColors.ink : FbColors.faint))
.width(26)
.height(26)
.textAlign(TextAlign.Center)
.borderRadius(13)
.backgroundColor(c.isToday
? this.accent
: (c.day === this.pickedDay && c.inMonth
? fbPalette(this.accent).brand + '1F'
: Color.Transparent))
// 事件小点(最多 3 个)
Row({ space: 2 }) {
if (c.count > 0) {
ForEach(this.dots(c.count), (i: number) => {
FbDot({ dia: 4, fillC: c.dotColor })
}, (i: number): string => 'dot_' + c.day.toString() + '_' + i.toString())
}
}
.height(5)
}
.width('100%')
.height(44)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.onClick(() => {
this.pickedDay = c.day;
})
}
/** 一条日程行:时间 + 色点 + 标题 + 日历本标签 */
@Builder
eventRow(ev: FbCalEvent, isLast: boolean) {
Column({ space: 0 }) {
Row({ space: 10 }) {
Text(ev.allDay ? '全天' : ev.time)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.width(44)
FbDot({ dia: 8, fillC: ev.color })
Text(ev.title)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(ev.calName)
.fontSize(10)
.fontColor(Color.White)
.backgroundColor(ev.color)
.borderRadius(4)
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
.maxLines(1)
}
.width('100%')
.padding({ top: 11, bottom: 11 })
.alignItems(VerticalAlign.Center)
if (!isLast) {
Divider()
.color(FbColors.line2)
.margin({ left: 54 })
}
}
.width('100%')
}
/** 只列「显示着的」日历本(与 Web 端 Calendar::hiddenCalendars 的反面同口径) */
private visibleBooks(): FbCalBook[] {
return this.books.filter((b: FbCalBook): boolean => b.visible);
}
/** 本月事件数(演示数据里用未来七天 + 今天的条数代表示意) */
private monthEventCount(): number {
return this.todayEvents.length + this.soonEvents.length;
}
/** 造「点」的索引数组(ForEach 需要数组,不能直接循环次数) */
private dots(count: number): number[] {
const n: number = Math.min(3, count);
const out: number[] = [];
for (let i = 0; i < n; i++) {
out.push(i);
}
return out;
}
/** 网格高度 = 行数 × 行高(6 行时 6×46) */
private gridHeight(): number {
const rows: number = Math.ceil(this.cells().length / 7);
return rows * 46;
}
/**
* 生成当月月历格子。
*
* 周一起始(与 Web 端一致 —— 中文日历习惯周一为第一列)。
*/
private cells(): FbCalCell[] {
const out: FbCalCell[] = [];
const y: number = this.showYear;
const m: number = this.showMonth;
const first: Date = new Date(y, m - 1, 1);
// JS 的 getDay(): 0=周日 … 6=周六 ⇒ 换算成「周一为 0」
const lead: number = (first.getDay() + 6) % 7;
const daysInMonth: number = new Date(y, m, 0).getDate();
const prevDays: number = new Date(y, m - 1, 0).getDate();
const now: Date = new Date();
const isThisMonth: boolean = now.getFullYear() === y && (now.getMonth() + 1) === m;
// 前置:上月的尾巴
for (let i = lead - 1; i >= 0; i--) {
out.push({
day: prevDays - i,
inMonth: false,
isToday: false,
count: 0,
dotColor: FB_MUTED
});
}
// 本月
for (let d = 1; d <= daysInMonth; d++) {
const cnt: number = this.eventCountOf(d, isThisMonth);
out.push({
day: d,
inMonth: true,
isToday: isThisMonth && now.getDate() === d,
count: cnt,
dotColor: cnt > 0 ? this.accent : FB_FAINT
});
}
// 补齐到整周(补下月的头)
const tail: number = (7 - (out.length % 7)) % 7;
for (let d = 1; d <= tail; d++) {
out.push({
day: d,
inMonth: false,
isToday: false,
count: 0,
dotColor: FB_FAINT
});
}
return out;
}
/** 某天的日程条数(演示数据:今天取 todayEvents,其余从 soonEvents 的 'MM-DD' 匹配) */
private eventCountOf(day: number, isThisMonth: boolean): number {
if (!isThisMonth) {
return 0;
}
const now: Date = new Date();
if (now.getDate() === day) {
return this.todayEvents.length;
}
const mm: string = this.showMonth < 10
? '0' + this.showMonth.toString()
: this.showMonth.toString();
const dd: string = day < 10 ? '0' + day.toString() : day.toString();
const key: string = mm + '-' + dd;
let n: number = 0;
for (const ev of this.soonEvents) {
if (ev.date === key) {
n++;
}
}
return n;
}
}
@@ -0,0 +1,314 @@
/**
* 家书 FamilyBook · 纪念日模块(鸿蒙端)
*
* 对应 Web 端 `templates/days/index.php`。
*
* Web 端的结构:**「即将到来」横条在顶**(30 天内的纪念日,含名册里每个人的生日),
* 下面是全部纪念日的列表(可按类别筛)。
*
* 端上照此,但把「即将到来」做成**大数字卡片**(还剩几天用大字),
* 因为「还有几天」是这个模块唯一真正要一眼看到的信息。
*/
import { FbColors, FbMetric } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbAnniversaryItem, FbKinChip } from '../../model/Types';
import { FB_DEMO_ANNIVERSARIES } from '../../model/DemoData';
import { FbAvatar, FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../components/Widgets';
@ComponentV2
export struct FbDaysPage {
@Param app: FbAppState = fbAppState();
/** 模块主题色(纪念日 = 陶土橙) */
private accent: string = '#B4633C';
private all: FbAnniversaryItem[] = FB_DEMO_ANNIVERSARIES;
/** 类别筛选('' = 全部) */
@Local kindFilter: string = '';
build() {
Column() {
// ---- 页头 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('纪念日')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text('节日 / 纪念日 / 生日,都在这一条时间线上')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
FbPrimaryButton({
label: '添加',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
FbIconButton({ icon: $r('app.media.fb_ic_gear'), tint: FbColors.muted })
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
Scroll() {
Column({ space: 0 }) {
// ---- 即将到来(30 天内)----
Column({ space: 0 }) {
Row({ space: 8 }) {
Text('即将到来')
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Blank()
Text('30 天内')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.width('100%')
.padding({ left: 4, right: 4, bottom: 10 })
if (this.upcoming().length === 0) {
Text('三十天内没有纪念日')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
} else {
// 横向排列的「大数字」卡
Scroll() {
Row({ space: 10 }) {
ForEach(this.upcoming(), (a: FbAnniversaryItem) => {
this.upcomingCard(a)
}, (a: FbAnniversaryItem): string => 'up_' + a.id.toString())
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
}
}
.width('100%')
.padding(FbMetric.cardPad)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad })
.alignItems(HorizontalAlign.Start)
// ---- 全部纪念日 ----
Row({ space: 8 }) {
this.kindChip('全部', '')
this.kindChip('生日', 'birthday')
this.kindChip('纪念日', 'anniversary')
this.kindChip('节日', 'holiday')
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 14, bottom: 8 })
if (this.filtered().length === 0) {
FbEmptyBox({
title: '这里还没有纪念日',
hint: '名册里填上生日,它就会自动出现在这儿',
icon: $r('app.media.fb_ic_days')
})
} else {
Column({ space: 0 }) {
ForEach(this.filtered(), (a: FbAnniversaryItem, idx: number) => {
this.annRow(a, idx === this.filtered().length - 1)
}, (a: FbAnniversaryItem): string => 'ann_' + a.id.toString())
}
.width('100%')
.padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, top: 4, bottom: 8 })
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 28 })
}
}
.width('100%')
}
.layoutWeight(1)
.width('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
}
.width('100%')
.height('100%')
.backgroundColor(FbColors.paper)
.alignItems(HorizontalAlign.Start)
}
/** 即将到来的大数字卡 */
@Builder
upcomingCard(a: FbAnniversaryItem) {
Column({ space: 4 }) {
if (a.daysLeft === 0) {
Text('今天')
.fontSize(24)
.fontWeight(FontWeight.Medium)
.fontColor(Color.White)
} else {
Row({ space: 1 }) {
Text(a.daysLeft.toString())
.fontSize(26)
.fontWeight(FontWeight.Medium)
.fontColor(Color.White)
Text('天')
.fontSize(FbMetric.fontTiny)
.fontColor('#FFFFFFCC')
.margin({ bottom: 4 })
}
.alignItems(VerticalAlign.Bottom)
}
Text(a.title)
.fontSize(FbMetric.fontSmall)
.fontColor(Color.White)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(a.date)
.fontSize(10)
.fontColor('#FFFFFFB3')
}
.width(96)
.padding(14)
.backgroundColor(this.accent)
.borderRadius(FbMetric.radius)
.alignItems(HorizontalAlign.Start)
}
/** 一行纪念日:日期 + 标题 + 相关人 + 天数 */
@Builder
annRow(a: FbAnniversaryItem, isLast: boolean) {
Column({ space: 0 }) {
Row({ space: 12 }) {
// 日期块
Column({ space: 0 }) {
Text(this.monthDay(a.date))
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.yearText(a.date))
.fontSize(9)
.fontColor(FbColors.faint)
}
.width(52)
.alignItems(HorizontalAlign.Start)
Column({ space: 4 }) {
Row({ space: 6 }) {
Text(a.title)
.fontSize(FbMetric.fontBody)
.fontColor(FbColors.ink)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
FbTag({ text: this.kindName(a.kind), tone: this.kindTone(a.kind) })
}
.width('100%')
.alignItems(VerticalAlign.Center)
// 相关人(生日才有)
if (a.people.length > 0) {
Row({ space: 6 }) {
ForEach(a.people, (p: FbKinChip) => {
Row({ space: 3 }) {
FbAvatar({ name: p.shown, color: p.avatarColor, dia: 16, fontSizeVp: 9 })
Text(p.shown)
.fontSize(10)
.fontColor(FbColors.muted)
}
.alignItems(VerticalAlign.Center)
}, (p: FbKinChip): string => 'ap_' + a.id.toString() + '_' + p.id.toString())
}
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
// 剩余天数
Text(a.daysLeft === 0 ? '今天' : a.daysLeft.toString() + ' 天')
.fontSize(FbMetric.fontTiny)
.fontWeight(a.daysLeft <= 7 ? FontWeight.Medium : FontWeight.Normal)
.fontColor(a.daysLeft <= 7 ? this.accent : FbColors.faint)
}
.width('100%')
.padding({ top: 12, bottom: 12 })
.alignItems(VerticalAlign.Center)
if (!isLast) {
Divider().color(FbColors.line2).margin({ left: 64 })
}
}
.width('100%')
}
@Builder
kindChip(label: string, key: string) {
Text(label)
.fontSize(FbMetric.fontSmall)
.fontColor(this.kindFilter === key ? Color.White : FbColors.muted)
.backgroundColor(this.kindFilter === key ? this.accent : FbColors.inputBg)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(FbMetric.radiusPill)
.onClick(() => {
this.kindFilter = key;
})
}
/** 'MM-DD' → '10-17';'YYYY-MM-DD' → '11-08' */
private monthDay(date: string): string {
const parts: string[] = date.split('-');
if (parts.length === 3) {
return parts[1] + '-' + parts[2];
}
return date;
}
/** 带年份的日期显示年份(如 '2019-11-08' → '2019'),不带则显示「每年」 */
private yearText(date: string): string {
const parts: string[] = date.split('-');
if (parts.length === 3) {
return parts[0];
}
return '每年';
}
private kindName(kind: string): string {
if (kind === 'birthday') {
return '生日';
}
if (kind === 'holiday') {
return '节日';
}
if (kind === 'solar_term') {
return '节气';
}
return '纪念日';
}
private kindTone(kind: string): string {
if (kind === 'birthday') {
return 'brand';
}
if (kind === 'holiday') {
return 'red';
}
if (kind === 'solar_term') {
return 'green';
}
return 'blue';
}
private upcoming(): FbAnniversaryItem[] {
return this.all.filter((a: FbAnniversaryItem): boolean => a.daysLeft <= 30);
}
private filtered(): FbAnniversaryItem[] {
if (this.kindFilter.length === 0) {
return this.all;
}
return this.all.filter((a: FbAnniversaryItem): boolean => a.kind === this.kindFilter);
}
}
@@ -0,0 +1,438 @@
/**
* 家书 FamilyBook · 首页(鸿蒙端)
*
* 对应 Web 端 `templates/home.php`。版面照搬它的三段:
* ① hero(家庭名 + 格言 + 我的资料/家谱两个快捷入口)
* ② 信息面板(问候 + 日期/星期/农历 + 日子标记 | 右侧天气)
* ③ 今天的日程 + 近期任务(两块并排;窄屏上下堆叠)
* ④ 「往年的今天」回忆条
* ⑤ 模块入口卡片(12 个模块的关键数字)
*
* ⚠️ 只做**界面**,不做交互实现:卡片点了切模块(前端状态),不发请求。
*/
import { FbColors, FbMetric, fbPalette } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbModuleCard, FbCalEvent, FbTaskItem, FbKinChip } from '../../model/Types';
import {
FB_DEMO_EVENTS_TODAY, FB_DEMO_EVENTS_SOON, FB_DEMO_KIN, FB_DEMO_MARKS,
FB_DEMO_MODULE_CARDS, FB_DEMO_TASKS, FB_DEMO_WEATHER, FB_DEMO_WEEK_STAT
} from '../../model/DemoData';
import { FbAvatar, FbDot, FbDotLine, FbSectionTitle, FbStatTriple, FbTag } from '../../components/Widgets';
@ComponentV2
export struct FbHomePage {
@Param app: FbAppState = fbAppState();
/** 顶栏右侧插槽(由外层传入,本页不用,留空占位以保持签名统一) */
@BuilderParam topActions: () => void;
private cards: FbModuleCard[] = FB_DEMO_MODULE_CARDS;
private todayEvents: FbCalEvent[] = FB_DEMO_EVENTS_TODAY;
private soonEvents: FbCalEvent[] = FB_DEMO_EVENTS_SOON;
private tasks: FbTaskItem[] = FB_DEMO_TASKS;
/** 问候语时段(与 Web 端 home.php 里那段 PHP 同口径) */
private hello(): string {
const h: number = new Date().getHours();
if (h < 5) {
return '夜深了';
}
if (h < 11) {
return '早上好';
}
if (h < 13) {
return '中午好';
}
if (h < 18) {
return '下午好';
}
return '晚上好';
}
/** 今天的日期与星期 */
private dateLine(): string {
const d: Date = new Date();
const week: string[] = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
return (d.getMonth() + 1).toString() + ' 月 ' + d.getDate().toString() + ' 日 · '
+ week[d.getDay()];
}
/** 天气图标(按文案粗略映射到已有的几个图标上,不引外部图) */
private weatherIcon(): Resource {
// TODO(接口):真实天气码 → 图标映射。现在只区分「有太阳/没太阳」两种。
return this.weather().text.indexOf('晴') >= 0
? $r('app.media.fb_ic_days')
: $r('app.media.fb_ic_calendar');
}
private weather() {
return FB_DEMO_WEATHER;
}
build() {
Scroll() {
Column({ space: 0 }) {
// ==================== ① hero ====================
Row({ space: 12 }) {
Image($r('app.media.fb_logo'))
.width(52)
.height(52)
.borderRadius(14)
Column({ space: 3 }) {
Text(this.app.session.familyTitle)
.fontSize(FbMetric.fontH1)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.app.session.slogan)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
// 两个快捷入口:我的资料 / 我的家谱
Row({ space: 8 }) {
this.heroAction($r('app.media.fb_ic_user'), 'people')
this.heroAction($r('app.media.fb_ic_kin'), 'kin')
}
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 16, bottom: 14 })
.alignItems(VerticalAlign.Center)
// ==================== ② 信息面板 ====================
Column({ space: 12 }) {
Row() {
Column({ space: 6 }) {
Row({ space: 4 }) {
Text(this.hello())
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(',' + this.app.session.me.name)
.fontSize(FbMetric.fontBody)
.fontColor(FbColors.muted)
}
Row({ space: 8 }) {
Text(this.dateLine())
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
Text('八月廿二')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
// 日子标记(节气 / 节日 / 生日)
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(FB_DEMO_MARKS, (mk: string, idx: number) => {
FbTag({ text: mk, tone: idx === 0 ? 'green' : 'brand' })
.margin({ right: 6, top: 2 })
}, (mk: string, idx: number): string => 'mk_' + idx.toString() + '_' + mk)
}
.width('100%')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
// 天气靠右
Row({ space: 8 }) {
Column({ space: 0 }) {
Text(this.weather().temp.toString() + '°')
.fontSize(26)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.weather().text + ' · ' + this.weather().wind)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.maxLines(1)
}
.alignItems(HorizontalAlign.End)
Image(this.weatherIcon())
.width(30)
.height(30)
.renderMode(ImageRenderMode.Template)
.fillColor(fbPalette('#B0763A').brand)
}
.alignItems(VerticalAlign.Center)
}
.width('100%')
.alignItems(VerticalAlign.Top)
Divider().color(FbColors.line2)
FbStatTriple({
labelA: '未来七天日程',
valueA: FB_DEMO_WEEK_STAT.events.toString(),
labelB: '待办',
valueB: FB_DEMO_WEEK_STAT.todos.toString(),
labelC: '逾期',
valueC: FB_DEMO_WEEK_STAT.overdue.toString(),
cAlarm: FB_DEMO_WEEK_STAT.overdue > 0
})
}
.width('100%')
.padding(FbMetric.cardPad)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad })
// ==================== ③ 今天的日程 + 近期任务 ====================
Column({ space: 0 }) {
FbSectionTitle({ title: '今天的日程', extra: this.todayEvents.length.toString() + ' 条' })
Column({ space: 10 }) {
if (this.todayEvents.length === 0) {
FbDotLine({ label: '今天没有安排,过得轻松一点', muted: true })
} else {
ForEach(this.todayEvents, (ev: FbCalEvent) => {
Row({ space: 10 }) {
Text(ev.allDay ? '全天' : ev.time)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.width(46)
Column() {
FbDot({ dia: 8, fillC: ev.color })
}
.width(8)
Text(ev.title)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(ev.calName)
.fontSize(10)
.fontColor(Color.White)
.backgroundColor(ev.color)
.borderRadius(4)
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
}
.width('100%')
.alignItems(VerticalAlign.Center)
}, (ev: FbCalEvent): string => 'tev_' + ev.id.toString())
}
}
.width('100%')
.padding(FbMetric.cardPad)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 8 })
Column({ space: 0 }) {
FbSectionTitle({
title: '近期任务',
extra: '进行中与已逾期'
})
Column({ space: 10 }) {
ForEach(this.recentTasks(), (t: FbTaskItem) => {
Row({ space: 10 }) {
FbDot({ dia: 8, fillC: t.overdue ? '#C0533F' : t.boardColor })
Text(t.title)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (t.childTotal > 0) {
Text(t.childDone.toString() + '/' + t.childTotal.toString())
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
if (t.dueText.length > 0) {
Text(t.dueText)
.fontSize(FbMetric.fontTiny)
.fontColor(t.overdue ? FbColors.red : FbColors.faint)
}
}
.width('100%')
.alignItems(VerticalAlign.Center)
}, (t: FbTaskItem): string => 'htk_' + t.id.toString())
}
.width('100%')
.padding(FbMetric.cardPad)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 })
// ==================== ④ 我的家人 ====================
Column({ space: 0 }) {
FbSectionTitle({ title: '我的家人', extra: '以我为中心 · 上下三代' })
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(FB_DEMO_KIN, (k: FbKinChip) => {
Column({ space: 4 }) {
FbAvatar({ name: k.shown, color: k.avatarColor, dia: 42, fontSizeVp: 17 })
Text(k.shown)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.ink)
.maxLines(1)
Text(k.kinship)
.fontSize(10)
.fontColor(FbColors.faint)
.maxLines(1)
}
.width(64)
.padding({ top: 6, bottom: 6 })
}, (k: FbKinChip): string => 'kin_' + k.id.toString())
}
.width('100%')
.padding(FbMetric.cardPad)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 })
// ==================== ⑤ 未来七天 ====================
Column({ space: 0 }) {
FbSectionTitle({ title: '未来七天', extra: this.soonEvents.length.toString() + ' 条' })
Column({ space: 10 }) {
ForEach(this.soonEvents, (ev: FbCalEvent) => {
Row({ space: 10 }) {
Text(ev.date)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.width(44)
Text(ev.allDay ? '全天' : ev.time)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.width(40)
FbDot({ dia: 7, fillC: ev.color })
Text(ev.title)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.alignItems(VerticalAlign.Center)
}, (ev: FbCalEvent): string => 'sev_' + ev.id.toString())
}
.width('100%')
.padding(FbMetric.cardPad)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 })
// ==================== ⑥ 模块入口 ====================
Column({ space: 0 }) {
FbSectionTitle({ title: '全部模块', extra: this.cards.length.toString() + ' 个' })
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(this.cards, (c: FbModuleCard) => {
this.moduleCard(c)
}, (c: FbModuleCard): string => 'mc_' + c.key)
}
.width('100%')
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 28 })
}
.width('100%')
}
.width('100%')
.height('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
}
/** hero 上的方形快捷入口 */
@Builder
heroAction(icon: Resource, target: string) {
Button({ type: ButtonType.ROUNDED_RECTANGLE }) {
Image(icon)
.width(20)
.height(20)
.renderMode(ImageRenderMode.Template)
.fillColor(FbColors.muted)
}
.width(40)
.height(40)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.onClick(() => {
this.app.switchModule(target);
})
}
/** 一块模块入口卡(图标 + 模块名 + 一句关键数字) */
@Builder
moduleCard(c: FbModuleCard) {
Column({ space: 8 }) {
Row({ space: 8 }) {
Image(this.cardIcon(c.key))
.width(18)
.height(18)
.renderMode(ImageRenderMode.Template)
.fillColor(fbPalette(c.color).brand)
Text(c.name)
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.layoutWeight(1)
.maxLines(1)
}
.width('100%')
.alignItems(VerticalAlign.Center)
Text(c.big)
.fontSize(20)
.fontWeight(FontWeight.Medium)
.fontColor(fbPalette(c.color).brand)
.maxLines(1)
Text(c.lines.length > 0 ? c.lines[0] : '')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('48.5%')
.padding(14)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ bottom: 10 })
.alignItems(HorizontalAlign.Start)
.onClick(() => {
this.app.switchModule(c.key);
})
}
/** 卡片图标(按模块 key 取) */
private cardIcon(key: string): Resource {
switch (key) {
case 'people': return $r('app.media.fb_ic_people');
case 'kin': return $r('app.media.fb_ic_kin');
case 'movies': return $r('app.media.fb_ic_movies');
case 'trips': return $r('app.media.fb_ic_trips');
case 'calendar': return $r('app.media.fb_ic_calendar');
case 'resources': return $r('app.media.fb_ic_resources');
case 'tasks': return $r('app.media.fb_ic_tasks');
case 'days': return $r('app.media.fb_ic_days');
case 'ledger': return $r('app.media.fb_ic_ledger');
case 'books': return $r('app.media.fb_ic_books');
case 'nav': return $r('app.media.fb_ic_nav');
case 'notes': return $r('app.media.fb_ic_notes');
default: return $r('app.media.fb_ic_home');
}
}
/** 首页只放「进行中 + 已逾期」两类任务(与 Web 端同一取舍) */
private recentTasks(): FbTaskItem[] {
return this.tasks.filter((t: FbTaskItem): boolean => t.status === 'doing' || t.overdue);
}
}
@@ -0,0 +1,364 @@
/**
* 家书 FamilyBook · 财务模块(鸿蒙端)
*
* 对应 Web 端 `templates/ledger/index.php`。Web 端的结构(用户明确要求过的):
* 「上面是概览,下面就是具体账目…新建账目和设置都在右侧;
* 每个账目的金额变化与总金额变化,在账本列表上面画出折线图。」
*
* 端上保留这个层次:
* ① 账本横条(多本账可横向切换)
* ② 概览卡:净资产大字 + 资产/负债分列(折线图位置留给第二轮,需要图表能力)
* ③ 账目表:按类别分组,每行带「盘点」入口
*
* ⚠️ 金额全程以**分**参与计算(`Ledger` 那套约定),显示时才转成元;
* 任何地方都不要拿「元」去加总 —— 浮点误差会越滚越大。
*/
import { FbColors, FbMetric } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbGroup, FbLedgerBook, FbLedgerEntry } from '../../model/Types';
import {
FB_DEMO_LEDGER_BOOKS, FB_DEMO_LEDGER_ENTRIES, fbLedgerKindName, fbMoney
} from '../../model/DemoData';
import { FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../components/Widgets';
@ComponentV2
export struct FbLedgerPage {
@Param app: FbAppState = fbAppState();
/** 模块主题色(财务 = 橄榄绿) */
private accent: string = '#5E7A3C';
private books: FbLedgerBook[] = FB_DEMO_LEDGER_BOOKS;
private entries: FbLedgerEntry[] = FB_DEMO_LEDGER_ENTRIES;
/** 当前账本 id(0 = 用第一本) */
@Local bookId: number = 0;
build() {
Column() {
// ---- 页头 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text(this.currentBook().name)
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.books.length.toString() + ' 本账 · '
+ this.currentBook().entryCount.toString() + ' 条账目')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
FbPrimaryButton({
label: '记一笔',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
FbIconButton({ icon: $r('app.media.fb_ic_gear'), tint: FbColors.muted })
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
// ---- 账本切换(横向)----
Scroll() {
Row({ space: 8 }) {
ForEach(this.books, (b: FbLedgerBook) => {
Row({ space: 6 }) {
Text(b.name)
.fontSize(FbMetric.fontSmall)
.fontColor(this.isCurrent(b.id) ? Color.White : FbColors.muted)
.maxLines(1)
if (b.myPerm !== 'owner') {
FbTag({
text: b.myPerm === 'write' ? '可写' : '只读',
tone: this.isCurrent(b.id) ? 'brand' : 'muted'
})
}
}
.padding({ left: 12, right: 12, top: 7, bottom: 7 })
.backgroundColor(this.isCurrent(b.id) ? this.accent : FbColors.surface)
.borderRadius(FbMetric.radiusPill)
.alignItems(VerticalAlign.Center)
.onClick(() => {
this.bookId = b.id;
})
}, (b: FbLedgerBook): string => 'bk_' + b.id.toString())
}
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.padding({ bottom: 10 })
Scroll() {
Column({ space: 0 }) {
// ---- 概览卡 ----
Column({ space: 12 }) {
Column({ space: 4 }) {
Text('净资产')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
Text(fbMoney(this.netCent()))
.fontSize(30)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
}
.alignItems(HorizontalAlign.Start)
.width('100%')
// 资产 / 负债分列
Row({ space: 0 }) {
Column({ space: 3 }) {
Text('资产')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Text(fbMoney(this.assetCent()))
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.green)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Column({ space: 3 }) {
Text('负债')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Text(fbMoney(this.debtCent()))
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.red)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
// 折线图占位(第二轮接图表能力后填)
Column({ space: 4 }) {
Row({ space: 6 }) {
Image($r('app.media.fb_ic_chart'))
.width(13)
.height(13)
.renderMode(ImageRenderMode.Template)
.fillColor(FbColors.faint)
Text('净资产变化(盘点成线)')
.fontSize(10)
.fontColor(FbColors.faint)
}
.alignItems(VerticalAlign.Center)
Column()
.width('100%')
.height(56)
.borderRadius(FbMetric.radiusS)
.backgroundColor(FbColors.inputBg)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(FbMetric.cardPad)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad })
.alignItems(HorizontalAlign.Start)
// ---- 账目(按类别分组)----
Row({ space: 8 }) {
Text('账目')
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Blank()
Text(this.entries.length.toString() + ' 条 · 按类别')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.width('100%')
.padding({ left: FbMetric.pagePad + 4, right: FbMetric.pagePad + 4, top: 14, bottom: 8 })
if (this.entries.length === 0) {
FbEmptyBox({
title: '这本账还没有账目',
hint: '存款、基金、保险、房子、车子都可以记进来',
icon: $r('app.media.fb_ic_ledger')
})
} else {
ForEach(this.groups(), (g: FbGroup<FbLedgerEntry>) => {
Column({ space: 0 }) {
// 类别小标题 + 该类别小计
Row({ space: 8 }) {
Text(g.title)
.fontSize(FbMetric.fontTiny)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(fbMoney(this.sumOf(g.items)))
.fontSize(10)
.fontColor(FbColors.faint)
Blank()
}
.width('100%')
.padding({ top: 10, bottom: 6 })
Column({ space: 0 }) {
ForEach(g.items, (e: FbLedgerEntry, idx: number) => {
this.entryRow(e, idx === g.items.length - 1)
}, (e: FbLedgerEntry): string => 'le_' + e.id.toString())
}
.width('100%')
}
.width('100%')
.padding(FbMetric.cardPad)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 12 })
.alignItems(HorizontalAlign.Start)
}, (g: FbGroup<FbLedgerEntry>): string => 'lg_' + g.key)
Blank().height(16)
}
}
.width('100%')
}
.layoutWeight(1)
.width('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
}
.width('100%')
.height('100%')
.backgroundColor(FbColors.paper)
.alignItems(HorizontalAlign.Start)
}
/** 一条账目:名字 + 归属人 + 金额 + 盘点入口 */
@Builder
entryRow(e: FbLedgerEntry, isLast: boolean) {
Column({ space: 0 }) {
Row({ space: 10 }) {
Column({ space: 3 }) {
Row({ space: 6 }) {
Text(e.name)
.fontSize(FbMetric.fontBody)
.fontColor(FbColors.ink)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (e.ownerName.length > 0) {
FbTag({ text: e.ownerName, tone: 'muted' })
}
}
.alignItems(VerticalAlign.Center)
Text('更新于 ' + e.updatedAt)
.fontSize(10)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(fbMoney(e.amountCent))
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor(e.amountCent < 0 ? FbColors.red : FbColors.ink)
// 「盘点」:Web 端是每行一个入口,端上保留成一个轻按钮
Text('盘点')
.fontSize(10)
.fontColor(this.accent)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.backgroundColor(this.accent + '14')
.borderRadius(FbMetric.radiusPill)
}
.width('100%')
.padding({ top: 11, bottom: 11 })
.alignItems(VerticalAlign.Center)
if (!isLast) {
Divider().color(FbColors.line2)
}
}
.width('100%')
}
private isCurrent(id: number): boolean {
return this.currentId() === id;
}
private currentId(): number {
if (this.bookId > 0) {
return this.bookId;
}
return this.books.length > 0 ? this.books[0].id : 0;
}
private currentBook(): FbLedgerBook {
const hit: FbLedgerBook | undefined = this.books.find((b: FbLedgerBook): boolean => b.id === this.currentId());
if (hit !== undefined) {
return hit;
}
return { id: 0, name: '账本', netCent: 0, myPerm: 'owner', entryCount: 0 };
}
private netCent(): number {
return this.currentBook().netCent;
}
private assetCent(): number {
let sum: number = 0;
for (const e of this.entries) {
if (e.amountCent > 0) {
sum = sum + e.amountCent;
}
}
return sum;
}
private debtCent(): number {
let sum: number = 0;
for (const e of this.entries) {
if (e.amountCent < 0) {
sum = sum + e.amountCent;
}
}
return sum;
}
private sumOf(items: FbLedgerEntry[]): number {
let sum: number = 0;
for (const e of items) {
sum = sum + e.amountCent;
}
return sum;
}
private groups(): FbGroup<FbLedgerEntry>[] {
const keys: string[] = [];
const buckets: FbLedgerEntry[][] = [];
for (const e of this.entries) {
const k: string = fbLedgerKindName(e.kind);
let idx: number = keys.indexOf(k);
if (idx < 0) {
keys.push(k);
buckets.push([]);
idx = keys.length - 1;
}
buckets[idx].push(e);
}
const out: FbGroup<FbLedgerEntry>[] = [];
for (let i = 0; i < keys.length; i++) {
out.push({
key: keys[i],
title: keys[i],
items: buckets[i],
count: buckets[i].length.toString() + ' 条'
});
}
return out;
}
}
@@ -0,0 +1,382 @@
/**
* 家书 FamilyBook · 导航 / 笔记 / 通知(鸿蒙端)
*
* 这三个模块的 Web 页面(`templates/nav/index.php`、`notes/index.php`、
* `notifications/index.php`)体量都不大,端上把它们放在同一个文件里 ——
* 都是「一列条目 + 一个入口」的形态,拆成三个文件反而要重复一堆样板。
*
* ⚠️ 三个 struct 各自独立,由 Index.ets 按当前模块选一个渲染。
*/
import { FbColors, FbMetric } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbGroup, FbNavLink, FbNoteItem, FbNotificationItem } from '../../model/Types';
import {
FB_DEMO_NAV, FB_DEMO_NOTES, FB_DEMO_NOTIFICATIONS, fbGroupNav
} from '../../model/DemoData';
import { FbAvatar, FbDot, FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../components/Widgets';
// ============================================================
// 导航
// ============================================================
@ComponentV2
export struct FbNavPage {
@Param app: FbAppState = fbAppState();
/** 模块主题色(导航 = 蓝) */
private accent: string = '#3E7A9E';
private all: FbNavLink[] = FB_DEMO_NAV;
build() {
Column() {
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('我的导航')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.all.length.toString() + ' 个站点 · 按分组排列')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
FbPrimaryButton({
label: '添加',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
List({ space: 0 }) {
ForEach(this.groups(), (g: FbGroup<FbNavLink>) => {
ListItemGroup({ header: this.groupHeader(g.title, g.count + ' 个') }) {
ForEach(g.items, (n: FbNavLink) => {
ListItem() {
this.navRow(n)
}
}, (n: FbNavLink): string => 'nv_' + n.id.toString())
}
}, (g: FbGroup<FbNavLink>): string => 'ng_' + g.key)
}
.layoutWeight(1)
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 24 })
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
.width('100%')
.height('100%')
.backgroundColor(FbColors.paper)
.alignItems(HorizontalAlign.Start)
}
@Builder
navRow(n: FbNavLink) {
Row({ space: 12 }) {
// 首字母方块(Web 端用站点 favicon,端上先用首字,避免引网络图)
Text(n.title.substring(0, 1))
.width(38)
.height(38)
.borderRadius(FbMetric.radiusS)
.backgroundColor(this.accent + '1A')
.fontColor(this.accent)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.textAlign(TextAlign.Center)
Column({ space: 3 }) {
Text(n.title)
.fontSize(FbMetric.fontBody)
.fontColor(FbColors.ink)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(n.url)
.fontSize(10)
.fontColor(FbColors.faint)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Image($r('app.media.fb_ic_forward'))
.width(13)
.height(13)
.renderMode(ImageRenderMode.Template)
.fillColor(FbColors.faint)
}
.width('100%')
.padding(14)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ bottom: 8 })
.alignItems(VerticalAlign.Center)
}
@Builder
groupHeader(title: string, count: string) {
Row({ space: 8 }) {
Text(title)
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(count)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Blank()
}
.width('100%')
.padding({ left: 4, right: 4, top: 12, bottom: 8 })
.backgroundColor(FbColors.paper)
}
private groups(): FbGroup<FbNavLink>[] {
return fbGroupNav(this.all);
}
}
// ============================================================
// 笔记
// ============================================================
@ComponentV2
export struct FbNotesPage {
@Param app: FbAppState = fbAppState();
/** 模块主题色(笔记 = 褐灰) */
private accent: string = '#7A6A4F';
private all: FbNoteItem[] = FB_DEMO_NOTES;
build() {
Column() {
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('笔记')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.all.length.toString() + ' 条 · 分块 + 标签')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
FbPrimaryButton({
label: '记一条',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 10 })
.alignItems(VerticalAlign.Center)
if (this.all.length === 0) {
FbEmptyBox({
title: '还没有笔记',
hint: '随手记点什么,按标签归类和检索',
icon: $r('app.media.fb_ic_notes')
})
} else {
// ⚠️ 用**两列定尺寸网格**而不是一列长卡:Web 端用户明确要求过
// 「笔记列表要么按列表展示,要么按块展示,一行几个块,但块大小要确定,
// 显示不下就显示简要信息」—— 一列长卡一条占大半屏,很难扫。
Grid() {
ForEach(this.all, (n: FbNoteItem) => {
GridItem() {
this.noteCard(n)
}
}, (n: FbNoteItem): string => 'nt_' + n.id.toString())
}
.columnsTemplate('1fr 1fr')
.columnsGap(10)
.rowsGap(10)
.layoutWeight(1)
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 24 })
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
}
.width('100%')
.height('100%')
.backgroundColor(FbColors.paper)
.alignItems(HorizontalAlign.Start)
}
@Builder
noteCard(n: FbNoteItem) {
// 定尺寸卡片:标题 + 摘要 + 底部标签/时间。内容多就截断(用户要求过「块大小要确定」)
Column({ space: 6 }) {
Row({ space: 6 }) {
Text(n.title)
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
if (n.shared) {
FbTag({ text: '共享', tone: 'green' })
}
}
.width('100%')
.alignItems(VerticalAlign.Top)
Text(n.excerpt)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.lineHeight(19)
.maxLines(4)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
.layoutWeight(1)
// 底部:标签(最多两个)+ 时间
Row({ space: 6 }) {
ForEach(n.tags.slice(0, 2), (t: string) => {
FbTag({ text: '#' + t, tone: 'muted' })
}, (t: string): string => 'ntg_' + n.id.toString() + '_' + t)
Blank()
if (n.attachCount > 0) {
Text(n.attachCount.toString())
.fontSize(10)
.fontColor(FbColors.faint)
}
Text(n.updatedAt)
.fontSize(10)
.fontColor(FbColors.faint)
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
.width('100%')
.height(168)
.padding(14)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.alignItems(HorizontalAlign.Start)
}
}
// ============================================================
// 通知
// ============================================================
@ComponentV2
export struct FbNotificationsPage {
@Param app: FbAppState = fbAppState();
private all: FbNotificationItem[] = FB_DEMO_NOTIFICATIONS;
build() {
Column() {
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('通知消息')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.unreadCount().toString() + ' 条未读 · 共 '
+ this.all.length.toString() + ' 条')
.fontSize(FbMetric.fontTiny)
.fontColor(this.unreadCount() > 0 ? FbColors.red : FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('全部已读')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.brand)
.padding({ left: 12, right: 12, top: 7, bottom: 7 })
.backgroundColor(FbColors.brand + '14')
.borderRadius(FbMetric.radiusPill)
.onClick(() => {
// 本轮只做界面:把本地未读标记清掉即可
this.app.unread = 0;
})
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 10 })
.alignItems(VerticalAlign.Center)
if (this.all.length === 0) {
FbEmptyBox({
title: '没有新消息',
hint: '别人共享给你的资源、日程与任务都会出现在这儿',
icon: $r('app.media.fb_ic_bell')
})
} else {
List({ space: 10 }) {
ForEach(this.all, (n: FbNotificationItem) => {
ListItem() {
this.notifCard(n)
}
}, (n: FbNotificationItem): string => 'nf_' + n.id.toString())
}
.layoutWeight(1)
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 24 })
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
}
.width('100%')
.height('100%')
.backgroundColor(FbColors.paper)
.alignItems(HorizontalAlign.Start)
}
@Builder
notifCard(n: FbNotificationItem) {
Row({ space: 12 }) {
FbAvatar({ name: n.from.shown, color: n.from.avatarColor, dia: 38, fontSizeVp: 15 })
Column({ space: 4 }) {
Row({ space: 6 }) {
Text(n.title)
.fontSize(FbMetric.fontSmall)
.fontWeight(n.unread ? FontWeight.Medium : FontWeight.Normal)
.fontColor(FbColors.ink)
.layoutWeight(1)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (n.unread) {
FbDot({ dia: 7, fillC: '#C0533F' })
}
}
.width('100%')
Text(n.body)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
Text(n.timeText)
.fontSize(10)
.fontColor(FbColors.faint)
.width('100%')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding(14)
.backgroundColor(n.unread ? FbColors.surface : FbColors.surfaceAlt)
.borderRadius(FbMetric.radius)
.alignItems(VerticalAlign.Top)
}
private unreadCount(): number {
return this.all.filter((n: FbNotificationItem): boolean => n.unread).length;
}
}
@@ -0,0 +1,220 @@
/**
* 家书 FamilyBook · 观影模块(鸿蒙端)
*
* 对应 Web 端 `templates/movies/index.php`。Web 端是**海报墙**(waterfall 瀑布流),
* 端上改成**两列网格**:手机宽度下两列海报刚好一屏看清 4–6 部,
* 比瀑布流(列宽随图片高度浮动)更整齐、也更好点。
*
* 页头右侧两个入口:搜索、设置(影院管理 / 统计 / 年度报告 —— 与 Web 端一致,
* 「影院」不单独占抽屉位置,它是观影模块的一部分)。
*/
import { FbColors, FbMetric, fbPalette } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbMovieItem, FbKinChip } from '../../model/Types';
import { FB_DEMO_CINEMAS, FB_DEMO_MOVIES } from '../../model/DemoData';
import { FbDot, FbEmptyBox, FbIconButton, FbPersonChip, FbPrimaryButton, FbRating, FbSectionTitle } from '../../components/Widgets';
@ComponentV2
export struct FbMoviesPage {
@Param app: FbAppState = fbAppState();
private movies: FbMovieItem[] = FB_DEMO_MOVIES;
private cinemas: string[] = FB_DEMO_CINEMAS;
/** 模块主题色(观影 = 紫) */
private accent: string = '#7A4E8C';
build() {
Column() {
// ---- 页头:标题 + 记一场电影 + 设置 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('观影记录')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text('共 ' + this.movies.length.toString() + ' 部 · 按观影时间排序')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
FbPrimaryButton({
label: '记一场',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
// 「影院」不单独占抽屉位置 —— 它是观影模块的一部分,从这里进
FbIconButton({
icon: $r('app.media.fb_ic_gear'),
tint: FbColors.muted,
dia: 36
})
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
// ---- 搜索条 ----
Row({ space: 6 }) {
Image($r('app.media.fb_ic_search'))
.width(15)
.height(15)
.renderMode(ImageRenderMode.Template)
.fillColor(FbColors.faint)
Text('搜索片名、影院、观影人…')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
.layoutWeight(1)
.maxLines(1)
}
.width('100%')
.height(36)
.padding({ left: 12, right: 12 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 4 })
.alignItems(VerticalAlign.Center)
// ---- 影院数提示 ----
Row({ space: 6 }) {
Blank()
Text('影院 ' + this.cinemas.length.toString() + ' 家')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.width('100%')
.padding({ left: FbMetric.pagePad + 4, right: FbMetric.pagePad + 4, top: 6, bottom: 6 })
// ---- 海报网格 ----
if (this.movies.length === 0) {
FbEmptyBox({
title: '还没有观影记录',
hint: '一家人一起看过的电影,都会以海报墙的方式排在这里',
icon: $r('app.media.fb_ic_movies')
})
} else {
Grid() {
ForEach(this.movies, (mv: FbMovieItem) => {
GridItem() {
this.movieCard(mv)
}
}, (mv: FbMovieItem): string => 'mv_' + mv.id.toString())
}
.columnsTemplate('1fr 1fr')
.columnsGap(12)
.rowsGap(12)
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 24 })
.layoutWeight(1)
.width('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
}
.width('100%')
.height('100%')
.backgroundColor(FbColors.paper)
.alignItems(HorizontalAlign.Start)
}
/** 一张电影卡:海报位(2:3)+ 片名 + 元信息 + 同看的人 */
@Builder
movieCard(mv: FbMovieItem) {
Column({ space: 0 }) {
// 海报位:有海报就放图,没有就画占位(Web 端用「影」字占位,端上用图标)
Stack({ alignContent: Alignment.Center }) {
if (mv.hasPoster) {
// TODO(接口):接上 media.php 的海报地址后换掉这个纯色块
Column()
.width('100%')
.height('100%')
.linearGradient({
angle: 145,
colors: [[fbPalette(this.accent).brand2, 0.0], [fbPalette(this.accent).ink, 1.0]]
})
} else {
Column()
.width('100%')
.height('100%')
.backgroundColor(FbColors.inputBg)
}
Column({ space: 6 }) {
Image($r('app.media.fb_ic_movies'))
.width(mv.hasPoster ? 26 : 22)
.height(mv.hasPoster ? 26 : 22)
.renderMode(ImageRenderMode.Template)
.fillColor(mv.hasPoster ? '#FFFFFFB3' : FbColors.faint)
if (!mv.hasPoster) {
Text(mv.title)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.textAlign(TextAlign.Center)
.maxLines(3)
.padding({ left: 10, right: 10 })
}
}
}
.width('100%')
.aspectRatio(0.75)
.clip(true)
.borderRadius({ topLeft: FbMetric.radius, topRight: FbMetric.radius })
// 卡片正文
Column({ space: 5 }) {
Text(mv.title)
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 6 }) {
Text(mv.watchedAt)
.fontSize(10)
.fontColor(FbColors.faint)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
if (mv.cinemaName.length > 0) {
Row({ space: 4 }) {
FbDot({ dia: 6, fillC: mv.cinemaColor })
Text(mv.cinemaName)
.fontSize(10)
.fontColor(mv.cinemaColor)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
}
.width('100%')
}
Row({ space: 8 }) {
if (mv.reviewCount > 0) {
FbRating({ score: mv.rating, max: 10 })
}
Blank()
// 同看的人(只出头像,空间紧)
Row({ space: 2 }) {
ForEach(mv.viewers.slice(0, 3), (v: FbKinChip) => {
FbPersonChip({ name: v.shown, color: v.avatarColor, withText: false })
.padding({ left: 0, right: 0 })
}, (v: FbKinChip): string => 'vw_' + mv.id.toString() + '_' + v.id.toString())
}
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
.width('100%')
.padding(10)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.clip(true)
}
}
@@ -0,0 +1,432 @@
/**
* 家书 FamilyBook · 名册 / 家谱(鸿蒙端)
*
* 对应 Web 端 `templates/people/index.php`(名册)与 `templates/kin/tree.php`(家谱)。
*
* 这两页在 Web 端是两个模块,但**看的是同一批人**:名册是「平铺的名单」(通讯录视角),
* 家谱是「以我为中心的连线」(关系视角)。端上放在同一个文件里,用内部分段切换 ——
* 手机上在两页之间来回跳没有意义,切一下就能对照看。
*
* ⚠️ 家谱的树形图(Web 端是 HTML 折线)在手机窄屏上排不下。
* 端上改成**「以我为中心的三圈」**:我 → 直系(父母/配偶/子女)→ 其他亲属,
* 每圈一张卡,卡里按人列。关系表达上等价,宽度可控。
*/
import { FbColors, FbMetric } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbGroup, FbKinRelation, FbRosterItem } from '../../model/Types';
import { FB_DEMO_KIN_RELATIONS, FB_DEMO_ROSTER, fbGroupRoster } from '../../model/DemoData';
import { FbAvatar, FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../components/Widgets';
/** 家谱里一个人一圈的分组 */
interface FbKinRing {
/** 圈名:我 / 直系 / 亲属 */
title: string;
/** 说明 */
hint: string;
/** 圈内的关系条目 */
rels: FbKinRelation[];
}
@ComponentV2
export struct FbPeoplePage {
@Param app: FbAppState = fbAppState();
/** 子视图:名册(roster)/ 家谱(kin) */
@Local subView: string = 'roster';
/** 名册范围:member(家里的人)/ friend(亲友) */
@Local rosterScope: string = 'member';
/** 搜索词(本轮只做界面,不过滤) */
@Local keyword: string = '';
private all: FbRosterItem[] = FB_DEMO_ROSTER;
private rels: FbKinRelation[] = FB_DEMO_KIN_RELATIONS;
build() {
Column() {
// ---- 分段切换(名册 / 家谱)----
Row({ space: 4 }) {
this.segBtn('名册', 'roster', '#A84A28')
this.segBtn('家谱', 'kin', '#A84A28')
}
.padding(3)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 })
if (this.subView === 'roster') {
this.rosterView()
} else {
this.kinView()
}
}
.width('100%')
.height('100%')
.backgroundColor(FbColors.paper)
.alignItems(HorizontalAlign.Start)
}
// ============================================================
// 名册
// ============================================================
@Builder
rosterView() {
Column() {
// 操作条
Row({ space: 8 }) {
Row({ space: 6 }) {
Image($r('app.media.fb_ic_search'))
.width(15)
.height(15)
.renderMode(ImageRenderMode.Template)
.fillColor(FbColors.faint)
TextInput({ placeholder: '搜索姓名、手机、备注', text: this.keyword })
.placeholderColor(FbColors.faint)
.placeholderFont({ size: FbMetric.fontSmall })
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(Color.Transparent)
.padding(0)
.layoutWeight(1)
.onChange((v: string) => {
this.keyword = v;
})
}
.layoutWeight(1)
.height(36)
.padding({ left: 12, right: 12 })
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.alignItems(VerticalAlign.Center)
FbPrimaryButton({
label: '添加',
bg: '#4A7C82',
icon: $r('app.media.fb_ic_plus')
})
FbIconButton({ icon: $r('app.media.fb_ic_gear'), tint: FbColors.muted })
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
// 范围切换(家里的人 / 亲友)
Row({ space: 8 }) {
this.scopeChip('家里的人', 'member')
this.scopeChip('亲友', 'friend')
Blank()
Text(this.byScope().length.toString() + ' 人')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 6 })
.alignItems(VerticalAlign.Center)
// 分组列表
List({ space: 0 }) {
ForEach(this.groups(), (g: FbGroup<FbRosterItem>) => {
ListItemGroup({ header: this.groupHeader(g.title, g.count + ' 人') }) {
ForEach(g.items, (p: FbRosterItem) => {
ListItem() {
this.personRow(p)
}
}, (p: FbRosterItem): string => 'pr_' + p.id.toString())
}
}, (g: FbGroup<FbRosterItem>): string => 'rg_' + g.key)
}
.layoutWeight(1)
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 24 })
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
.width('100%')
.layoutWeight(1)
}
/** 一个人一行:头像 + 名字/称谓 + 标签 + 生日 */
@Builder
personRow(p: FbRosterItem) {
Row({ space: 12 }) {
FbAvatar({ name: p.name, color: p.avatarColor, dia: 40, fontSizeVp: 16 })
Column({ space: 3 }) {
Row({ space: 6 }) {
Text(p.name)
.fontSize(FbMetric.fontBody)
.fontColor(FbColors.ink)
.fontWeight(FontWeight.Medium)
.maxLines(1)
if (p.isAdmin) {
FbTag({ text: '管理员', tone: 'brand' })
}
if (p.kinship !== p.name) {
FbTag({ text: p.kinship, tone: 'muted' })
}
}
.alignItems(VerticalAlign.Center)
Row({ space: 8 }) {
if (p.tags.length > 0) {
ForEach(p.tags, (t: string) => {
FbTag({ text: t, tone: 'blue' })
}, (t: string): string => 'tg_' + p.id.toString() + '_' + t)
}
if (p.birthday.length > 0) {
Text('生日 ' + p.birthday)
.fontSize(10)
.fontColor(FbColors.faint)
}
}
.alignItems(VerticalAlign.Center)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Image($r('app.media.fb_ic_forward'))
.width(13)
.height(13)
.renderMode(ImageRenderMode.Template)
.fillColor(FbColors.faint)
}
.width('100%')
.padding(14)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ bottom: 8 })
.alignItems(VerticalAlign.Center)
}
// ============================================================
// 家谱
// ============================================================
@Builder
kinView() {
Column() {
// 说明条
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('以我为中心')
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.rels.length.toString() + ' 条连线 · 父母 / 配偶 / 子女填上就能长出来')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
FbPrimaryButton({
label: '补关系',
bg: '#8A6B4B',
icon: $r('app.media.fb_ic_plus')
})
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
Scroll() {
Column({ space: 0 }) {
ForEach(this.rings(), (r: FbKinRing) => {
this.ringCard(r)
}, (r: FbKinRing): string => 'ring_' + r.title)
// 完整连线表(家谱的「原始数据」,便于核对填得对不对)
Column({ space: 0 }) {
Row() {
Text('全部连线')
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Blank()
Text(this.rels.length.toString() + ' 条')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.width('100%')
.padding({ left: 4, right: 4, bottom: 8 })
Column({ space: 0 }) {
ForEach(this.rels, (r: FbKinRelation, idx: number) => {
Row({ space: 10 }) {
Text(r.fromName)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.muted)
.maxLines(1)
Image($r('app.media.fb_ic_forward'))
.width(12)
.height(12)
.renderMode(ImageRenderMode.Template)
.fillColor(FbColors.faint)
Text(r.toName)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.layoutWeight(1)
.maxLines(1)
FbTag({ text: r.label, tone: 'muted' })
}
.width('100%')
.padding({ top: 10, bottom: 10 })
.alignItems(VerticalAlign.Center)
if (idx < this.rels.length - 1) {
Divider().color(FbColors.line2)
}
}, (r: FbKinRelation): string => 'rel_' + r.id.toString())
}
.width('100%')
}
.width('100%')
.padding(FbMetric.cardPad)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 28 })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
}
.layoutWeight(1)
.width('100%')
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
}
.width('100%')
.layoutWeight(1)
}
/** 一圈亲属卡 */
@Builder
ringCard(r: FbKinRing) {
Column({ space: 10 }) {
Row({ space: 8 }) {
Text(r.title)
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(r.hint)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Blank()
Text(r.rels.length.toString())
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.width('100%')
if (r.rels.length === 0) {
Text('还没有连线')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
} else {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(r.rels, (rel: FbKinRelation) => {
Row({ space: 6 }) {
FbAvatar({ name: rel.toName, color: this.colorOf(rel.toId), dia: 28, fontSizeVp: 12 })
Column({ space: 1 }) {
Text(rel.toName)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.maxLines(1)
Text(rel.label)
.fontSize(10)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
}
.padding({ left: 6, right: 12, top: 5, bottom: 5 })
.backgroundColor(FbColors.surfaceAlt)
.borderRadius(FbMetric.radiusPill)
.margin({ right: 8, bottom: 8 })
.alignItems(VerticalAlign.Center)
}, (rel: FbKinRelation): string => 'kr_' + rel.id.toString())
}
.width('100%')
}
}
.width('100%')
.padding(FbMetric.cardPad)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 })
.alignItems(HorizontalAlign.Start)
}
// ============================================================
// 共用小件
// ============================================================
@Builder
segBtn(label: string, key: string, color: string) {
Text(label)
.fontSize(FbMetric.fontSmall)
.fontWeight(this.subView === key ? FontWeight.Medium : FontWeight.Normal)
.fontColor(this.subView === key ? Color.White : FbColors.muted)
.textAlign(TextAlign.Center)
.height(30)
.layoutWeight(1)
.backgroundColor(this.subView === key ? color : Color.Transparent)
.borderRadius(FbMetric.radiusPill)
.onClick(() => {
this.subView = key;
})
}
@Builder
scopeChip(label: string, key: string) {
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(this.rosterScope === key ? Color.White : FbColors.muted)
.backgroundColor(this.rosterScope === key ? '#4A7C82' : FbColors.inputBg)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(FbMetric.radiusPill)
.onClick(() => {
this.rosterScope = key;
})
}
@Builder
groupHeader(title: string, count: string) {
Row({ space: 8 }) {
Text(title)
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(count)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Blank()
}
.width('100%')
.padding({ left: 4, right: 4, top: 12, bottom: 8 })
.backgroundColor(FbColors.paper)
}
private byScope(): FbRosterItem[] {
return this.all.filter((p: FbRosterItem): boolean => p.roster === this.rosterScope);
}
private groups(): FbGroup<FbRosterItem>[] {
return fbGroupRoster(this.byScope());
}
/** 三圈:我 / 直系 / 其他亲属 */
private rings(): FbKinRing[] {
const direct: string[] = ['parent', 'spouse', 'child'];
return [
{ title: '直系', hint: '父母 / 配偶 / 子女', rels: this.rels.filter((r: FbKinRelation): boolean => direct.indexOf(r.kind) >= 0) },
{ title: '其他亲属', hint: '兄弟姐妹与姻亲', rels: this.rels.filter((r: FbKinRelation): boolean => direct.indexOf(r.kind) < 0) }
];
}
/** 取某个人的头像色(从名册里查,查不到用默认砖红) */
private colorOf(id: number): string {
const hit: FbRosterItem | undefined = this.all.find((p: FbRosterItem): boolean => p.id === id);
return hit !== undefined ? hit.avatarColor : '#A84A28';
}
}
@@ -0,0 +1,224 @@
/**
* 家书 FamilyBook · 资源模块(鸿蒙端)
*
* 对应 Web 端 `templates/resources/index.php`。
* 资源 = 证件 / 资料 / 附件的集合(户口本、房产证、学籍资料…),可共享给某个成员。
*
* 端上形态:**按类别分组的卡片列表**,卡片右侧显示归属人与附件数;
* 那一排「类别筛选胶囊」对应 Web 端页头的分类入口。
*/
import { FbColors, FbMetric } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbGroup, FbResourceItem } from '../../model/Types';
import { FB_DEMO_RESOURCES, fbGroupResource } from '../../model/DemoData';
import { FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../components/Widgets';
@ComponentV2
export struct FbResourcesPage {
@Param app: FbAppState = fbAppState();
/** 模块主题色(资源 = 靛蓝) */
private accent: string = '#5566A8';
private all: FbResourceItem[] = FB_DEMO_RESOURCES;
/** 当前筛选类别('' = 全部) */
@Local kindFilter: string = '';
build() {
Column() {
// ---- 页头 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text(this.all.length.toString() + ' 项资源')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text('证件 / 资料 / 附件,可共享给家庭成员')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
FbPrimaryButton({
label: '添加',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
FbIconButton({ icon: $r('app.media.fb_ic_gear'), tint: FbColors.muted })
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
// ---- 类别筛选 ----
Row({ space: 8 }) {
this.kindChip('全部', '')
this.kindChip('证件', 'document')
this.kindChip('资料', 'material')
Blank()
Text('共享给我 ' + this.sharedCount().toString() + ' 项')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 8 })
.alignItems(VerticalAlign.Center)
// ---- 列表 ----
if (this.filtered().length === 0) {
FbEmptyBox({
title: '这里还没有资源',
hint: '家里的证件、资料和附件都可以收进来,需要时一键共享',
icon: $r('app.media.fb_ic_resources')
})
} else {
List({ space: 0 }) {
ForEach(this.groups(), (g: FbGroup<FbResourceItem>) => {
ListItemGroup({ header: this.groupHeader(g.title, g.items.length.toString() + ' 项') }) {
ForEach(g.items, (r: FbResourceItem) => {
ListItem() {
this.resourceCard(r)
}
}, (r: FbResourceItem): string => 'res_' + r.id.toString())
}
}, (g: FbGroup<FbResourceItem>): string => 'rsg_' + g.key)
}
.layoutWeight(1)
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 24 })
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
}
.width('100%')
.height('100%')
.backgroundColor(FbColors.paper)
.alignItems(HorizontalAlign.Start)
}
/** 一项资源卡 */
@Builder
resourceCard(r: FbResourceItem) {
Row({ space: 12 }) {
// 类别图标底
Stack({ alignContent: Alignment.Center }) {
Image(this.kindIcon(r.kind))
.width(19)
.height(19)
.renderMode(ImageRenderMode.Template)
.fillColor(this.accent)
}
.width(42)
.height(42)
.borderRadius(FbMetric.radiusS)
.backgroundColor(this.accent + '14')
Column({ space: 4 }) {
Row({ space: 6 }) {
Text(r.name)
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
if (r.shared) {
FbTag({ text: '已共享', tone: 'green' })
}
}
.width('100%')
.alignItems(VerticalAlign.Center)
Row({ space: 10 }) {
Text('归属 ' + r.ownerName)
.fontSize(10)
.fontColor(FbColors.muted)
Row({ space: 3 }) {
Image($r('app.media.fb_ic_resources'))
.width(11)
.height(11)
.renderMode(ImageRenderMode.Template)
.fillColor(FbColors.faint)
Text(r.fileCount.toString() + ' 个附件')
.fontSize(10)
.fontColor(FbColors.faint)
}
Text(r.updatedAt)
.fontSize(10)
.fontColor(FbColors.faint)
}
.alignItems(VerticalAlign.Center)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Image($r('app.media.fb_ic_forward'))
.width(13)
.height(13)
.renderMode(ImageRenderMode.Template)
.fillColor(FbColors.faint)
}
.width('100%')
.padding(14)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ bottom: 8 })
.alignItems(VerticalAlign.Center)
}
@Builder
kindChip(label: string, key: string) {
Text(label)
.fontSize(FbMetric.fontSmall)
.fontColor(this.kindFilter === key ? Color.White : FbColors.muted)
.backgroundColor(this.kindFilter === key ? this.accent : FbColors.inputBg)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(FbMetric.radiusPill)
.onClick(() => {
this.kindFilter = key;
})
}
@Builder
groupHeader(title: string, count: string) {
Row({ space: 8 }) {
Text(title)
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(count)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Blank()
}
.width('100%')
.padding({ left: 4, right: 4, top: 12, bottom: 8 })
.backgroundColor(FbColors.paper)
}
private kindIcon(kind: string): Resource {
if (kind === 'document') {
return $r('app.media.fb_ic_lock');
}
if (kind === 'material') {
return $r('app.media.fb_ic_book');
}
return $r('app.media.fb_ic_resources');
}
private filtered(): FbResourceItem[] {
if (this.kindFilter.length === 0) {
return this.all;
}
return this.all.filter((r: FbResourceItem): boolean => r.kind === this.kindFilter);
}
private groups(): FbGroup<FbResourceItem>[] {
return fbGroupResource(this.filtered());
}
private sharedCount(): number {
return this.all.filter((r: FbResourceItem): boolean => r.shared).length;
}
}
@@ -0,0 +1,343 @@
/**
* 家书 FamilyBook · 设置页(鸿蒙端)
*
* 对应 Web 端两处设置:
* · `templates/settings/me.php`(个人设置:我的资料、昵称、模块显隐与排序、主题色)
* · `templates/settings/_modules.php`(模块勾选 + 拖动排序 + 每模块配色)
*
* 端上把它做成**个人设置**一页(通知、模块显隐、主题、关于),
* 并把「哪些模块出现在抽屉里」这一项真正接上 —— 因为它是抽屉形态的核心配置:
* 抽屉里列什么,正是由这里决定的。
*/
import { FbColors, FbMetric, fbPalette } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbModuleDef, FB_MODULES, FB_PRESET_COLORS } from '../../model/Modules';
import { FbAvatar, FbDot, FbTag } from '../../components/Widgets';
@ComponentV2
export struct FbSettingsPage {
@Param app: FbAppState = fbAppState();
/** 抽屉里勾选着的模块 key */
@Local checked: string[] = ['home', 'people', 'movies', 'trips', 'calendar',
'resources', 'tasks', 'days', 'ledger', 'books', 'nav', 'notes'];
/** 当前展开的配色分组('' = 都没展开) */
@Local colorOpenKey: string = '';
build() {
Scroll() {
Column({ space: 0 }) {
// ==================== 我的资料 ====================
Row({ space: 14 }) {
FbAvatar({
name: this.app.session.me.name,
color: this.app.session.me.avatarColor,
dia: 56,
fontSizeVp: 22
})
Column({ space: 4 }) {
Row({ space: 6 }) {
Text(this.app.session.me.name)
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
if (this.app.session.me.role === 'admin') {
FbTag({ text: '管理员', tone: 'brand' })
}
}
.alignItems(VerticalAlign.Center)
Text(this.app.session.familyTitle + ' · ' + this.app.session.me.kinship)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Image($r('app.media.fb_ic_forward'))
.width(14)
.height(14)
.renderMode(ImageRenderMode.Template)
.fillColor(FbColors.faint)
}
.width('100%')
.padding(FbMetric.cardPad)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 })
// ==================== 通知 ====================
Column({ space: 0 }) {
this.rowTitle('通知')
this.switchRow('新消息提醒', '共享 / 任务 / 日程变动时通知我', true)
this.switchRow('到期提醒', '日程开始前 10 分钟提醒', true)
this.switchRow('逾期提醒', '任务逾期时提醒我', true)
}
.width('100%')
.padding(FbMetric.cardPad)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 })
// ==================== 抽屉里显示哪些模块 ====================
Column({ space: 0 }) {
Row({ space: 8 }) {
Text('抽屉里的模块')
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Blank()
Text(this.checked.length.toString() + '/' + FB_MODULES.length.toString())
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.width('100%')
.padding({ bottom: 4 })
Text('首页与会话相关的入口始终保留;家谱默认收起(不需要每天看)')
.fontSize(10)
.fontColor(FbColors.faint)
.width('100%')
.padding({ bottom: 8 })
ForEach(FB_MODULES, (m: FbModuleDef) => {
this.moduleToggleRow(m)
}, (m: FbModuleDef): string => 'set_' + m.key)
}
.width('100%')
.padding(FbMetric.cardPad)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 })
.alignItems(HorizontalAlign.Start)
// ==================== 模块主题色 ====================
Column({ space: 0 }) {
Text('模块主题色')
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.width('100%')
Text('一个家认同一套配色 —— 换个色,整个模块的按钮与强调色一起变')
.fontSize(10)
.fontColor(FbColors.faint)
.width('100%')
.padding({ top: 4, bottom: 8 })
ForEach(FB_MODULES, (m: FbModuleDef) => {
this.colorRow(m)
}, (m: FbModuleDef): string => 'col_' + m.key)
}
.width('100%')
.padding(FbMetric.cardPad)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12 })
.alignItems(HorizontalAlign.Start)
// ==================== 关于 ====================
Column({ space: 0 }) {
this.rowTitle('关于')
this.plainRow('版本', '0.1.0(界面框架)')
this.plainRow('数据来源', '待接入 · 当前为演示数据')
this.plainRow('服务器', '未配置')
}
.width('100%')
.padding(FbMetric.cardPad)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 28 })
}
.width('100%')
}
.width('100%')
.height('100%')
.backgroundColor(FbColors.paper)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.TopStart)
}
// ============================================================
// 行件
// ============================================================
/** 一个模块的「显隐」开关行 */
@Builder
moduleToggleRow(m: FbModuleDef) {
Row({ space: 12 }) {
Stack({ alignContent: Alignment.Center }) {
Image(this.iconOf(m.key))
.width(17)
.height(17)
.renderMode(ImageRenderMode.Template)
.fillColor(fbPalette(m.color).brand)
}
.width(32)
.height(32)
.borderRadius(9)
.backgroundColor(fbPalette(m.color).brand + '14')
Column({ space: 2 }) {
Text(m.name)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
Text(m.desc)
.fontSize(10)
.fontColor(FbColors.faint)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
// 首页不可关(它是落地页)
if (m.key === 'home') {
Text('常驻')
.fontSize(10)
.fontColor(FbColors.faint)
} else {
Toggle({ type: ToggleType.Switch, isOn: this.isChecked(m.key) })
.selectedColor(fbPalette(m.color).brand)
.onChange((on: boolean) => {
this.toggle(m.key, on);
})
}
}
.width('100%')
.padding({ top: 9, bottom: 9 })
.alignItems(VerticalAlign.Center)
}
/** 一个模块的配色行(点标题展开预设色板) */
@Builder
colorRow(m: FbModuleDef) {
Column({ space: 0 }) {
Row({ space: 10 }) {
FbDot({ dia: 16, fillC: m.color })
Text(m.name)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.layoutWeight(1)
Text(this.colorOpenKey === m.key ? '收起' : '换色')
.fontSize(10)
.fontColor(FbColors.brand)
}
.width('100%')
.padding({ top: 9, bottom: 9 })
.alignItems(VerticalAlign.Center)
.onClick(() => {
this.colorOpenKey = this.colorOpenKey === m.key ? '' : m.key;
})
if (this.colorOpenKey === m.key) {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(FB_PRESET_COLORS, (c: string) => {
Stack({ alignContent: Alignment.Center }) {
FbDot({ dia: 26, fillC: c })
if (c === m.color) {
Image($r('app.media.fb_ic_check'))
.width(13)
.height(13)
.renderMode(ImageRenderMode.Template)
.fillColor(Color.White)
}
}
.margin({ right: 8, bottom: 8 })
}, (c: string): string => 'sw_' + m.key + '_' + c)
}
.width('100%')
.padding({ bottom: 6 })
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
/** 小标题 */
@Builder
rowTitle(title: string) {
Text(title)
.fontSize(FbMetric.fontBody)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.width('100%')
.padding({ bottom: 6 })
}
/** 带开关的设置行 */
@Builder
switchRow(title: string, hint: string, on: boolean) {
Row({ space: 12 }) {
Column({ space: 2 }) {
Text(title)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
Text(hint)
.fontSize(10)
.fontColor(FbColors.faint)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Toggle({ type: ToggleType.Switch, isOn: on })
.selectedColor(FbColors.brand)
}
.width('100%')
.padding({ top: 9, bottom: 9 })
.alignItems(VerticalAlign.Center)
}
/** 只读信息行 */
@Builder
plainRow(label: string, value: string) {
Row() {
Text(label)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
Blank()
Text(value)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
}
.width('100%')
.padding({ top: 9, bottom: 9 })
.alignItems(VerticalAlign.Center)
}
private isChecked(key: string): boolean {
return this.checked.indexOf(key) >= 0;
}
private toggle(key: string, on: boolean): void {
if (on) {
if (this.checked.indexOf(key) < 0) {
this.checked.push(key);
}
} else {
this.checked = this.checked.filter((k: string): boolean => k !== key);
}
}
private iconOf(key: string): Resource {
switch (key) {
case 'home': return $r('app.media.fb_ic_home');
case 'kin': return $r('app.media.fb_ic_kin');
case 'people': return $r('app.media.fb_ic_people');
case 'movies': return $r('app.media.fb_ic_movies');
case 'trips': return $r('app.media.fb_ic_trips');
case 'calendar': return $r('app.media.fb_ic_calendar');
case 'resources': return $r('app.media.fb_ic_resources');
case 'tasks': return $r('app.media.fb_ic_tasks');
case 'days': return $r('app.media.fb_ic_days');
case 'ledger': return $r('app.media.fb_ic_ledger');
case 'books': return $r('app.media.fb_ic_books');
case 'nav': return $r('app.media.fb_ic_nav');
case 'notes': return $r('app.media.fb_ic_notes');
default: return $r('app.media.fb_ic_dot');
}
}
}
@@ -0,0 +1,231 @@
/**
* 家书 FamilyBook · 任务模块(鸿蒙端)
*
* 对应 Web 端 `templates/tasks/index.php`。Web 端是**看板**(按看板分列、卡片拖动改状态),
* 端上改成**按状态分组的三段列表**(进行中 / 待办 / 已完成):
* 手机宽度放不下多列看板,横向滚动又容易误触;
* 而用户真正关心的是「哪件事做到哪一步了」—— 按状态分组正对这个。
*
* 看板名仍保留(卡片上那个小色块标签),点它可以切到别的看板。
*/
import { FbColors, FbMetric } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbGroup, FbTaskItem } from '../../model/Types';
import { FB_DEMO_TASK_BOARDS, FB_DEMO_TASKS, fbGroupTask } from '../../model/DemoData';
import { FbCheckCircle, FbDot, FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../components/Widgets';
@ComponentV2
export struct FbTasksPage {
@Param app: FbAppState = fbAppState();
private boards: string[] = FB_DEMO_TASK_BOARDS;
/** 模块主题色(任务 = 赭红) */
private accent: string = '#A8574F';
private tasks: FbTaskItem[] = FB_DEMO_TASKS;
/** 当前筛选的看板('' = 全部) */
@Local boardFilter: string = '';
build() {
Column() {
// ---- 页头 ----
Row({ space: 8 }) {
Column({ space: 2 }) {
Text(this.doingCount().toString() + ' 项在做')
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(this.todoCount().toString() + ' 项待办'
+ (this.overdueCount() > 0 ? ' · ' + this.overdueCount().toString() + ' 项逾期' : ''))
.fontSize(FbMetric.fontTiny)
.fontColor(this.overdueCount() > 0 ? FbColors.red : FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
FbPrimaryButton({
label: '新建任务',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
FbIconButton({ icon: $r('app.media.fb_ic_gear'), tint: FbColors.muted })
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 8 })
.alignItems(VerticalAlign.Center)
// ---- 看板筛选条(横向滚动)----
Scroll() {
Row({ space: 8 }) {
this.boardChip('全部', '')
ForEach(this.boards, (b: string) => {
this.boardChip(b, b)
}, (b: string): string => 'bd_' + b)
}
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.padding({ bottom: 10 })
// ---- 按状态分组的列表 ----
if (this.filtered().length === 0) {
FbEmptyBox({
title: '这里没有任务',
hint: '新建时会顺带指定它属于哪个看板、做到哪一步',
icon: $r('app.media.fb_ic_tasks')
})
} else {
List({ space: 0 }) {
ForEach(this.groups(), (g: FbGroup<FbTaskItem>) => {
ListItemGroup({ header: this.groupHeader(g) }) {
ForEach(g.items, (t: FbTaskItem) => {
ListItem() {
this.taskCard(t)
}
}, (t: FbTaskItem): string => 'tk_' + t.id.toString())
}
}, (g: FbGroup<FbTaskItem>): string => 'grp_' + g.key)
}
.layoutWeight(1)
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 24 })
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
}
.width('100%')
.height('100%')
.backgroundColor(FbColors.paper)
.alignItems(HorizontalAlign.Start)
}
/** 看板筛选胶囊 */
@Builder
boardChip(label: string, key: string) {
Text(label)
.fontSize(FbMetric.fontSmall)
.fontColor(this.boardFilter === key ? Color.White : FbColors.muted)
.backgroundColor(this.boardFilter === key ? this.accent : FbColors.surface)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(FbMetric.radiusPill)
.onClick(() => {
this.boardFilter = key;
})
}
/** 分组头(粘在列表顶部,滚动时保持可见) */
@Builder
groupHeader(g: FbGroup<FbTaskItem>) {
Row({ space: 8 }) {
Text(g.title)
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
Text(g.count)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Blank()
}
.width('100%')
.padding({ left: 4, right: 4, top: 12, bottom: 8 })
.backgroundColor(FbColors.paper)
}
/** 一张任务卡 */
@Builder
taskCard(t: FbTaskItem) {
Column({ space: 8 }) {
Row({ space: 10 }) {
// 状态圈:完成=实心+勾、进行中=实心+白点、待办=空心
FbCheckCircle({
dia: 16,
done: t.status === 'done',
doing: t.status === 'doing',
color: this.accent
})
Text(t.title)
.fontSize(FbMetric.fontBody)
.fontColor(t.status === 'done' ? FbColors.faint : FbColors.ink)
.decoration({
type: t.status === 'done' ? TextDecorationType.LineThrough : TextDecorationType.None
})
.layoutWeight(1)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.alignItems(VerticalAlign.Center)
// 第二行:看板标签 + 负责人 + 截止 + 子任务进度
Row({ space: 8 }) {
Row({ space: 4 }) {
FbDot({ dia: 6, fillC: t.boardColor })
Text(t.board)
.fontSize(10)
.fontColor(t.boardColor)
}
if (t.assignee.length > 0) {
FbTag({ text: t.assignee, tone: 'muted' })
}
if (t.dueText.length > 0) {
Row({ space: 3 }) {
Text(t.dueText)
.fontSize(10)
.fontColor(t.overdue ? FbColors.red : FbColors.faint)
if (t.overdue) {
FbTag({ text: '逾期', tone: 'red' })
}
}
}
Blank()
if (t.childTotal > 0) {
Row({ space: 3 }) {
Image($r('app.media.fb_ic_check'))
.width(11)
.height(11)
.renderMode(ImageRenderMode.Template)
.fillColor(FbColors.faint)
Text(t.childDone.toString() + '/' + t.childTotal.toString())
.fontSize(10)
.fontColor(FbColors.faint)
}
}
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
.width('100%')
.padding(14)
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.margin({ bottom: 8 })
.alignItems(HorizontalAlign.Start)
}
private filtered(): FbTaskItem[] {
if (this.boardFilter.length === 0) {
return this.tasks;
}
return this.tasks.filter((t: FbTaskItem): boolean => t.board === this.boardFilter);
}
private groups(): FbGroup<FbTaskItem>[] {
return fbGroupTask(this.filtered());
}
private doingCount(): number {
return this.tasks.filter((t: FbTaskItem): boolean => t.status === 'doing').length;
}
private todoCount(): number {
return this.tasks.filter((t: FbTaskItem): boolean => t.status === 'todo').length;
}
private overdueCount(): number {
return this.tasks.filter((t: FbTaskItem): boolean => t.overdue).length;
}
}
@@ -0,0 +1,274 @@
/**
* 家书 FamilyBook · 旅程模块(鸿蒙端)
*
* 对应 Web 端 `templates/trips/index.php` + `trips/show.php`。
*
* 术语照 Web 端**固定下来**的那套(用户明确要求):
* · **旅程** = 列表里的一条(容器,如「青岛·崂山 两日」);
* · **行程** = 旅程里面的一段一段(详情页时间轴上的节点)。
*
* 端上的形态:**上列表 / 下详情同屏**不现实(手机高度不够),
* 所以这页做「列表 + 展开选中项的行程摘要」;点某条旅程展开它的行程时间轴。
* 时间轴节点分「计划 / 实际」两阶段 —— 与 Web 端一致(v41 加的四种动作按钮)。
*/
import { FbColors, FbMetric } from '../../common/Theme';
import { FbAppState, fbAppState } from '../../common/Store';
import { FbTripItem, FbTripNode } from '../../model/Types';
import { FB_DEMO_TRIPS, FB_DEMO_TRIP_NODES, fbMoney } from '../../model/DemoData';
import { FbDot, FbEmptyBox, FbIconButton, FbPrimaryButton, FbTag } from '../../components/Widgets';
@ComponentV2
export struct FbTripsPage {
@Param app: FbAppState = fbAppState();
private trips: FbTripItem[] = FB_DEMO_TRIPS;
private nodes: FbTripNode[] = FB_DEMO_TRIP_NODES;
/** 模块主题色(旅程 = 绿) */
private accent: string = '#3F7D5A';
/** 当前展开的旅程 id(0 = 都没展开) */
@Local expandedId: number = 0;
build() {
Column() {
// ---- 页头操作条 ----
Row({ space: 8 }) {
Column({ space: 1 }) {
Text('共 ' + this.trips.length.toString() + ' 个旅程')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
Text('每个旅程里可以有若干段行程')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
FbPrimaryButton({
label: '新建旅程',
bg: this.accent,
icon: $r('app.media.fb_ic_plus')
})
FbIconButton({ icon: $r('app.media.fb_ic_gear'), tint: FbColors.muted })
}
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, top: 12, bottom: 10 })
.alignItems(VerticalAlign.Center)
if (this.trips.length === 0) {
FbEmptyBox({
title: '还没有旅程',
hint: '去过的地方会按时间排在这里,点开能看到每一段行程',
icon: $r('app.media.fb_ic_trips')
})
} else {
List({ space: 12 }) {
ForEach(this.trips, (t: FbTripItem) => {
ListItem() {
this.tripCard(t)
}
}, (t: FbTripItem): string => 'trip_' + t.id.toString())
}
.layoutWeight(1)
.width('100%')
.padding({ left: FbMetric.pagePad, right: FbMetric.pagePad, bottom: 24 })
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
}
.width('100%')
.height('100%')
.backgroundColor(FbColors.paper)
.alignItems(HorizontalAlign.Start)
}
/** 一张旅程卡:标题 + 时间 + 进度;展开后接行程时间轴 */
@Builder
tripCard(t: FbTripItem) {
Column({ space: 0 }) {
// 卡头
Row({ space: 10 }) {
// 左侧色条(模块色的竖条,一眼区分旅程)
Column()
.width(4)
.height(38)
.borderRadius(2)
.backgroundColor(this.accent)
Column({ space: 3 }) {
Text(t.title)
.fontSize(FbMetric.fontTitle)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 8 }) {
Text(t.dateRange)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
Text(t.sub)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column({ space: 3 }) {
if (t.nodeCount > 0) {
Text(t.doneCount.toString() + '/' + t.nodeCount.toString())
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(this.accent)
Text('行程')
.fontSize(10)
.fontColor(FbColors.faint)
} else {
FbTag({ text: '还没行程', tone: 'muted' })
}
}
.alignItems(HorizontalAlign.End)
Image($r('app.media.fb_ic_forward'))
.width(14)
.height(14)
.renderMode(ImageRenderMode.Template)
.fillColor(FbColors.faint)
.rotate({ angle: this.isExpanded(t.id) ? 90 : 0 })
}
.width('100%')
.padding(FbMetric.cardPad)
.alignItems(VerticalAlign.Center)
.onClick(() => {
// 展开/收起:只改本地状态,不发请求
this.expandedId = this.isExpanded(t.id) ? 0 : t.id;
})
// 展开区:行程时间轴
if (this.isExpanded(t.id)) {
Divider()
.color(FbColors.line2)
.margin({ left: FbMetric.cardPad, right: FbMetric.cardPad })
if (this.nodes.length === 0) {
Column() {
Text('这个旅程还没有排行程')
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.faint)
}
.width('100%')
.padding(FbMetric.cardPad)
} else {
Column({ space: 0 }) {
// 分两段列:「实际」(已发生的)在前,「计划」在后 —— 与 Web 端顺序一致
this.stageBlock('实际', 'actual')
this.stageBlock('计划', 'plan')
}
.width('100%')
.padding({ left: FbMetric.cardPad, right: FbMetric.cardPad, bottom: FbMetric.cardPad })
}
}
}
.width('100%')
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radius)
.clip(true)
.alignItems(HorizontalAlign.Start)
}
/** 一段阶段块(实际 / 计划)+ 它下面的节点时间轴 */
@Builder
stageBlock(title: string, stage: string) {
if (this.nodesOf(stage).length > 0) {
Column({ space: 0 }) {
Row({ space: 6 }) {
FbTag({
text: title,
tone: stage === 'actual' ? 'green' : 'blue'
})
Text(this.nodesOf(stage).length.toString() + ' 段')
.fontSize(10)
.fontColor(FbColors.faint)
}
.width('100%')
.padding({ top: 10, bottom: 6 })
ForEach(this.nodesOf(stage), (n: FbTripNode) => {
this.nodeRow(n)
}, (n: FbTripNode): string => 'tn_' + stage + '_' + n.id.toString())
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}
/** 一个行程节点:左侧圆点 + 内容块,内容块的左边框连成时间轴竖线 */
@Builder
nodeRow(n: FbTripNode) {
Row({ space: 8 }) {
// 圆点:顶对齐,与标题首行齐平
FbDot({
dia: 9,
fillC: n.status === 'done' ? this.accent : '#A8988A'
})
.margin({ top: 5 })
// 内容块:左边框 = 时间轴的竖线(节点之间无间距,边框首尾相接成为连续线)
Column({ space: 4 }) {
Row({ space: 8 }) {
Text(n.title)
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(n.when)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
}
.width('100%')
Text(n.place)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
Row({ space: 6 }) {
if (n.mode.length > 0) {
FbTag({ text: n.mode, tone: 'blue' })
}
if (n.status === 'done') {
FbTag({ text: '已完成', tone: 'green' })
} else if (n.status === 'skip') {
FbTag({ text: '跳过', tone: 'muted' })
} else {
FbTag({ text: '待办', tone: 'muted' })
}
if (n.expenseCent > 0) {
Text(fbMoney(n.expenseCent))
.fontSize(10)
.fontColor(FbColors.muted)
}
}
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 12, bottom: 16 })
.border({ width: { left: 1 }, color: FbColors.line })
}
.width('100%')
.alignItems(VerticalAlign.Top)
}
private isExpanded(id: number): boolean {
return this.expandedId === id;
}
private nodesOf(stage: string): FbTripNode[] {
return this.nodes.filter((n: FbTripNode): boolean => n.stage === stage);
}
}