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 类根因后归零。)
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"apiType": "stageMode",
|
||||
"buildOption": {
|
||||
"resOptions": {
|
||||
"copyCodeResource": {
|
||||
"enable": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"buildOptionSet": [
|
||||
{
|
||||
"name": "release",
|
||||
"arkOptions": {
|
||||
"obfuscation": {
|
||||
"ruleOptions": {
|
||||
"enable": false,
|
||||
"files": [
|
||||
"./obfuscation-rules.txt"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
"targets": [
|
||||
{
|
||||
"name": "default"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { hapTasks } from '@ohos/hvigor-ohos-plugin';
|
||||
|
||||
export default {
|
||||
system: hapTasks, /* Built-in plugin of Hvigor. It cannot be modified. */
|
||||
plugins: [] /* Custom plugin to extend the functionality of Hvigor. */
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# Define project specific obfuscation rules here.
|
||||
# You can include the obfuscation configuration files in the current module's build-profile.json5.
|
||||
#
|
||||
# For more details, see
|
||||
# https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/source-obfuscation
|
||||
|
||||
# Obfuscation options:
|
||||
# -disable-obfuscation: disable all obfuscations
|
||||
# -enable-property-obfuscation: obfuscate the property names
|
||||
# -enable-toplevel-obfuscation: obfuscate the names in the global scope
|
||||
# -compact: remove unnecessary blank spaces and all line feeds
|
||||
# -keep-property-name: specify property names that you want to keep
|
||||
# -keep-global-name: specify global names that you want to keep
|
||||
|
||||
-enable-property-obfuscation
|
||||
-enable-toplevel-obfuscation
|
||||
-enable-filename-obfuscation
|
||||
-enable-export-obfuscation
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "entry",
|
||||
"version": "0.1.0",
|
||||
"description": "家书 · 鸿蒙客户端主模块",
|
||||
"main": "",
|
||||
"author": "",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 应用级状态(鸿蒙端)
|
||||
*
|
||||
* 用 V2 范式(@ObservedV2 + @Trace)—— 全项目统一 V2,不与 V1 装饰器混用
|
||||
* (@State/@Link 与 @Local/@Param 混用会直接编译报错)。
|
||||
*
|
||||
* ⚠️ 本类只放「跨页面共享」的状态:当前模块、抽屉开合、当前会话。
|
||||
* 页面自己的局部状态一律用 @Local,不往这里塞。
|
||||
*/
|
||||
|
||||
import { AppStorageV2 } from '@kit.ArkUI';
|
||||
import { FbAccount, FbSession } from '../model/Types';
|
||||
import { FB_DEMO_SESSION } from '../model/DemoData';
|
||||
|
||||
/**
|
||||
* 全局界面状态。
|
||||
*
|
||||
* ⚠️ 属性名避开通用链式属性名(width / height / value / padding …)——
|
||||
* 与 CustomComponent 基类方法重名会报 10505001。
|
||||
* 这里用 `drawerOpen` / `activeModule` 这类业务前缀,天然不冲突。
|
||||
*/
|
||||
@ObservedV2
|
||||
export class FbAppState {
|
||||
/** 抽屉是否展开(手机端滑出层;宽屏端恒为常驻,不看这个值) */
|
||||
@Trace drawerOpen: boolean = false;
|
||||
|
||||
/** 当前所在模块 key(home / people / movies …) */
|
||||
@Trace activeModule: string = 'home';
|
||||
|
||||
/** 是否宽屏(窗口短边 ≥ 600vp)—— 决定抽屉是「常驻侧栏」还是「滑出层」 */
|
||||
@Trace wideLayout: boolean = false;
|
||||
|
||||
/** 当前会话(接口接上之前先用演示数据;`loggedIn=false` 时应跳登录页) */
|
||||
@Trace session: FbSession = FB_DEMO_SESSION;
|
||||
|
||||
/** 未读通知数(顶栏铃铛角标) */
|
||||
@Trace unread: number = 2;
|
||||
|
||||
/** 当前模块的账号(便捷取用,避免页面里到处写 this.app.session.me) */
|
||||
me(): FbAccount {
|
||||
return this.session.me;
|
||||
}
|
||||
|
||||
/** 切模块:同时收起抽屉 */
|
||||
switchModule(key: string): void {
|
||||
this.activeModule = key;
|
||||
this.drawerOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取全局状态单例。
|
||||
*
|
||||
* ⚠️ 用 AppStorageV2 而不是 `new` 一个模块级变量 —— 后者在页面重建时会丢,
|
||||
* 而且多个页面拿到的可能不是同一个实例。
|
||||
*/
|
||||
export function fbAppState(): FbAppState {
|
||||
return AppStorageV2.connect(FbAppState, 'fbAppState', () => new FbAppState())!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 界面断点:窗口短边 ≥ 600vp 视为宽屏(平板 / 折叠屏展开 / 横屏手机)。
|
||||
*
|
||||
* 与 SyncCalendar 里那条判据同一依据 —— 短边在旋转时不变,
|
||||
* 所以「比例接近 1:1 的设备横竖屏布局保持一致」是天然满足的。
|
||||
*/
|
||||
export const FB_WIDE_BREAKPOINT: number = 600;
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 主题与设计令牌(鸿蒙端)
|
||||
*
|
||||
* 对应 Web 端 `assets/css/app.css` 里的 `:root { --paper / --ink / --brand … }`。
|
||||
*
|
||||
* 配色取自图标:宣纸米 / 砖红封面 / 深褐书脊 / 纸白。
|
||||
* 原则:扁平、留白充足、字重克制(正文 400,标题 500-600)。
|
||||
*
|
||||
* ⚠️ 深浅色两套值取自资源文件(`resources/base|dark/element/color.json`),
|
||||
* 这里只做**语义名 → $r 资源**的映射,不硬编码 hex ——
|
||||
* 这样系统切深色时不用改任何页面代码。
|
||||
*/
|
||||
|
||||
/** 语义色令牌(全部指向资源,随系统深浅色自动切换) */
|
||||
export class FbColors {
|
||||
/** 宣纸底(页面背景) */
|
||||
static readonly paper: Resource = $r('app.color.page_bg');
|
||||
/** 纸白(卡片背景) */
|
||||
static readonly surface: Resource = $r('app.color.surface');
|
||||
/** 略暖的白(次级卡片 / 内嵌块) */
|
||||
static readonly surfaceAlt: Resource = $r('app.color.surface_alt');
|
||||
/** 输入框底 */
|
||||
static readonly inputBg: Resource = $r('app.color.input_bg');
|
||||
|
||||
/** 主文字(墨) */
|
||||
static readonly ink: Resource = $r('app.color.ink');
|
||||
/** 次级文字 */
|
||||
static readonly muted: Resource = $r('app.color.muted');
|
||||
/** 最弱的文字(占位、时间戳) */
|
||||
static readonly faint: Resource = $r('app.color.faint');
|
||||
|
||||
/** 品牌色(砖红)—— 未进具体模块时的默认色 */
|
||||
static readonly brand: Resource = $r('app.color.brand');
|
||||
static readonly brand2: Resource = $r('app.color.brand_2');
|
||||
static readonly brandInk: Resource = $r('app.color.brand_ink');
|
||||
|
||||
/** 分割线 */
|
||||
static readonly line: Resource = $r('app.color.line');
|
||||
static readonly line2: Resource = $r('app.color.line_2');
|
||||
|
||||
/** 语义色:绿=成功 / 红=删除 / 蓝=编辑 / 黄=提醒 */
|
||||
static readonly green: Resource = $r('app.color.green');
|
||||
static readonly red: Resource = $r('app.color.red');
|
||||
static readonly blue: Resource = $r('app.color.blue');
|
||||
static readonly yellow: Resource = $r('app.color.yellow');
|
||||
}
|
||||
|
||||
/** 间距 / 圆角 / 字号令牌(数值常量,vp/fp 由调用处加单位) */
|
||||
export class FbMetric {
|
||||
/** 页面左右内边距 */
|
||||
static readonly pagePad: number = 16;
|
||||
/** 卡片内边距 */
|
||||
static readonly cardPad: number = 16;
|
||||
/** 卡片间距 */
|
||||
static readonly cardGap: number = 12;
|
||||
|
||||
/** 大圆角(卡片) */
|
||||
static readonly radius: number = 16;
|
||||
/** 小圆角(内嵌块 / 输入框) */
|
||||
static readonly radiusS: number = 10;
|
||||
/** 胶囊圆角 */
|
||||
static readonly radiusPill: number = 999;
|
||||
|
||||
/** 正文 */
|
||||
static readonly fontBody: number = 15;
|
||||
/** 次级信息 */
|
||||
static readonly fontSmall: number = 13;
|
||||
/** 最小号(时间戳 / 计数) */
|
||||
static readonly fontTiny: number = 11;
|
||||
/** 卡片标题 */
|
||||
static readonly fontTitle: number = 17;
|
||||
/** 页面大标题 */
|
||||
static readonly fontH1: number = 24;
|
||||
|
||||
/** 顶栏高度 */
|
||||
static readonly topBarH: number = 56;
|
||||
/** 抽屉宽度 */
|
||||
static readonly drawerW: number = 264;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模块主题色 → 客户端可用的三档调色板。
|
||||
*
|
||||
* 与 Web 端 `Module::cssVars()` 的差别:那边填进 CSS 变量(浏览器按色值渲染),
|
||||
* 这边直接算成 hex 字符串给 ArkUI 用(ArkUI 的 ResourceColor 接受 '#RRGGBB')。
|
||||
* 算法本身共用(见 `Modules.ets::fbThemeVars`)。
|
||||
*/
|
||||
export interface FbPalette {
|
||||
/** 模块主色(图标 / 强调) */
|
||||
main: string;
|
||||
/** 按钮底色 */
|
||||
brand: string;
|
||||
/** hover / 浅底 */
|
||||
brand2: string;
|
||||
/** 文字色(深一档,保证在浅底上看得清) */
|
||||
ink: string;
|
||||
/** 极浅底色(选中行背景) */
|
||||
wash: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 由模块主题色算出整套调色板。
|
||||
*
|
||||
* `wash` 是「选中行 / 标签底」用的极浅色 —— 做法是把主色降饱和、抬明度到 L≈0.94,
|
||||
* 这样在宣纸米底色上只是一层淡淡的染色,不会抢内容。
|
||||
*/
|
||||
export function fbPalette(moduleColor: string): FbPalette {
|
||||
const v = fbThemeVarsLocal(moduleColor);
|
||||
return {
|
||||
main: moduleColor,
|
||||
brand: v.brand,
|
||||
brand2: v.brand2,
|
||||
ink: v.ink,
|
||||
wash: fbWashOf(moduleColor)
|
||||
};
|
||||
}
|
||||
|
||||
/** 极浅染色底(L≈0.94,饱和度压到 0.35 上限) */
|
||||
function fbWashOf(hex: string): string {
|
||||
const hsl: number[] = fbHexToHslLocal(hex);
|
||||
const sat: number = Math.min(0.35, hsl[1]);
|
||||
return fbHslToHexLocal(hsl[0], sat, 0.94);
|
||||
}
|
||||
|
||||
/** 局部副本:避免 Theme 依赖 Modules(两文件都能独立使用) */
|
||||
function fbThemeVarsLocal(hex: string): FbThemeVarsLocal {
|
||||
const hsl: number[] = fbHexToHslLocal(hex.startsWith('#') ? hex : '#A84A28');
|
||||
const h: number = hsl[0];
|
||||
const sat: number = Math.max(0.28, Math.min(0.72, hsl[1]));
|
||||
return {
|
||||
brand: '#' + fbHslToHexLocal(h, sat, 0.42),
|
||||
brand2: '#' + fbHslToHexLocal(h, sat, 0.58),
|
||||
ink: '#' + fbHslToHexLocal(h, sat, 0.28)
|
||||
};
|
||||
}
|
||||
|
||||
interface FbThemeVarsLocal {
|
||||
brand: string;
|
||||
brand2: string;
|
||||
ink: string;
|
||||
}
|
||||
|
||||
function fbHexToHslLocal(hex: string): number[] {
|
||||
const s: string = hex.startsWith('#') ? hex.substring(1) : hex;
|
||||
const r: number = parseInt(s.substring(0, 2), 16) / 255;
|
||||
const g: number = parseInt(s.substring(2, 4), 16) / 255;
|
||||
const b: number = parseInt(s.substring(4, 6), 16) / 255;
|
||||
const mx: number = Math.max(r, g, b);
|
||||
const mn: number = Math.min(r, g, b);
|
||||
const l: number = (mx + mn) / 2;
|
||||
const d: number = mx - mn;
|
||||
if (d === 0) {
|
||||
return [0, 0, l];
|
||||
}
|
||||
const den: number = 1 - Math.abs(2 * l - 1);
|
||||
const sat: number = den > 1e-9 ? d / den : 0;
|
||||
let h: number;
|
||||
if (mx === r) {
|
||||
h = ((g - b) / d) % 6;
|
||||
} else if (mx === g) {
|
||||
h = ((b - r) / d) + 2;
|
||||
} else {
|
||||
h = ((r - g) / d) + 4;
|
||||
}
|
||||
h = h * 60;
|
||||
if (h < 0) {
|
||||
h = h + 360;
|
||||
}
|
||||
return [h / 360, sat, l];
|
||||
}
|
||||
|
||||
function fbHslToHexLocal(h: number, sat: number, l: number): string {
|
||||
const hh: number = h - Math.floor(h);
|
||||
if (sat <= 0) {
|
||||
const v: number = Math.round(l * 255);
|
||||
return fbH2(v) + fbH2(v) + fbH2(v);
|
||||
}
|
||||
const q: number = l < 0.5 ? l * (1 + sat) : l + sat - l * sat;
|
||||
const p: number = 2 * l - q;
|
||||
const conv = (t0: number): number => {
|
||||
let t: number = t0 - Math.floor(t0);
|
||||
if (t < 1 / 6) {
|
||||
return p + (q - p) * 6 * t;
|
||||
}
|
||||
if (t < 1 / 2) {
|
||||
return q;
|
||||
}
|
||||
if (t < 2 / 3) {
|
||||
return p + (q - p) * (2 / 3 - t) * 6;
|
||||
}
|
||||
return p;
|
||||
};
|
||||
const r: number = Math.max(0, Math.min(255, Math.round(conv(hh + 1 / 3) * 255)));
|
||||
const g: number = Math.max(0, Math.min(255, Math.round(conv(hh) * 255)));
|
||||
const b: number = Math.max(0, Math.min(255, Math.round(conv(hh - 1 / 3) * 255)));
|
||||
return '#' + fbH2(r) + fbH2(g) + fbH2(b);
|
||||
}
|
||||
|
||||
function fbH2(v: number): string {
|
||||
const t: string = v.toString(16).toUpperCase();
|
||||
return t.length < 2 ? '0' + t : t;
|
||||
}
|
||||
|
||||
/** 图标资源名 → Resource(模块图标与通用图标都在这张表里) */
|
||||
export class FbIcon {
|
||||
/** 取模块图标(key 同 FB_MODULES 的 key) */
|
||||
static module(key: string): Resource {
|
||||
return fbIconOf('fb_ic_' + key);
|
||||
}
|
||||
|
||||
/** 取通用图标(search / menu / close / bell / user / gear …) */
|
||||
static ui(name: string): Resource {
|
||||
return fbIconOf('fb_ic_' + name);
|
||||
}
|
||||
}
|
||||
|
||||
/** 动态资源名 → Resource(`$r` 需要字面量,这里用 switch 展开) */
|
||||
function fbIconOf(name: string): Resource {
|
||||
switch (name) {
|
||||
case 'fb_ic_home': return $r('app.media.fb_ic_home');
|
||||
case 'fb_ic_kin': return $r('app.media.fb_ic_kin');
|
||||
case 'fb_ic_people': return $r('app.media.fb_ic_people');
|
||||
case 'fb_ic_movies': return $r('app.media.fb_ic_movies');
|
||||
case 'fb_ic_trips': return $r('app.media.fb_ic_trips');
|
||||
case 'fb_ic_calendar': return $r('app.media.fb_ic_calendar');
|
||||
case 'fb_ic_resources': return $r('app.media.fb_ic_resources');
|
||||
case 'fb_ic_tasks': return $r('app.media.fb_ic_tasks');
|
||||
case 'fb_ic_days': return $r('app.media.fb_ic_days');
|
||||
case 'fb_ic_ledger': return $r('app.media.fb_ic_ledger');
|
||||
case 'fb_ic_books': return $r('app.media.fb_ic_books');
|
||||
case 'fb_ic_nav': return $r('app.media.fb_ic_nav');
|
||||
case 'fb_ic_notes': return $r('app.media.fb_ic_notes');
|
||||
case 'fb_ic_search': return $r('app.media.fb_ic_search');
|
||||
case 'fb_ic_menu': return $r('app.media.fb_ic_menu');
|
||||
case 'fb_ic_close': return $r('app.media.fb_ic_close');
|
||||
case 'fb_ic_back': return $r('app.media.fb_ic_back');
|
||||
case 'fb_ic_forward': return $r('app.media.fb_ic_forward');
|
||||
case 'fb_ic_plus': return $r('app.media.fb_ic_plus');
|
||||
case 'fb_ic_bell': return $r('app.media.fb_ic_bell');
|
||||
case 'fb_ic_gear': return $r('app.media.fb_ic_gear');
|
||||
case 'fb_ic_user': return $r('app.media.fb_ic_user');
|
||||
case 'fb_ic_chart': return $r('app.media.fb_ic_chart');
|
||||
case 'fb_ic_check': return $r('app.media.fb_ic_check');
|
||||
case 'fb_ic_dot': return $r('app.media.fb_ic_dot');
|
||||
case 'fb_ic_share': return $r('app.media.fb_ic_share');
|
||||
case 'fb_ic_filter': return $r('app.media.fb_ic_filter');
|
||||
case 'fb_ic_lock': return $r('app.media.fb_ic_lock');
|
||||
case 'fb_ic_wallet': return $r('app.media.fb_ic_wallet');
|
||||
case 'fb_ic_book': return $r('app.media.fb_ic_book');
|
||||
case 'fb_ic_edit': return $r('app.media.fb_ic_edit');
|
||||
case 'fb_ic_trash': return $r('app.media.fb_ic_trash');
|
||||
default: return $r('app.media.fb_ic_dot');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 左侧抽屉(鸿蒙端)
|
||||
*
|
||||
* 形态对齐 Web 端顶栏 `.nav` 那一排模块入口,但**改成抽屉**(用户建议):
|
||||
* Web 端导航条最多放 12 个模块,手机宽度下必然溢出;抽屉竖向排列,
|
||||
* 还能顺带放下「模块说明 + 主题色」这层信息,一眼看清每个模块是干什么的。
|
||||
*
|
||||
* 两条形态,同一个组件:
|
||||
* · **常驻侧栏**(宽屏:窗口短边 ≥ 600vp)—— 固定在左边,占 264vp;
|
||||
* · **滑出层**(手机竖屏)—— 覆盖在内容上,点遮罩或选中项即收起。
|
||||
*
|
||||
* ⚠️ 这里**不用 SideBarContainer**:它的控制按钮与「滑出」是内建的一套,
|
||||
* 样式(图标、位置、宽度动画)很难与家书的视觉统一,且它只能有一层侧栏
|
||||
* 内容 —— 而我们要在抽屉里放「品牌区 + 模块列表 + 底部账号区」三段。
|
||||
* 自己用 Stack + 位移动画做,行为完全可控。
|
||||
*/
|
||||
|
||||
import { FbColors, FbIcon, FbMetric } from '../common/Theme';
|
||||
import { FbAppState, fbAppState } from '../common/Store';
|
||||
import { FbModuleDef, FB_MODULES, fbDefaultNavModules, fbThemeVars } from '../model/Modules';
|
||||
import { FbAvatar } from './Widgets';
|
||||
|
||||
@ComponentV2
|
||||
export struct FbDrawer {
|
||||
/** 全局状态(父组件传入,保证与页面同一份实例) */
|
||||
@Param app: FbAppState = fbAppState();
|
||||
/** 抽屉宽度 */
|
||||
@Param drawerW: number = FbMetric.drawerW;
|
||||
/** 是否以「覆盖层」形态出现(宽屏时为 false = 常驻) */
|
||||
@Param overlayMode: boolean = true;
|
||||
/** 覆盖层形态下:点遮罩关闭 */
|
||||
@Event onDismiss: () => void = () => {
|
||||
};
|
||||
|
||||
/** 抽屉里列出的模块(接口接上后由 user_settings('nav_modules') 下发) */
|
||||
@Local items: FbModuleDef[] = fbDefaultNavModules();
|
||||
|
||||
/** 品牌区:这里放阅文风格的书名(家书)+ 家庭名 */
|
||||
@Builder
|
||||
brandHeader() {
|
||||
Row({ space: 10 }) {
|
||||
Image($r('app.media.fb_logo'))
|
||||
.width(34)
|
||||
.height(34)
|
||||
.borderRadius(9)
|
||||
Column({ space: 1 }) {
|
||||
Text('家书')
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(FbColors.ink)
|
||||
.letterSpacing(1)
|
||||
Text(this.app.session.familyTitle)
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.muted)
|
||||
.maxLines(1)
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 18, right: 12, top: 18, bottom: 14 })
|
||||
}
|
||||
|
||||
/** 一个模块行 */
|
||||
@Builder
|
||||
moduleRow(m: FbModuleDef) {
|
||||
Row({ space: 12 }) {
|
||||
// 图标底:选中时用模块色实底(白图标),未选中时用极浅染色底(彩色图标)
|
||||
Stack() {
|
||||
Image(FbIcon.module(m.key))
|
||||
.width(18)
|
||||
.height(18)
|
||||
.renderMode(ImageRenderMode.Template)
|
||||
.fillColor(this.isActive(m.key) ? Color.White : fbThemeVars(m.color).brand)
|
||||
}
|
||||
.width(34)
|
||||
.height(34)
|
||||
.borderRadius(10)
|
||||
.backgroundColor(this.isActive(m.key) ? fbThemeVars(m.color).brand : this.washOf(m.color))
|
||||
|
||||
Column({ space: 1 }) {
|
||||
Text(m.name)
|
||||
.fontSize(FbMetric.fontBody)
|
||||
.fontWeight(this.isActive(m.key) ? FontWeight.Medium : FontWeight.Normal)
|
||||
.fontColor(FbColors.ink)
|
||||
Text(m.desc)
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.faint)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 12, right: 12, top: 9, bottom: 9 })
|
||||
.borderRadius(FbMetric.radiusS)
|
||||
.backgroundColor(this.isActive(m.key) ? this.washOf(m.color) : Color.Transparent)
|
||||
.onClick(() => {
|
||||
this.app.switchModule(m.key);
|
||||
this.onDismiss();
|
||||
})
|
||||
}
|
||||
|
||||
/** 底部账号区(对应 Web 端右上角头像菜单里的那几项) */
|
||||
@Builder
|
||||
accountFooter() {
|
||||
Column({ space: 2 }) {
|
||||
Divider()
|
||||
.color(FbColors.line2)
|
||||
.margin({ bottom: 6 })
|
||||
|
||||
this.footerRow('fb_ic_bell', '通知消息', this.app.unread > 0 ? this.app.unread.toString() : '')
|
||||
this.footerRow('fb_ic_user', '个人设置', '')
|
||||
this.footerRow('fb_ic_lock', '修改密码', '')
|
||||
if (this.app.session.me.role === 'admin') {
|
||||
this.footerRow('fb_ic_gear', '系统设置', '')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 12, right: 12, bottom: 10 })
|
||||
}
|
||||
|
||||
/** 底部一行 */
|
||||
@Builder
|
||||
footerRow(iconName: string, label: string, badge: string) {
|
||||
Row({ space: 12 }) {
|
||||
Image(FbIcon.ui(iconName))
|
||||
.width(17)
|
||||
.height(17)
|
||||
.renderMode(ImageRenderMode.Template)
|
||||
.fillColor(FbColors.muted)
|
||||
Text(label)
|
||||
.fontSize(FbMetric.fontSmall)
|
||||
.fontColor(FbColors.ink)
|
||||
.layoutWeight(1)
|
||||
if (badge.length > 0) {
|
||||
Text(badge)
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(Color.White)
|
||||
.backgroundColor(FbColors.red)
|
||||
.borderRadius(FbMetric.radiusPill)
|
||||
.padding({ left: 6, right: 6, top: 1, bottom: 1 })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 10, right: 10, top: 9, bottom: 9 })
|
||||
.borderRadius(FbMetric.radiusS)
|
||||
}
|
||||
|
||||
/** 当前是否选中该模块 */
|
||||
private isActive(key: string): boolean {
|
||||
return this.app.activeModule === key;
|
||||
}
|
||||
|
||||
/** 模块色的极浅染色底(选中行 / 图标底) */
|
||||
private washOf(hex: string): string {
|
||||
// 与 Theme.fbPalette 的 wash 同口径:压饱和、抬明度到 0.94
|
||||
const t = fbThemeVars(hex);
|
||||
return t.brand + '1A';
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
this.brandHeader()
|
||||
|
||||
// 模块列表:可滚动,模块多时不会把底部账号区挤掉
|
||||
List({ space: 1 }) {
|
||||
ForEach(this.items, (m: FbModuleDef) => {
|
||||
ListItem() {
|
||||
this.moduleRow(m)
|
||||
}
|
||||
}, (m: FbModuleDef): string => 'nav_' + m.key)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.width('100%')
|
||||
.padding({ left: 6, right: 6 })
|
||||
.scrollBar(BarState.Off)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
|
||||
this.accountFooter()
|
||||
}
|
||||
.width(this.drawerW)
|
||||
.height('100%')
|
||||
.backgroundColor(FbColors.surface)
|
||||
// ⚠️ 侧栏右侧的分隔线只在常驻形态下需要;覆盖层形态下它贴着屏幕边,加了反而怪
|
||||
.border(this.overlayMode
|
||||
? { width: 0 }
|
||||
: { width: { right: 1 }, color: FbColors.line2 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 抽屉 + 内容 的整体骨架。
|
||||
*
|
||||
* 两种形态在这里分派:
|
||||
* · 宽屏 → Row(抽屉占位 + 内容区);
|
||||
* · 窄屏 → Stack(内容铺满,抽屉带遮罩浮在上面)。
|
||||
*/
|
||||
@ComponentV2
|
||||
export struct FbDrawerShell {
|
||||
@Param app: FbAppState = fbAppState();
|
||||
/** 内容区(由父组件通过尾随 lambda 传入) */
|
||||
@BuilderParam content: () => void;
|
||||
|
||||
build() {
|
||||
Stack({ alignContent: Alignment.TopStart }) {
|
||||
// ---- 宽屏:并排 ----
|
||||
if (this.app.wideLayout) {
|
||||
Row() {
|
||||
FbDrawer({
|
||||
app: this.app,
|
||||
overlayMode: false
|
||||
})
|
||||
Column() {
|
||||
this.content()
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.height('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
} else {
|
||||
// ---- 窄屏:内容铺满 + 抽屉浮层 ----
|
||||
Column() {
|
||||
this.content()
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
|
||||
if (this.app.drawerOpen) {
|
||||
// 遮罩
|
||||
Column()
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor('#66000000')
|
||||
.onClick(() => {
|
||||
this.app.drawerOpen = false;
|
||||
})
|
||||
.transition(TransitionEffect.OPACITY.animation({ duration: 180 }))
|
||||
|
||||
FbDrawer({
|
||||
app: this.app,
|
||||
overlayMode: true,
|
||||
onDismiss: (): void => {
|
||||
this.app.drawerOpen = false;
|
||||
}
|
||||
})
|
||||
.transition(TransitionEffect.translate({ x: -FbMetric.drawerW })
|
||||
.animation({ duration: 220, curve: Curve.FastOutSlowIn }))
|
||||
}
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(FbColors.paper)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 顶栏(鸿蒙端)
|
||||
*
|
||||
* 对应 Web 端 `templates/layout.php` 的 `<header class="topbar">`,但按手机习惯调整:
|
||||
* · 左:抽屉按钮(窄屏)/模块名(宽屏,此时抽屉常驻,不需要按钮)
|
||||
* · 中:当前模块名 + 一句副标题(Web 端顶栏只放 logo + 导航,端上加副标题更清楚)
|
||||
* · 右:通知铃铛(带未读角标)+ 头像(点开是账号菜单)
|
||||
*
|
||||
* ⚠️ 顶栏**不放**「设置」—— 与 Web 端一致:设置收在头像菜单里,
|
||||
* 顶栏只留日常入口,再把管理入口顶出来会把顶栏挤满。
|
||||
*/
|
||||
|
||||
import { FbColors, FbIcon, FbMetric } from '../common/Theme';
|
||||
import { FbAppState, fbAppState } from '../common/Store';
|
||||
import { fbModuleOf } from '../model/Modules';
|
||||
import { FbAvatar } from './Widgets';
|
||||
|
||||
@ComponentV2
|
||||
export struct FbTopBar {
|
||||
@Param app: FbAppState = fbAppState();
|
||||
/** 右侧额外操作(各模块页自己往里塞「新建」「搜索」等按钮) */
|
||||
@BuilderParam actions: () => void;
|
||||
|
||||
build() {
|
||||
// 顶部安全区由外层容器统一处理(见 Index.ets),这里只占视觉高度
|
||||
Row({ space: 10 }) {
|
||||
// ---- 左:窄屏显示抽屉按钮 ----
|
||||
if (!this.app.wideLayout) {
|
||||
Button({ type: ButtonType.Circle }) {
|
||||
Image(FbIcon.ui('menu'))
|
||||
.width(18)
|
||||
.height(18)
|
||||
.renderMode(ImageRenderMode.Template)
|
||||
.fillColor(FbColors.ink)
|
||||
}
|
||||
.width(36)
|
||||
.height(36)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.onClick(() => {
|
||||
this.app.drawerOpen = true;
|
||||
})
|
||||
} else {
|
||||
Image($r('app.media.fb_logo'))
|
||||
.width(28)
|
||||
.height(28)
|
||||
.borderRadius(8)
|
||||
}
|
||||
|
||||
// ---- 中:当前模块名 ----
|
||||
Column({ space: 0 }) {
|
||||
Text(this.moduleName())
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(FbColors.ink)
|
||||
if (this.moduleDesc().length > 0) {
|
||||
Text(this.moduleDesc())
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.faint)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
|
||||
// ---- 右:各模块自己的操作 ----
|
||||
this.actions()
|
||||
|
||||
// 通知铃铛(未读角标)
|
||||
Stack({ alignContent: Alignment.TopEnd }) {
|
||||
Button({ type: ButtonType.Circle }) {
|
||||
Image(FbIcon.ui('bell'))
|
||||
.width(18)
|
||||
.height(18)
|
||||
.renderMode(ImageRenderMode.Template)
|
||||
.fillColor(FbColors.muted)
|
||||
}
|
||||
.width(36)
|
||||
.height(36)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.onClick(() => {
|
||||
this.app.switchModule('__notifications');
|
||||
})
|
||||
|
||||
if (this.app.unread > 0) {
|
||||
Text(this.app.unread > 99 ? '99+' : this.app.unread.toString())
|
||||
.fontSize(10)
|
||||
.fontColor(Color.White)
|
||||
.backgroundColor(FbColors.red)
|
||||
.borderRadius(FbMetric.radiusPill)
|
||||
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
|
||||
.margin({ top: 2, right: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
// 头像(点开账号菜单)
|
||||
FbAvatar({
|
||||
name: this.app.session.me.name,
|
||||
color: this.app.session.me.avatarColor,
|
||||
dia: 32,
|
||||
fontSizeVp: 14
|
||||
})
|
||||
.margin({ left: 2 })
|
||||
}
|
||||
.width('100%')
|
||||
.height(FbMetric.topBarH)
|
||||
.padding({ left: 8, right: 10 })
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.backgroundColor(FbColors.surface)
|
||||
.border({ width: { bottom: 1 }, color: FbColors.line2 })
|
||||
}
|
||||
|
||||
private moduleName(): string {
|
||||
const m = fbModuleOf(this.app.activeModule);
|
||||
return m !== undefined ? m.name : '家书';
|
||||
}
|
||||
|
||||
private moduleDesc(): string {
|
||||
const m = fbModuleOf(this.app.activeModule);
|
||||
return m !== undefined ? m.desc : '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 共享 UI 组件(鸿蒙端)
|
||||
*
|
||||
* 这些都是**无状态或轻状态**的展示件,对应 Web 端 `assets/css/app.css` 里
|
||||
* 那几个反复出现的类:`.card` / `.page-head` / `.empty` / `.hint` / `.node-badge` …
|
||||
*
|
||||
* ⚠️ 全部用 @ComponentV2 + @Param(只读入参),不在子组件里改父组件状态。
|
||||
* ⚠️ 所有 struct 加 `Fb` 前缀(arkts-no-decl-merging:同 hap 内顶层名不得重复)。
|
||||
*/
|
||||
|
||||
import { FbColors, FbIcon, FbMetric } from '../common/Theme';
|
||||
|
||||
/**
|
||||
* 圆点 / 色块(时间轴的点、日历的日程小点、看板的色标…)。
|
||||
*
|
||||
* ⚠️ 为什么不用 `Circle()`:它的 `.fill()` 是 **API 26.0.0** 才有的重载,
|
||||
* 本工程 compatibleSdkVersion 是 6.1.1(24),编译器会告警
|
||||
* 「This API is supported since SDK version 26.0.0」,真机上可能不生效。
|
||||
* 改用「方块 + 圆角 = 圆」的写法:`Column().width().height().borderRadius(dia/2)`
|
||||
* 只用通用属性,任何版本都成立,而且圆形效果完全等价。
|
||||
*
|
||||
* @Param dia 直径(vp)
|
||||
* @Param fillC 填充色(ResourceColor:hex 字符串或 $r 语义色)
|
||||
* @Param ringC 描边色(不传则不描边)
|
||||
*/
|
||||
@ComponentV2
|
||||
export struct FbDot {
|
||||
@Param dia: number = 8;
|
||||
@Param fillC: ResourceColor = '#A84A28';
|
||||
@Param ringC?: ResourceColor = undefined;
|
||||
|
||||
build() {
|
||||
Stack({ alignContent: Alignment.Center }) {
|
||||
// 底:填充
|
||||
Column()
|
||||
.width(this.dia)
|
||||
.height(this.dia)
|
||||
.borderRadius(this.dia / 2)
|
||||
.backgroundColor(this.fillC)
|
||||
// 描边(空心点用:内层挖一个背景色圆,等效于 1.4vp 环形)
|
||||
if (this.ringC !== undefined) {
|
||||
Column()
|
||||
.width(this.dia - 2.8)
|
||||
.height(this.dia - 2.8)
|
||||
.borderRadius((this.dia - 2.8) / 2)
|
||||
.backgroundColor(FbColors.surface)
|
||||
}
|
||||
}
|
||||
.width(this.dia)
|
||||
.height(this.dia)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 空心状态圈(任务卡的完成圈:未完成时空心、完成时实心 + 勾)。
|
||||
*
|
||||
* 同样避开 Circle().fill()/stroke(),用嵌套方块实现。
|
||||
*/
|
||||
@ComponentV2
|
||||
export struct FbCheckCircle {
|
||||
/** 直径 */
|
||||
@Param dia: number = 16;
|
||||
/** 是否已完成(实心 + 勾) */
|
||||
@Param done: boolean = false;
|
||||
/** 是否进行中(实心小点) */
|
||||
@Param doing: boolean = false;
|
||||
/** 主色 */
|
||||
@Param color: string = '#A8574F';
|
||||
|
||||
build() {
|
||||
Stack({ alignContent: Alignment.Center }) {
|
||||
// 外圈:完成/进行中是实心主色,否则用浅灰实心(代表描边)
|
||||
Column()
|
||||
.width(this.dia)
|
||||
.height(this.dia)
|
||||
.borderRadius(this.dia / 2)
|
||||
.backgroundColor(this.done || this.doing ? this.color : '#A8988A')
|
||||
|
||||
// 内层:未勾选时用卡片底「挖空」,形成空心环
|
||||
if (!this.done && !this.doing) {
|
||||
Column()
|
||||
.width(this.dia - 2.8)
|
||||
.height(this.dia - 2.8)
|
||||
.borderRadius((this.dia - 2.8) / 2)
|
||||
.backgroundColor(FbColors.surface)
|
||||
}
|
||||
|
||||
// 已完成:打勾图标
|
||||
if (this.done) {
|
||||
Image($r('app.media.fb_ic_check'))
|
||||
.width(this.dia * 0.66)
|
||||
.height(this.dia * 0.66)
|
||||
.renderMode(ImageRenderMode.Template)
|
||||
.fillColor(Color.White)
|
||||
} else if (this.doing) {
|
||||
// 进行中:中心一个白点
|
||||
Column()
|
||||
.width(this.dia * 0.42)
|
||||
.height(this.dia * 0.42)
|
||||
.borderRadius(this.dia * 0.21)
|
||||
.backgroundColor(Color.White)
|
||||
}
|
||||
}
|
||||
.width(this.dia)
|
||||
.height(this.dia)
|
||||
}
|
||||
}
|
||||
|
||||
/** 空态:一块居中的提示卡(Web 端 `.card.empty`) */
|
||||
@ComponentV2
|
||||
export struct FbEmptyBox {
|
||||
/** 主标题 */
|
||||
@Param title: string = '这里还是空的';
|
||||
/** 副说明 */
|
||||
@Param hint: string = '';
|
||||
/** 可选的插图图标(默认圆点) */
|
||||
@Param icon: Resource = $r('app.media.fb_ic_dot');
|
||||
|
||||
build() {
|
||||
Column({ space: 8 }) {
|
||||
Image(this.icon)
|
||||
.width(34)
|
||||
.height(34)
|
||||
.opacity(0.35)
|
||||
.renderMode(ImageRenderMode.Template)
|
||||
.fillColor(FbColors.faint)
|
||||
Text(this.title)
|
||||
.fontSize(FbMetric.fontBody)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(FbColors.ink)
|
||||
if (this.hint.length > 0) {
|
||||
Text(this.hint)
|
||||
.fontSize(FbMetric.fontSmall)
|
||||
.fontColor(FbColors.muted)
|
||||
.textAlign(TextAlign.Center)
|
||||
.lineHeight(20)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 40, bottom: 40, left: 24, right: 24 })
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
/** 区块小标题(Web 端 `.cinema-h2`:一行标题 + 可选的右侧说明) */
|
||||
@ComponentV2
|
||||
export struct FbSectionTitle {
|
||||
@Param title: string = '';
|
||||
@Param extra: string = '';
|
||||
|
||||
build() {
|
||||
Row() {
|
||||
Text(this.title)
|
||||
.fontSize(FbMetric.fontSmall)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(FbColors.muted)
|
||||
.letterSpacing(0.6)
|
||||
if (this.extra.length > 0) {
|
||||
Blank()
|
||||
Text(this.extra)
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.faint)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 4, right: 4, top: 12, bottom: 8 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 头像(Web 端 `nav_avatar.php`:姓名首字 + 由名字哈希出的底色)。
|
||||
*
|
||||
* 端上不做「上传头像」—— 先把「首字 + 色块」这套做出来,
|
||||
* 接接口后再叠真实图片。
|
||||
*/
|
||||
@ComponentV2
|
||||
export struct FbAvatar {
|
||||
/** 显示名(取首字) */
|
||||
@Param name: string = '';
|
||||
/** 底色(由业务侧算好传进来) */
|
||||
@Param color: string = '#A84A28';
|
||||
/** 直径(vp) */
|
||||
@Param dia: number = 36;
|
||||
/** 字号(vp);不传则按直径推算 */
|
||||
@Param fontSizeVp: number = 0;
|
||||
|
||||
/** 取名字的第一个字(含中文名的姓) */
|
||||
private initial(): string {
|
||||
const t: string = this.name.trim();
|
||||
if (t.length === 0) {
|
||||
return '?';
|
||||
}
|
||||
return t.substring(0, 1);
|
||||
}
|
||||
|
||||
build() {
|
||||
Text(this.initial())
|
||||
.width(this.dia)
|
||||
.height(this.dia)
|
||||
.borderRadius(this.dia / 2)
|
||||
.backgroundColor(this.color)
|
||||
.fontColor(Color.White)
|
||||
.fontSize(this.fontSizeVp > 0 ? this.fontSizeVp : Math.round(this.dia * 0.42))
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.textAlign(TextAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 「同看的人」这类人员标签(Web 端 `person_chip`:小头像 + 称呼)。
|
||||
*
|
||||
* ⚠️ 会出现在 <a> 内部的行内场景,所以整块不可点、纯展示。
|
||||
*/
|
||||
@ComponentV2
|
||||
export struct FbPersonChip {
|
||||
@Param name: string = '';
|
||||
@Param color: string = '#A84A28';
|
||||
/** 是否显示文字(只放头像时传 false) */
|
||||
@Param withText: boolean = true;
|
||||
|
||||
build() {
|
||||
Row({ space: 4 }) {
|
||||
FbAvatar({ name: this.name, color: this.color, dia: 18, fontSizeVp: 10 })
|
||||
if (this.withText) {
|
||||
Text(this.name)
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.muted)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
}
|
||||
.padding({ left: 4, right: 8, top: 2, bottom: 2 })
|
||||
.borderRadius(FbMetric.radiusPill)
|
||||
.backgroundColor(FbColors.inputBg)
|
||||
}
|
||||
}
|
||||
|
||||
/** 小徽标(Web 端 `.node-badge` / `.badge`:管理员、访客、标签…) */
|
||||
@ComponentV2
|
||||
export struct FbTag {
|
||||
@Param text: string = '';
|
||||
@Param tone: string = 'muted';
|
||||
|
||||
private fgColor(): string {
|
||||
if (this.tone === 'brand') {
|
||||
return '#A84A28';
|
||||
}
|
||||
if (this.tone === 'red') {
|
||||
return '#C0533F';
|
||||
}
|
||||
if (this.tone === 'green') {
|
||||
return '#4E7C3F';
|
||||
}
|
||||
if (this.tone === 'blue') {
|
||||
return '#2F6FB0';
|
||||
}
|
||||
return '#7E6B5A';
|
||||
}
|
||||
|
||||
private bgColor(): string {
|
||||
if (this.tone === 'brand') {
|
||||
return '#14A84A28';
|
||||
}
|
||||
if (this.tone === 'red') {
|
||||
return '#14C0533F';
|
||||
}
|
||||
if (this.tone === 'green') {
|
||||
return '#146F9E5C';
|
||||
}
|
||||
if (this.tone === 'blue') {
|
||||
return '#142F6FB0';
|
||||
}
|
||||
return '#0F2C231D';
|
||||
}
|
||||
|
||||
build() {
|
||||
Text(this.text)
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(this.fgColor())
|
||||
.backgroundColor(this.bgColor())
|
||||
.padding({ left: 7, right: 7, top: 2, bottom: 2 })
|
||||
.borderRadius(FbMetric.radiusPill)
|
||||
.maxLines(1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 页头的「设置 / 更多」按钮(Web 端 `.btn.btn-icon`)。
|
||||
*
|
||||
* ⚠️ 必须是 `Button() { Image() }` 形式:`Button('label') { ... }` 同时给
|
||||
* label 和子组件会报 10905202。
|
||||
*/
|
||||
@ComponentV2
|
||||
export struct FbIconButton {
|
||||
@Param icon: Resource = $r('app.media.fb_ic_gear');
|
||||
/** 图标色:ResourceColor 同时接受 Resource($r 语义色)与 '#RRGGBB' 字符串 */
|
||||
@Param tint: ResourceColor = FbColors.muted;
|
||||
/** 直径 */
|
||||
@Param dia: number = 36;
|
||||
@Event onTap: () => void = () => {
|
||||
};
|
||||
|
||||
build() {
|
||||
Button({ type: ButtonType.Circle }) {
|
||||
Image(this.icon)
|
||||
.width(18)
|
||||
.height(18)
|
||||
.renderMode(ImageRenderMode.Template)
|
||||
.fillColor(this.tint)
|
||||
}
|
||||
.width(this.dia)
|
||||
.height(this.dia)
|
||||
.backgroundColor(FbColors.inputBg)
|
||||
.onClick(() => {
|
||||
this.onTap();
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主操作按钮(Web 端 `.btn`:模块色底 + 白字)。
|
||||
*
|
||||
* ⚠️ 同样用 `Button() { Row() { … } }` 形式,label 与子组件二选一。
|
||||
*/
|
||||
@ComponentV2
|
||||
export struct FbPrimaryButton {
|
||||
@Param label: string = '';
|
||||
/** 底色(一般传模块主题色的 hex 字符串,也可传 Resource 语义色) */
|
||||
@Param bg: ResourceColor = '#A84A28';
|
||||
/** 前置图标(不传则只显示文字) */
|
||||
@Param icon?: Resource = undefined;
|
||||
/** 是否撑满宽度 */
|
||||
@Param block: boolean = false;
|
||||
@Event onTap: () => void = () => {
|
||||
};
|
||||
|
||||
build() {
|
||||
Button({ type: ButtonType.ROUNDED_RECTANGLE }) {
|
||||
Row({ space: 6 }) {
|
||||
if (this.icon !== undefined) {
|
||||
Image(this.icon)
|
||||
.width(16)
|
||||
.height(16)
|
||||
.renderMode(ImageRenderMode.Template)
|
||||
.fillColor(Color.White)
|
||||
}
|
||||
Text(this.label)
|
||||
.fontSize(FbMetric.fontSmall)
|
||||
.fontColor(Color.White)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
}
|
||||
.justifyContent(FlexAlign.Center)
|
||||
}
|
||||
.height(36)
|
||||
.padding({ left: 14, right: 14 })
|
||||
.backgroundColor(this.bg)
|
||||
.borderRadius(FbMetric.radiusS)
|
||||
.stateEffect(true)
|
||||
.width(this.block ? '100%' : undefined)
|
||||
.onClick(() => {
|
||||
this.onTap();
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一行「标签 + 值」的信息条(名册详情、观影信息、财务条目…都用它)。
|
||||
*
|
||||
* 对应 Web 端各页面里那种 `圆点 + 文字` 的行。
|
||||
*/
|
||||
@ComponentV2
|
||||
export struct FbDotLine {
|
||||
/** 圆点色(不传则不画点) */
|
||||
@Param dotColor: string = '';
|
||||
/** 左侧主文字 */
|
||||
@Param label: string = '';
|
||||
/** 右侧值 */
|
||||
@Param tail: string = '';
|
||||
/** 主文字淡化成次要色(用于「没有内容」的占位行) */
|
||||
@Param muted: boolean = false;
|
||||
|
||||
build() {
|
||||
Row({ space: 8 }) {
|
||||
if (this.dotColor.length > 0) {
|
||||
FbDot({ dia: 7, fillC: this.dotColor })
|
||||
}
|
||||
Text(this.label)
|
||||
.fontSize(FbMetric.fontSmall)
|
||||
.fontColor(this.muted ? FbColors.faint : FbColors.ink)
|
||||
.layoutWeight(1)
|
||||
.maxLines(2)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
if (this.tail.length > 0) {
|
||||
Text(this.tail)
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.faint)
|
||||
.maxLines(1)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
/** 一条「三格数字」统计条(首页近七天:日程 / 待办 / 逾期) */
|
||||
@ComponentV2
|
||||
export struct FbStatTriple {
|
||||
@Param labelA: string = '';
|
||||
@Param valueA: string = '';
|
||||
@Param labelB: string = '';
|
||||
@Param valueB: string = '';
|
||||
@Param labelC: string = '';
|
||||
@Param valueC: string = '';
|
||||
/** C 项是否用警示红(逾期数) */
|
||||
@Param cAlarm: boolean = false;
|
||||
|
||||
@Builder
|
||||
cell(label: string, value: string, alarm: boolean) {
|
||||
Column({ space: 2 }) {
|
||||
Text(value)
|
||||
.fontSize(20)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(alarm ? FbColors.red : FbColors.ink)
|
||||
Text(label)
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.muted)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
|
||||
build() {
|
||||
Row() {
|
||||
this.cell(this.labelA, this.valueA, false)
|
||||
this.cell(this.labelB, this.valueB, false)
|
||||
this.cell(this.labelC, this.valueC, this.cAlarm)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 10, bottom: 10 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 进度条(读书进度、旅程完成度…)。
|
||||
*
|
||||
* ⚠️ 用 Progress 的 Linear 类型;`ProgressType` 枚举是 Linear / Ring / Eclipse…
|
||||
*/
|
||||
@ComponentV2
|
||||
export struct FbProgressLine {
|
||||
/** 0–100 */
|
||||
@Param percent: number = 0;
|
||||
@Param color: string = '#A84A28';
|
||||
|
||||
build() {
|
||||
Progress({ value: this.percent, total: 100, type: ProgressType.Linear })
|
||||
.width('100%')
|
||||
.height(4)
|
||||
.color(this.color)
|
||||
.backgroundColor(FbColors.inputBg)
|
||||
}
|
||||
}
|
||||
|
||||
/** 评分(观影 /·/10 分、读书 5 星)—— 用文字而非图形,避免臆造评分组件 */
|
||||
@ComponentV2
|
||||
export struct FbRating {
|
||||
/** 分值 */
|
||||
@Param score: number = 0;
|
||||
/** 满分(10 或 5) */
|
||||
@Param max: number = 10;
|
||||
|
||||
build() {
|
||||
Row({ space: 2 }) {
|
||||
Text('★')
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor('#C08A2E')
|
||||
Text(this.max === 5 ? this.score.toFixed(1) : this.score.toFixed(1))
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#C08A2E')
|
||||
Text('/' + this.max.toString())
|
||||
.fontSize(FbMetric.fontTiny)
|
||||
.fontColor(FbColors.faint)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { AbilityConstant, Configuration, ConfigurationConstant, EnvironmentCallback,
|
||||
UIAbility, Want } from '@kit.AbilityKit';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { window } from '@kit.ArkUI';
|
||||
|
||||
const DOMAIN: number = 0x0000;
|
||||
|
||||
/**
|
||||
* 家书 · 应用入口。
|
||||
*
|
||||
* 只做三件事:加载主页面、把状态栏/导航条配色对齐主题、跟随系统深浅色重刷。
|
||||
*
|
||||
* ⚠️ 这里**不申请任何权限**(除 module.json5 里已声明的 INTERNET)。
|
||||
* 要权限必须在用户主动点对应功能时再申请 —— 启动即弹窗会被审核驳回。
|
||||
*/
|
||||
export default class EntryAbility extends UIAbility {
|
||||
private mainWindow?: window.Window;
|
||||
|
||||
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
|
||||
hilog.info(DOMAIN, 'FamilyBook', '%{public}s', 'Ability onCreate');
|
||||
}
|
||||
|
||||
onDestroy(): void {
|
||||
hilog.info(DOMAIN, 'FamilyBook', '%{public}s', 'Ability onDestroy');
|
||||
}
|
||||
|
||||
onWindowStageCreate(windowStage: window.WindowStage): void {
|
||||
// ① 先跟随系统深浅色。必须在取窗口之前调用,否则会读到尚未更新的色彩配置。
|
||||
try {
|
||||
this.context.getApplicationContext()
|
||||
.setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
|
||||
} catch (err) {
|
||||
hilog.error(DOMAIN, 'FamilyBook', 'setColorMode failed: %{public}s', JSON.stringify(err));
|
||||
}
|
||||
|
||||
// ② 状态栏 / 导航条配色对齐主题,消除顶部和底部的异色条。
|
||||
try {
|
||||
const win: window.Window = windowStage.getMainWindowSync();
|
||||
this.mainWindow = win;
|
||||
// 顺手把窗口引用存进 AppStorage:页面侧要在 aboutToAppear 里**同步**读
|
||||
// 避让区(状态栏高度)来铺安全区,而 window.getLastWindow() 只有异步版。
|
||||
AppStorage.setOrCreate<window.Window>('fbMainWindow', win);
|
||||
this.applySystemBars(win);
|
||||
} catch (err) {
|
||||
hilog.error(DOMAIN, 'FamilyBook', 'applySystemBars failed: %{public}s', JSON.stringify(err));
|
||||
}
|
||||
|
||||
windowStage.loadContent('pages/Index', (err) => {
|
||||
if (err.code) {
|
||||
hilog.error(DOMAIN, 'FamilyBook', 'loadContent failed: %{public}s', JSON.stringify(err));
|
||||
return;
|
||||
}
|
||||
hilog.info(DOMAIN, 'FamilyBook', '%{public}s', 'loadContent succeeded');
|
||||
});
|
||||
|
||||
// ③ 系统深浅色切换时重刷系统栏配色。
|
||||
// 用 applicationContext 的 environment 回调比 onConfigurationUpdate 更可靠
|
||||
// (后者在部分场景不回调,会出现「页面已变深色但状态栏还是浅色」)。
|
||||
try {
|
||||
const cb: EnvironmentCallback = {
|
||||
onConfigurationUpdated: (config: Configuration): void => {
|
||||
if (this.mainWindow !== undefined) {
|
||||
this.applySystemBars(this.mainWindow);
|
||||
}
|
||||
},
|
||||
onMemoryLevel: (level: AbilityConstant.MemoryLevel): void => {
|
||||
}
|
||||
};
|
||||
this.context.getApplicationContext().on('environment', cb);
|
||||
} catch (err) {
|
||||
hilog.error(DOMAIN, 'FamilyBook', 'watch environment failed: %{public}s', JSON.stringify(err));
|
||||
}
|
||||
}
|
||||
|
||||
/** 兜底:部分设备/场景只回调 UIAbility.onConfigurationUpdate */
|
||||
onConfigurationUpdate(): void {
|
||||
if (this.mainWindow !== undefined) {
|
||||
this.applySystemBars(this.mainWindow);
|
||||
}
|
||||
}
|
||||
|
||||
onWindowStageDestroy(): void {
|
||||
hilog.info(DOMAIN, 'FamilyBook', '%{public}s', 'Ability onWindowStageDestroy');
|
||||
}
|
||||
|
||||
onForeground(): void {
|
||||
hilog.info(DOMAIN, 'FamilyBook', '%{public}s', 'Ability onForeground');
|
||||
}
|
||||
|
||||
onBackground(): void {
|
||||
hilog.info(DOMAIN, 'FamilyBook', '%{public}s', 'Ability onBackground');
|
||||
}
|
||||
|
||||
/**
|
||||
* 取当前主题下 page_bg 的**真实取值**(经 dark/ 限定符解析)。
|
||||
*
|
||||
* ⚠️ 必须以「资源系统实际解析出的颜色」为准来刷系统栏 ——
|
||||
* 如果直接读 config.colorMode 判断,会在系统切换的那一帧读到旧值,
|
||||
* 结果状态栏与页面背景对不上。
|
||||
*/
|
||||
private themePageBg(): string {
|
||||
try {
|
||||
const c: number = this.context.resourceManager.getColorSync($r('app.color.page_bg').id) >>> 0;
|
||||
return '#' + c.toString(16).padStart(8, '0');
|
||||
} catch (err) {
|
||||
return '#F7F3EA';
|
||||
}
|
||||
}
|
||||
|
||||
/** 按背景亮度决定系统栏前景色(浅底用深字,深底用浅字) */
|
||||
private isDarkBg(bg: string): boolean {
|
||||
const s: string = bg.startsWith('#') ? bg.substring(1) : bg;
|
||||
if (s.length < 6) {
|
||||
return false;
|
||||
}
|
||||
const off: number = s.length >= 8 ? 2 : 0;
|
||||
const r: number = parseInt(s.substring(off, off + 2), 16);
|
||||
const g: number = parseInt(s.substring(off + 2, off + 4), 16);
|
||||
const b: number = parseInt(s.substring(off + 4, off + 6), 16);
|
||||
if (isNaN(r) || isNaN(g) || isNaN(b)) {
|
||||
return false;
|
||||
}
|
||||
return (0.2126 * r + 0.7152 * g + 0.0722 * b) < 128;
|
||||
}
|
||||
|
||||
/** 状态栏/导航条底色 = 页面背景色,前景按亮度取反 —— 视觉上与页面连成一片 */
|
||||
private applySystemBars(win: window.Window): void {
|
||||
const bg: string = this.themePageBg();
|
||||
const fg: string = this.isDarkBg(bg) ? '#FFFFFF' : '#2C231D';
|
||||
try {
|
||||
win.setWindowSystemBarProperties({
|
||||
statusBarColor: bg,
|
||||
statusBarContentColor: fg,
|
||||
navigationBarColor: bg,
|
||||
navigationBarContentColor: fg
|
||||
});
|
||||
} catch (err) {
|
||||
hilog.error(DOMAIN, 'FamilyBook', 'setSystemBarProperties failed: %{public}s',
|
||||
JSON.stringify(err));
|
||||
}
|
||||
// ⚠️ 这里**不再调** `win.setWindowBackgroundColor()` —— 该 API 对
|
||||
// phone / tablet 不可用(编译器告警 "This API is unavailable to phone, tablet."),
|
||||
// 而页面自身(Index.ets 的安全区 Column)已经用同色铺满整屏,
|
||||
// 窗口底色这层是多余的。留着只会带来一条无意义的告警。
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { BackupExtensionAbility, BundleVersion } from '@kit.CoreFileKit';
|
||||
|
||||
const DOMAIN: number = 0x0000;
|
||||
|
||||
/**
|
||||
* 家书 · 备份/恢复扩展。
|
||||
*
|
||||
* 只声明能力,不做额外处理(`backup_config.json` 里已允许备份与恢复)。
|
||||
* 数据一旦接入接口,这里应当是「清缓存、重置登录态」的地方。
|
||||
*/
|
||||
export default class EntryBackupAbility extends BackupExtensionAbility {
|
||||
async onBackup(): Promise<void> {
|
||||
hilog.info(DOMAIN, 'FamilyBook', '%{public}s', 'onBackup ok');
|
||||
}
|
||||
|
||||
async onRestore(bundleVersion: BundleVersion): Promise<void> {
|
||||
hilog.info(DOMAIN, 'FamilyBook', 'onRestore ok %{public}s', JSON.stringify(bundleVersion));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 演示数据(鸿蒙端)
|
||||
*
|
||||
* ⚠️ 本文件是**临时占位**:接口还没做,页面要能看出长什么样,所以这里造了一批
|
||||
* 结构完整的假数据。接接口时删掉本文件即可 —— 页面只依赖 `model/Types.ets`
|
||||
* 里的类型,不依赖这些常量本身。
|
||||
*
|
||||
* ⚠️ 数据刻意照 Web 端的真实形态造(首页 12 个模块卡、观影有海报位、
|
||||
* 旅程分「计划/实际」两阶段的行程段、任务按看板分组带逾期标记…),
|
||||
* 这样「页面该长什么样」的判断才站得住,而不是凭空拍。
|
||||
*
|
||||
* ⚠️ 全文件统一 `Fb` 前缀命名,避免与其他文件顶层名冲突(arkts-no-decl-merging)。
|
||||
*/
|
||||
|
||||
import { FbAccount, FbAnniversaryItem, FbBookItem, FbCalBook, FbCalEvent, FbGroup,
|
||||
FbKinChip, FbKinRelation, FbLedgerBook, FbLedgerEntry, FbModuleCard, FbMovieItem,
|
||||
FbNavLink, FbNoteItem, FbNotificationItem, FbResourceItem, FbRosterItem, FbSession,
|
||||
FbTaskItem, FbTripItem, FbTripNode, FbWeather, FbWeekStat } from './Types';
|
||||
|
||||
/** 假数据里的「我」 */
|
||||
export const FB_DEMO_ME: FbAccount = {
|
||||
id: 1,
|
||||
name: '杨永全',
|
||||
kinship: '我',
|
||||
avatarColor: '#A84A28',
|
||||
role: 'admin',
|
||||
isGuest: false,
|
||||
canWrite: true
|
||||
};
|
||||
|
||||
/** 假会话 */
|
||||
export const FB_DEMO_SESSION: FbSession = {
|
||||
loggedIn: true,
|
||||
familyId: 1,
|
||||
familyTitle: '杨家',
|
||||
slogan: '记录家庭的全部生活,一本用岁月写成的家谱',
|
||||
me: FB_DEMO_ME
|
||||
};
|
||||
|
||||
/** 造一个头像 chip 的快捷函数(少写重复字面量) */
|
||||
function fbChip(id: number, shown: string, kinship: string, color: string): FbKinChip {
|
||||
return { id: id, shown: shown, kinship: kinship, avatarColor: color, isAdmin: false };
|
||||
}
|
||||
|
||||
/** 首页「我的家人」 */
|
||||
export const FB_DEMO_KIN: FbKinChip[] = [
|
||||
fbChip(2, '爱人', '配偶', '#9A5C7A'),
|
||||
fbChip(3, '儿子', '儿子', '#4A7C82'),
|
||||
fbChip(4, '女儿', '女儿', '#6B5B95'),
|
||||
fbChip(5, '爸爸', '父亲', '#8A6B4B'),
|
||||
fbChip(6, '妈妈', '母亲', '#B0763A'),
|
||||
fbChip(7, '弟弟', '弟弟', '#3F7D5A')
|
||||
];
|
||||
|
||||
/** 首页天气 */
|
||||
export const FB_DEMO_WEATHER: FbWeather = {
|
||||
place: '青岛市 · 崂山区',
|
||||
temp: 21,
|
||||
text: '多云',
|
||||
wind: '东南风 3 级'
|
||||
};
|
||||
|
||||
/** 首页近七天统计 */
|
||||
export const FB_DEMO_WEEK_STAT: FbWeekStat = {
|
||||
events: 11,
|
||||
todos: 4,
|
||||
overdue: 1
|
||||
};
|
||||
|
||||
/** 首页日子标记 */
|
||||
export const FB_DEMO_MARKS: string[] = ['寒露', '重阳节', '儿子生日'];
|
||||
|
||||
/** 首页模块卡片(顺序与 Web 端 Module::DEFAULT_NAV 一致) */
|
||||
export const FB_DEMO_MODULE_CARDS: FbModuleCard[] = [
|
||||
{ key: 'people', name: '名册', big: '12 人', lines: ['1089 人在这个家里'], color: '#4A7C82' },
|
||||
{ key: 'movies', name: '观影', big: '86 部', lines: ['最近看了《漫长的季节》'], color: '#7A4E8C' },
|
||||
{ key: 'trips', name: '旅程', big: '9 次', lines: ['最近去了青岛·崂山'], color: '#3F7D5A' },
|
||||
{ key: 'calendar', name: '日程', big: '11 条', lines: ['未来七天'], color: '#B0763A' },
|
||||
{ key: 'resources', name: '资源', big: '37 项', lines: ['证件 / 资料 / 附件'], color: '#5566A8' },
|
||||
{ key: 'tasks', name: '任务', big: '3 件在做', lines: ['4 件待办 · 1 件逾期'], color: '#A8574F' },
|
||||
{ key: 'days', name: '纪念日', big: '6 天后', lines: ['结婚周年'], color: '#B4633C' },
|
||||
{ key: 'ledger', name: '财务', big: '¥86.4 万', lines: ['3 本账(净资产)'], color: '#5E7A3C' },
|
||||
{ key: 'books', name: '读书', big: '2 本在读', lines: ['31 本读完 · 48 条笔记'], color: '#6B5B95' },
|
||||
{ key: 'nav', name: '导航', big: '24 个', lines: ['我的常用站点'], color: '#3E7A9E' },
|
||||
{ key: 'notes', name: '笔记', big: '63 条', lines: ['最近:阳台改造清单'], color: '#7A6A4F' }
|
||||
];
|
||||
|
||||
// ============================================================
|
||||
// 名册
|
||||
// ============================================================
|
||||
|
||||
export const FB_DEMO_ROSTER: FbRosterItem[] = [
|
||||
{ id: 1, name: '杨永全', kinship: '我', group: '本家', tags: ['管理员'], avatarColor: '#A84A28', roster: 'member', birthday: '03-12', isAdmin: true },
|
||||
{ id: 2, name: '李慧', kinship: '配偶', group: '本家', tags: [], avatarColor: '#9A5C7A', roster: 'member', birthday: '07-08', isAdmin: false },
|
||||
{ id: 3, name: '杨小满', kinship: '儿子', group: '本家', tags: ['学生'], avatarColor: '#4A7C82', roster: 'member', birthday: '10-18', isAdmin: false },
|
||||
{ id: 4, name: '杨小禾', kinship: '女儿', group: '本家', tags: ['学生'], avatarColor: '#6B5B95', roster: 'member', birthday: '01-04', isAdmin: false },
|
||||
{ id: 5, name: '杨建国', kinship: '父亲', group: '长辈', tags: ['退休'], avatarColor: '#8A6B4B', roster: 'member', birthday: '05-21', isAdmin: false },
|
||||
{ id: 6, name: '王秀兰', kinship: '母亲', group: '长辈', tags: [], avatarColor: '#B0763A', roster: 'member', birthday: '11-30', isAdmin: false },
|
||||
{ id: 7, name: '杨永强', kinship: '弟弟', group: '本家', tags: [], avatarColor: '#3F7D5A', roster: 'member', birthday: '08-15', isAdmin: false },
|
||||
{ id: 8, name: '李国栋', kinship: '岳父', group: '姻亲', tags: [], avatarColor: '#5566A8', roster: 'member', birthday: '02-26', isAdmin: false },
|
||||
{ id: 9, name: '陈静', kinship: '岳母', group: '姻亲', tags: [], avatarColor: '#A8574F', roster: 'member', birthday: '09-09', isAdmin: false },
|
||||
{ id: 10, name: '张伟', kinship: '朋友', group: '亲友', tags: ['同事'], avatarColor: '#5E7A3C', roster: 'friend', birthday: '06-14', isAdmin: false },
|
||||
{ id: 11, name: '刘敏', kinship: '朋友', group: '亲友', tags: ['同学'], avatarColor: '#7A6A4F', roster: 'friend', birthday: '12-02', isAdmin: false },
|
||||
{ id: 12, name: '赵鹏', kinship: '朋友', group: '亲友', tags: ['邻居'], avatarColor: '#3D6B8E', roster: 'friend', birthday: '04-17', isAdmin: false }
|
||||
];
|
||||
|
||||
/** 家谱连线(父母 / 配偶 / 子女) */
|
||||
export const FB_DEMO_KIN_RELATIONS: FbKinRelation[] = [
|
||||
{ id: 1, fromId: 1, fromName: '杨永全', toId: 5, toName: '杨建国', kind: 'parent', label: '父亲' },
|
||||
{ id: 2, fromId: 1, fromName: '杨永全', toId: 6, toName: '王秀兰', kind: 'parent', label: '母亲' },
|
||||
{ id: 3, fromId: 1, fromName: '杨永全', toId: 2, toName: '李慧', kind: 'spouse', label: '配偶' },
|
||||
{ id: 4, fromId: 1, fromName: '杨永全', toId: 3, toName: '杨小满', kind: 'child', label: '儿子' },
|
||||
{ id: 5, fromId: 1, fromName: '杨永全', toId: 4, toName: '杨小禾', kind: 'child', label: '女儿' },
|
||||
{ id: 6, fromId: 1, fromName: '杨永全', toId: 7, toName: '杨永强', kind: 'child', label: '弟弟' },
|
||||
{ id: 7, fromId: 2, fromName: '李慧', toId: 8, toName: '李国栋', kind: 'parent', label: '岳父' },
|
||||
{ id: 8, fromId: 2, fromName: '李慧', toId: 9, toName: '陈静', kind: 'parent', label: '岳母' }
|
||||
];
|
||||
|
||||
// ============================================================
|
||||
// 观影
|
||||
// ============================================================
|
||||
|
||||
export const FB_DEMO_MOVIES: FbMovieItem[] = [
|
||||
{ id: 1, title: '漫长的季节', watchedAt: '2026-10-05 19:30', cinemaName: '家里', cinemaColor: '#8A6B4B', rating: 9.2, reviewCount: 3, viewers: [fbChip(1, '我', '我', '#A84A28'), fbChip(2, '爱人', '配偶', '#9A5C7A')], hasPoster: true },
|
||||
{ id: 2, title: '流浪地球 3', watchedAt: '2026-09-28 14:00', cinemaName: '万达影城·李沧店', cinemaColor: '#5566A8', rating: 8.6, reviewCount: 2, viewers: [fbChip(1, '我', '我', '#A84A28'), fbChip(3, '儿子', '儿子', '#4A7C82'), fbChip(4, '女儿', '女儿', '#6B5B95')], hasPoster: true },
|
||||
{ id: 3, title: '飞屋环游记', watchedAt: '2026-09-20 20:00', cinemaName: '家里', cinemaColor: '#8A6B4B', rating: 9.0, reviewCount: 1, viewers: [fbChip(4, '女儿', '女儿', '#6B5B95')], hasPoster: false },
|
||||
{ id: 4, title: '奥本海默', watchedAt: '2026-09-12 19:10', cinemaName: 'CGV·市南店', cinemaColor: '#A8574F', rating: 8.9, reviewCount: 4, viewers: [fbChip(1, '我', '我', '#A84A28'), fbChip(2, '爱人', '配偶', '#9A5C7A')], hasPoster: true },
|
||||
{ id: 5, title: '海蒂和爷爷', watchedAt: '2026-08-30 15:30', cinemaName: '家里', cinemaColor: '#8A6B4B', rating: 8.4, reviewCount: 2, viewers: [fbChip(4, '女儿', '女儿', '#6B5B95'), fbChip(3, '儿子', '儿子', '#4A7C82')], hasPoster: false },
|
||||
{ id: 6, title: '宇宙探索编辑部', watchedAt: '2026-08-16 21:00', cinemaName: '家里', cinemaColor: '#8A6B4B', rating: 8.1, reviewCount: 1, viewers: [fbChip(1, '我', '我', '#A84A28')], hasPoster: true }
|
||||
];
|
||||
|
||||
/** 观影影院(movies/cinemas) */
|
||||
export const FB_DEMO_CINEMAS: string[] = ['家里', '万达影城·李沧店', 'CGV·市南店', '横店影城', '猫眼线上'];
|
||||
|
||||
// ============================================================
|
||||
// 旅程
|
||||
// ============================================================
|
||||
|
||||
export const FB_DEMO_TRIPS: FbTripItem[] = [
|
||||
{ id: 1, title: '青岛·崂山 两日', sub: '青岛 · 2 天', dateRange: '2026-10-02 ~ 10-03', nodeCount: 8, doneCount: 8 },
|
||||
{ id: 2, title: '烟台海边周末', sub: '烟台 · 2 天', dateRange: '2026-08-15 ~ 08-16', nodeCount: 6, doneCount: 6 },
|
||||
{ id: 3, title: '国庆西安行', sub: '西安 · 4 天', dateRange: '2025-10-01 ~ 10-04', nodeCount: 12, doneCount: 12 },
|
||||
{ id: 4, title: '寒假哈尔滨', sub: '哈尔滨 · 5 天', dateRange: '2026-01-20 ~ 01-24', nodeCount: 0, doneCount: 0 }
|
||||
];
|
||||
|
||||
/** 一段旅程的行程(trips/show 时间轴;stage 分「计划 / 实际」) */
|
||||
export const FB_DEMO_TRIP_NODES: FbTripNode[] = [
|
||||
{ id: 1, title: '出发', when: '10-02 07:30', place: '家 → 青岛北站', stage: 'actual', status: 'done', mode: '自驾', expenseCent: 0 },
|
||||
{ id: 2, title: '抵达民宿', when: '10-02 10:20', place: '崂山区·沙子口民宿', stage: 'actual', status: 'done', mode: '自驾', expenseCent: 68000 },
|
||||
{ id: 3, title: '海边散步 + 午餐', when: '10-02 12:00', place: '仰口海水浴场', stage: 'actual', status: 'done', mode: '步行', expenseCent: 21800 },
|
||||
{ id: 4, title: '太清宫', when: '10-02 15:00', place: '崂山太清景区', stage: 'actual', status: 'done', mode: '自驾', expenseCent: 27000 },
|
||||
{ id: 5, title: '登山(巨峰)', when: '10-03 08:00', place: '崂山巨峰景区', stage: 'plan', status: 'done', mode: '自驾', expenseCent: 34000 },
|
||||
{ id: 6, title: '返程', when: '10-03 17:00', place: '青岛 → 家', stage: 'plan', status: 'done', mode: '自驾', expenseCent: 0 }
|
||||
];
|
||||
|
||||
// ============================================================
|
||||
// 日程
|
||||
// ============================================================
|
||||
|
||||
export const FB_DEMO_CAL_BOOKS: FbCalBook[] = [
|
||||
{ id: 1, name: '我的日程', color: '#A84A28', canWrite: true, visible: true, shared: false, derived: false },
|
||||
{ id: 2, name: '家庭日程', color: '#B0763A', canWrite: true, visible: true, shared: true, derived: false },
|
||||
{ id: 3, name: '观影', color: '#7A4E8C', canWrite: true, visible: true, shared: false, derived: true },
|
||||
{ id: 4, name: '旅行', color: '#3F7D5A', canWrite: true, visible: true, shared: false, derived: true },
|
||||
{ id: 5, name: '孩子的课表', color: '#4A7C82', canWrite: false, visible: false, shared: true, derived: false },
|
||||
{ id: 6, name: '节假日', color: '#C0533F', canWrite: false, visible: true, shared: false, derived: true }
|
||||
];
|
||||
|
||||
export const FB_DEMO_EVENTS_TODAY: FbCalEvent[] = [
|
||||
{ id: 1, title: '儿子数学兴趣班', date: '2026-10-11', time: '09:00', calName: '孩子的课表', color: '#4A7C82', allDay: false, place: '小区活动中心' },
|
||||
{ id: 2, title: '家庭大扫除', date: '2026-10-11', time: '10:30', calName: '家庭日程', color: '#B0763A', allDay: false, place: '家' },
|
||||
{ id: 3, title: '晚上看电影', date: '2026-10-11', time: '19:30', calName: '观影', color: '#7A4E8C', allDay: false, place: '家里' },
|
||||
{ id: 4, title: '妈妈体检预约', date: '2026-10-11', time: '', calName: '家庭日程', color: '#B0763A', allDay: true, place: '市立医院' }
|
||||
];
|
||||
|
||||
export const FB_DEMO_EVENTS_SOON: FbCalEvent[] = [
|
||||
{ id: 11, title: '女儿钢琴课', date: '10-12', time: '16:00', calName: '孩子的课表', color: '#4A7C82', allDay: false, place: '琴行' },
|
||||
{ id: 12, title: '结婚周年', date: '10-17', time: '', calName: '家庭日程', color: '#B0763A', allDay: true, place: '' },
|
||||
{ id: 13, title: '周末露营', date: '10-18', time: '08:00', calName: '家庭日程', color: '#B0763A', allDay: false, place: '仰口' },
|
||||
{ id: 14, title: '家长会', date: '10-20', time: '18:30', calName: '孩子的课表', color: '#4A7C82', allDay: false, place: '学校' }
|
||||
];
|
||||
|
||||
// ============================================================
|
||||
// 任务
|
||||
// ============================================================
|
||||
|
||||
export const FB_DEMO_TASKS: FbTaskItem[] = [
|
||||
{ id: 1, title: '交物业费', status: 'doing', board: '家务', boardColor: '#A8574F', dueText: '10-13 前', overdue: false, assignee: '我', childDone: 0, childTotal: 0 },
|
||||
{ id: 2, title: '预约明年体检', status: 'doing', board: '家人健康', boardColor: '#5E7A3C', dueText: '10-20 前', overdue: false, assignee: '爱人', childDone: 1, childTotal: 3 },
|
||||
{ id: 3, title: '整理去年旅行照片', status: 'doing', board: '家务', boardColor: '#A8574F', dueText: '', overdue: false, assignee: '我', childDone: 0, childTotal: 0 },
|
||||
{ id: 4, title: '换季衣物收纳', status: 'todo', board: '家务', boardColor: '#A8574F', dueText: '10-09 前', overdue: true, assignee: '爱人', childDone: 0, childTotal: 0 },
|
||||
{ id: 5, title: '给儿子买秋季校服', status: 'todo', board: '孩子', boardColor: '#4A7C82', dueText: '10-15 前', overdue: false, assignee: '我', childDone: 0, childTotal: 0 },
|
||||
{ id: 6, title: '车保养', status: 'todo', board: '车与房', boardColor: '#5566A8', dueText: '', overdue: false, assignee: '', childDone: 0, childTotal: 0 },
|
||||
{ id: 7, title: '读完《置身事内》', status: 'done', board: '家务', boardColor: '#A8574F', dueText: '', overdue: false, assignee: '我', childDone: 0, childTotal: 0 }
|
||||
];
|
||||
|
||||
/** 任务看板(tasks/settings) */
|
||||
export const FB_DEMO_TASK_BOARDS: string[] = ['家务', '家人健康', '孩子', '车与房'];
|
||||
|
||||
// ============================================================
|
||||
// 纪念日
|
||||
// ============================================================
|
||||
|
||||
export const FB_DEMO_ANNIVERSARIES: FbAnniversaryItem[] = [
|
||||
{ id: 1, title: '结婚周年', date: '10-17', daysLeft: 6, kind: 'anniversary', people: [fbChip(1, '我', '我', '#A84A28'), fbChip(2, '爱人', '配偶', '#9A5C7A')], yearly: true },
|
||||
{ id: 2, title: '儿子生日', date: '10-18', daysLeft: 7, kind: 'birthday', people: [fbChip(3, '儿子', '儿子', '#4A7C82')], yearly: true },
|
||||
{ id: 3, title: '重阳节', date: '10-29', daysLeft: 18, kind: 'holiday', people: [], yearly: true },
|
||||
{ id: 4, title: '爸爸生日', date: '05-21', daysLeft: 222, kind: 'birthday', people: [fbChip(5, '爸爸', '父亲', '#8A6B4B')], yearly: true },
|
||||
{ id: 5, title: '搬家纪念日', date: '2019-11-08', daysLeft: 28, kind: 'anniversary', people: [], yearly: true }
|
||||
];
|
||||
|
||||
// ============================================================
|
||||
// 财务
|
||||
// ============================================================
|
||||
|
||||
export const FB_DEMO_LEDGER_BOOKS: FbLedgerBook[] = [
|
||||
{ id: 1, name: '家庭总账', netCent: 86420000, myPerm: 'owner', entryCount: 9 },
|
||||
{ id: 2, name: '我的私房账', netCent: 1260000, myPerm: 'owner', entryCount: 3 },
|
||||
{ id: 3, name: '爸妈的养老账', netCent: 4580000, myPerm: 'write', entryCount: 5 }
|
||||
];
|
||||
|
||||
export const FB_DEMO_LEDGER_ENTRIES: FbLedgerEntry[] = [
|
||||
{ id: 1, name: '工资卡', kind: 'deposit', amountCent: 18600000, ownerName: '我', updatedAt: '2026-10-01' },
|
||||
{ id: 2, name: '公积金', kind: 'deposit', amountCent: 24300000, ownerName: '我', updatedAt: '2026-10-01' },
|
||||
{ id: 3, name: '指数基金', kind: 'fund', amountCent: 34800000, ownerName: '我', updatedAt: '2026-09-30' },
|
||||
{ id: 4, name: '重疾险', kind: 'insurance', amountCent: 500000, ownerName: '爱人', updatedAt: '2026-08-12' },
|
||||
{ id: 5, name: '自住房', kind: 'house', amountCent: 180000000, ownerName: '我们', updatedAt: '2026-06-20' },
|
||||
{ id: 6, name: '代步车', kind: 'car', amountCent: 8200000, ownerName: '我', updatedAt: '2026-06-20' },
|
||||
{ id: 7, name: '房贷余额', kind: 'debt', amountCent: -142600000, ownerName: '我们', updatedAt: '2026-10-01' },
|
||||
{ id: 8, name: '信用卡待还', kind: 'debt', amountCent: -3860000, ownerName: '爱人', updatedAt: '2026-10-08' },
|
||||
{ id: 9, name: '车位', kind: 'house', amountCent: 12000000, ownerName: '我们', updatedAt: '2026-06-20' }
|
||||
];
|
||||
|
||||
// ============================================================
|
||||
// 资源
|
||||
// ============================================================
|
||||
|
||||
export const FB_DEMO_RESOURCES: FbResourceItem[] = [
|
||||
{ id: 1, name: '户口本', kind: 'document', ownerName: '全家', fileCount: 2, shared: true, updatedAt: '2026-09-14' },
|
||||
{ id: 2, name: '房产证', kind: 'document', ownerName: '我们', fileCount: 3, shared: false, updatedAt: '2026-07-02' },
|
||||
{ id: 3, name: '儿子学籍资料', kind: 'material', ownerName: '儿子', fileCount: 6, shared: true, updatedAt: '2026-09-01' },
|
||||
{ id: 4, name: '全家保险合同', kind: 'document', ownerName: '全家', fileCount: 4, shared: true, updatedAt: '2026-08-12' },
|
||||
{ id: 5, name: '装修图纸', kind: 'material', ownerName: '我', fileCount: 12, shared: false, updatedAt: '2026-05-19' },
|
||||
{ id: 6, name: '车险单', kind: 'document', ownerName: '我', fileCount: 1, shared: false, updatedAt: '2026-04-08' }
|
||||
];
|
||||
|
||||
// ============================================================
|
||||
// 读书
|
||||
// ============================================================
|
||||
|
||||
export const FB_DEMO_BOOKS: FbBookItem[] = [
|
||||
{ id: 1, title: '置身事内', author: '兰小欢', status: 'reading', rate: 5, noteCount: 12, progress: 62 },
|
||||
{ id: 2, title: '地球的红飘带', author: '魏巍', status: 'reading', rate: 4, noteCount: 5, progress: 28 },
|
||||
{ id: 3, title: '蛤蟆先生去看心理医生', author: '罗伯特·戴博德', status: 'done', rate: 5, noteCount: 9, progress: 100 },
|
||||
{ id: 4, title: '一日三秋', author: '刘震云', status: 'done', rate: 4, noteCount: 7, progress: 100 },
|
||||
{ id: 5, title: '显微镜下的大明', author: '马伯庸', status: 'done', rate: 5, noteCount: 15, progress: 100 },
|
||||
{ id: 6, title: '也许你该找个人聊聊', author: '洛莉·戈特利布', status: 'want', rate: 0, noteCount: 0, progress: 0 }
|
||||
];
|
||||
|
||||
// ============================================================
|
||||
// 导航
|
||||
// ============================================================
|
||||
|
||||
export const FB_DEMO_NAV: FbNavLink[] = [
|
||||
{ id: 1, title: 'Gitea 代码', url: 'https://code.yangyq.net', group: '开发', icon: '' },
|
||||
{ id: 2, title: '阿里云控制台', url: 'https://ecs.console.aliyun.com', group: '开发', icon: '' },
|
||||
{ id: 3, title: '腾讯云文档', url: 'https://docs.qq.com', group: '办公', icon: '' },
|
||||
{ id: 4, title: '学校 OA', url: 'https://oa.example.edu.cn', group: '办公', icon: '' },
|
||||
{ id: 5, title: '豆瓣', url: 'https://www.douban.com', group: '常用', icon: '' },
|
||||
{ id: 6, title: '高德地图', url: 'https://www.amap.com', group: '常用', icon: '' },
|
||||
{ id: 7, title: '知乎', url: 'https://www.zhihu.com', group: '常用', icon: '' },
|
||||
{ id: 8, title: '国家气象中心', url: 'http://www.nmc.cn', group: '科研', icon: '' }
|
||||
];
|
||||
|
||||
// ============================================================
|
||||
// 笔记
|
||||
// ============================================================
|
||||
|
||||
export const FB_DEMO_NOTES: FbNoteItem[] = [
|
||||
{ id: 1, title: '阳台改造清单', excerpt: '1. 换推拉门(断桥铝,考虑隔音)2. 加一组置物架 3. 补两盆绿萝…', tags: ['家务', '装修'], updatedAt: '10-11 09:12', shared: false, attachCount: 2 },
|
||||
{ id: 2, title: '读书摘抄 · 置身事内', excerpt: '「地方政府的行为,要从它面对的制度约束去理解。」……', tags: ['读书'], updatedAt: '10-10 22:40', shared: true, attachCount: 0 },
|
||||
{ id: 3, title: '儿子的兴趣班时间表', excerpt: '周一 16:30 数学;周三 17:00 游泳;周六 09:00 钢琴…', tags: ['孩子'], updatedAt: '10-09 18:05', shared: true, attachCount: 1 },
|
||||
{ id: 4, title: '崂山两日流水', excerpt: '第一天仰口 → 太清宫;第二天巨峰。民宿 680,两餐 218…', tags: ['旅行', '记账'], updatedAt: '10-04 20:30', shared: false, attachCount: 6 },
|
||||
{ id: 5, title: '车里该备的东西', excerpt: '拖车绳、搭电线、应急灯、保温毯、常用药…', tags: ['车'], updatedAt: '09-25 07:48', shared: false, attachCount: 0 }
|
||||
];
|
||||
|
||||
// ============================================================
|
||||
// 通知
|
||||
// ============================================================
|
||||
|
||||
export const FB_DEMO_NOTIFICATIONS: FbNotificationItem[] = [
|
||||
{ id: 1, title: '爱人共享了「家庭日程」', body: '你现在可以看到这本日历的全部内容', timeText: '12 分钟前', unread: true, from: fbChip(2, '爱人', '配偶', '#9A5C7A') },
|
||||
{ id: 2, title: '任务「换季衣物收纳」已逾期', body: '截止时间 10-09,现在还没完成', timeText: '2 小时前', unread: true, from: fbChip(2, '爱人', '配偶', '#9A5C7A') },
|
||||
{ id: 3, title: '弟弟 在「观影」里加了一条', body: '《宇宙探索编辑部》', timeText: '昨天 21:04', unread: false, from: fbChip(7, '弟弟', '弟弟', '#3F7D5A') },
|
||||
{ id: 4, title: '爸爸生日还有 222 天', body: '05-21,要不要提前记一下礼物', timeText: '3 天前', unread: false, from: fbChip(5, '爸爸', '父亲', '#8A6B4B') }
|
||||
];
|
||||
|
||||
// ============================================================
|
||||
// 分组工具(把平铺数组按某个字段切成 FbGroup)
|
||||
// ============================================================
|
||||
|
||||
/** 按 roster 分组(名册页:家里的人 / 亲友) */
|
||||
export function fbGroupRoster(list: FbRosterItem[]): FbGroup<FbRosterItem>[] {
|
||||
return fbGroupBy(list, (it: FbRosterItem): string => it.group);
|
||||
}
|
||||
|
||||
/** 按 status 分组建任务(待办 / 进行中 / 已完成) */
|
||||
export function fbGroupTask(list: FbTaskItem[]): FbGroup<FbTaskItem>[] {
|
||||
const order: string[] = ['doing', 'todo', 'done'];
|
||||
const titles: string[] = ['进行中', '待办', '已完成'];
|
||||
const groups: FbGroup<FbTaskItem>[] = [];
|
||||
for (let i = 0; i < order.length; i++) {
|
||||
const key: string = order[i];
|
||||
const items: FbTaskItem[] = list.filter((t: FbTaskItem): boolean => t.status === key);
|
||||
if (items.length > 0) {
|
||||
groups.push({ key: key, title: titles[i], items: items, count: items.length.toString() + ' 项' });
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** 按 kind 分组建资源(证件 / 资料 / 其他) */
|
||||
export function fbGroupResource(list: FbResourceItem[]): FbGroup<FbResourceItem>[] {
|
||||
return fbGroupBy(list, (it: FbResourceItem): string => fbResourceKindName(it.kind));
|
||||
}
|
||||
|
||||
/** 按 status 分组建书架(在读 / 想读 / 读完) */
|
||||
export function fbGroupBook(list: FbBookItem[]): FbGroup<FbBookItem>[] {
|
||||
const order: string[] = ['reading', 'want', 'done'];
|
||||
const titles: string[] = ['在读', '想读', '读完'];
|
||||
const groups: FbGroup<FbBookItem>[] = [];
|
||||
for (let i = 0; i < order.length; i++) {
|
||||
const key: string = order[i];
|
||||
const items: FbBookItem[] = list.filter((b: FbBookItem): boolean => b.status === key);
|
||||
if (items.length > 0) {
|
||||
groups.push({ key: key, title: titles[i], items: items, count: items.length.toString() + ' 本' });
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** 按 group 分组建导航 */
|
||||
export function fbGroupNav(list: FbNavLink[]): FbGroup<FbNavLink>[] {
|
||||
return fbGroupBy(list, (it: FbNavLink): string => it.group);
|
||||
}
|
||||
|
||||
/** 通用分组:保持首次出现的顺序(不做字典序,免得打乱业务排序) */
|
||||
function fbGroupBy<T>(list: T[], keyOf: (it: T) => string): FbGroup<T>[] {
|
||||
const keys: string[] = [];
|
||||
const buckets: T[][] = [];
|
||||
for (const it of list) {
|
||||
const k: string = keyOf(it);
|
||||
let idx: number = keys.indexOf(k);
|
||||
if (idx < 0) {
|
||||
keys.push(k);
|
||||
buckets.push([]);
|
||||
idx = keys.length - 1;
|
||||
}
|
||||
buckets[idx].push(it);
|
||||
}
|
||||
const out: FbGroup<T>[] = [];
|
||||
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;
|
||||
}
|
||||
|
||||
/** 资源类别的中文名 */
|
||||
export function fbResourceKindName(kind: string): string {
|
||||
if (kind === 'document') {
|
||||
return '证件';
|
||||
}
|
||||
if (kind === 'material') {
|
||||
return '资料';
|
||||
}
|
||||
return '其他';
|
||||
}
|
||||
|
||||
/** 账目类别的中文名 */
|
||||
export function fbLedgerKindName(kind: string): string {
|
||||
if (kind === 'deposit') {
|
||||
return '存款';
|
||||
}
|
||||
if (kind === 'fund') {
|
||||
return '基金';
|
||||
}
|
||||
if (kind === 'insurance') {
|
||||
return '保险';
|
||||
}
|
||||
if (kind === 'house') {
|
||||
return '房产';
|
||||
}
|
||||
if (kind === 'car') {
|
||||
return '车辆';
|
||||
}
|
||||
if (kind === 'debt') {
|
||||
return '负债';
|
||||
}
|
||||
return '其他';
|
||||
}
|
||||
|
||||
/** 分 → 「¥12.3 万」这样的中文金额(Web 端 Ledger::money 同口径) */
|
||||
export function fbMoney(cent: number): string {
|
||||
const yuan: number = cent / 100;
|
||||
const neg: boolean = yuan < 0;
|
||||
const abs: number = Math.abs(yuan);
|
||||
let text: string;
|
||||
if (abs >= 100000000) {
|
||||
text = (abs / 100000000).toFixed(2) + ' 亿';
|
||||
} else if (abs >= 10000) {
|
||||
text = (abs / 10000).toFixed(2) + ' 万';
|
||||
} else {
|
||||
text = abs.toFixed(2);
|
||||
}
|
||||
return (neg ? '-¥' : '¥') + text;
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 模块注册中心(鸿蒙端)
|
||||
*
|
||||
* 与 Web 端 `app/Module.php` 一一对应 —— 那边是「有哪些模块 / 谁在导航条上 /
|
||||
* 各自什么主题色」的**唯一来源**,客户端这份是它在 ArkTS 侧的镜像。
|
||||
*
|
||||
* 三条分工(与 Web 端一致,别混):
|
||||
* · **模块本身**(有哪些、叫什么、默认什么色)→ 这里的 FB_MODULES,写死在代码里;
|
||||
* · **谁在抽屉里 / 什么顺序** → 后续接到接口后由 user_settings('nav_modules') 决定,
|
||||
* 没配过就用 DEFAULT_NAV;
|
||||
* · **主题色** → 后续由 settings('module_colors')(家庭级)下发。
|
||||
*
|
||||
* ⚠️ key 必须等于 Web 端路由名(r=xxx):接口对接、页面高亮、「本页属于哪个模块」
|
||||
* 全都按 key 走,两边各写一份迟早对不上。
|
||||
*
|
||||
* ⚠️ 命名前缀:ArkTS 不支持声明合并(arkts-no-decl-merging),不同文件的顶层
|
||||
* interface/class/struct 名不能重复。本文件所有导出名统一加 `Fb` 前缀。
|
||||
*/
|
||||
|
||||
/** 一个功能模块的静态定义 */
|
||||
export interface FbModuleDef {
|
||||
/** 模块 key,等于 Web 端路由名(home / kin / people / …) */
|
||||
key: string;
|
||||
/** 抽屉里显示的名字 */
|
||||
name: string;
|
||||
/** 默认主题色(家庭级设置可覆盖) */
|
||||
color: string;
|
||||
/** 一句话说明(抽屉里那行小灰字) */
|
||||
desc: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部模块(顺序与 Web 端 `Module::LIST` 一致)。
|
||||
*
|
||||
* ⚠️ 主题色一律挑中低饱和、中低明度的:按钮上是白字,太浅会看不清,
|
||||
* 太艳会盖过内容的纸墨底色(家书整体是宣纸米 + 砖红的暖色调)。
|
||||
*/
|
||||
export const FB_MODULES: FbModuleDef[] = [
|
||||
{ key: 'home', name: '首页', color: '#A84A28', desc: '今天要看的东西' },
|
||||
{ key: 'kin', name: '家谱', color: '#8A6B4B', desc: '以我为中心的一张网' },
|
||||
{ key: 'people', name: '名册', color: '#4A7C82', desc: '家庭名册与亲友通讯录' },
|
||||
{ key: 'movies', name: '观影', color: '#7A4E8C', desc: '一家人看过的电影' },
|
||||
{ key: 'trips', name: '旅程', color: '#3F7D5A', desc: '去过的地方与行程' },
|
||||
{ key: 'calendar', name: '日程', color: '#B0763A', desc: '日历 / 重复日程 / 提醒' },
|
||||
{ key: 'resources', name: '资源', color: '#5566A8', desc: '证件 / 资料 / 附件' },
|
||||
{ key: 'tasks', name: '任务', color: '#A8574F', desc: '看板与待办' },
|
||||
{ key: 'days', name: '纪念日', color: '#B4633C', desc: '生日与重要日子' },
|
||||
{ key: 'ledger', name: '财务', color: '#5E7A3C', desc: '账本与账目' },
|
||||
{ key: 'books', name: '读书', color: '#6B5B95', desc: '书架与读书笔记' },
|
||||
{ key: 'nav', name: '导航', color: '#3E7A9E', desc: '我的个人导航' },
|
||||
{ key: 'notes', name: '笔记', color: '#7A6A4F', desc: '随手记的分块笔记' }
|
||||
];
|
||||
|
||||
/**
|
||||
* 默认出现在抽屉里的模块(没自定义过就用它)。
|
||||
*
|
||||
* ⚠️ 与 Web 端 `Module::DEFAULT_NAV` 保持一致:**家谱默认不上导航条**
|
||||
* (用户原话「毕竟,不需要每天都查看家谱」),但它仍在抽屉的「更多」里能勾出来。
|
||||
*/
|
||||
export const FB_DEFAULT_NAV: string[] = [
|
||||
'home', 'people', 'movies', 'trips', 'calendar',
|
||||
'resources', 'tasks', 'days', 'ledger', 'books', 'nav', 'notes'
|
||||
];
|
||||
|
||||
/**
|
||||
* 主题色的预设色板(设置页点一下就换)。
|
||||
*
|
||||
* ⚠️ 与 Web 端 `Module::PRESET_COLORS` 是**同一套值** —— 两端配色预设必须一致,
|
||||
* 否则同一个人在手机和电脑上选出的「观影紫」会是两种紫。
|
||||
* ⚠️ 一律中低饱和、中低明度:fbThemeVars 会把主色压到 L≈0.42 当按钮底色、
|
||||
* L≈0.28 当字色 —— 太浅则白字在按钮上看不清,太艳则盖过纸墨底的本色。
|
||||
*/
|
||||
export const FB_PRESET_COLORS: string[] = [
|
||||
'#A84A28', '#8A6B4B', '#4A7C82', '#7A4E8C', '#3F7D5A',
|
||||
'#B0763A', '#5566A8', '#A8574F', '#5C8A48', '#6B5B95',
|
||||
'#9A5C7A', '#3D6B8E'
|
||||
];
|
||||
|
||||
/** 按 key 取模块定义(认不出返回 undefined,调用方自己兜底) */
|
||||
export function fbModuleOf(key: string): FbModuleDef | undefined {
|
||||
return FB_MODULES.find((m: FbModuleDef): boolean => m.key === key);
|
||||
}
|
||||
|
||||
/** 模块名(认不出返回 key 本身,避免抽屉里出现空白条目) */
|
||||
export function fbModuleName(key: string): string {
|
||||
const m = fbModuleOf(key);
|
||||
return m !== undefined ? m.name : key;
|
||||
}
|
||||
|
||||
/** 模块主题色(认不出回落到首页砖红) */
|
||||
export function fbModuleColor(key: string): string {
|
||||
const m = fbModuleOf(key);
|
||||
return m !== undefined ? m.color : '#A84A28';
|
||||
}
|
||||
|
||||
/** 默认抽屉列表对应的模块定义数组(顺序照 FB_DEFAULT_NAV) */
|
||||
export function fbDefaultNavModules(): FbModuleDef[] {
|
||||
const out: FbModuleDef[] = [];
|
||||
for (const k of FB_DEFAULT_NAV) {
|
||||
const m = fbModuleOf(k);
|
||||
if (m !== undefined) {
|
||||
out.push(m);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把「#RRGGBB」压成三档同色系(与 Web 端 `Module::cssVars()` 同一算法)。
|
||||
*
|
||||
* 为什么要三档而不是只给一个主色:按钮 hover 色与文字色都是从主色推出来的
|
||||
* 深浅两档,只换主色会让 hover 变成别的颜色、或者白字压在浅底上看不清。
|
||||
* 这里统一按 HSL 压明度,保证三档永远同色系。
|
||||
*/
|
||||
export interface FbThemeVars {
|
||||
/** 按钮底色(L≈0.42) */
|
||||
brand: string;
|
||||
/** hover / 强调(L≈0.58) */
|
||||
brand2: string;
|
||||
/** 文字色(L≈0.28) */
|
||||
ink: string;
|
||||
}
|
||||
|
||||
/** hex → [h(0..1), s, l] */
|
||||
function fbHexToHsl(hex: string): number[] {
|
||||
const s: string = hex.startsWith('#') ? hex.substring(1) : hex;
|
||||
const r: number = parseInt(s.substring(0, 2), 16) / 255;
|
||||
const g: number = parseInt(s.substring(2, 4), 16) / 255;
|
||||
const b: number = parseInt(s.substring(4, 6), 16) / 255;
|
||||
const mx: number = Math.max(r, g, b);
|
||||
const mn: number = Math.min(r, g, b);
|
||||
const l: number = (mx + mn) / 2;
|
||||
const d: number = mx - mn;
|
||||
if (d === 0) {
|
||||
return [0, 0, l];
|
||||
}
|
||||
const den: number = 1 - Math.abs(2 * l - 1);
|
||||
const sat: number = den > 1e-9 ? d / den : 0;
|
||||
let h: number;
|
||||
if (mx === r) {
|
||||
h = ((g - b) / d) % 6;
|
||||
} else if (mx === g) {
|
||||
h = ((b - r) / d) + 2;
|
||||
} else {
|
||||
h = ((r - g) / d) + 4;
|
||||
}
|
||||
h = h * 60;
|
||||
if (h < 0) {
|
||||
h = h + 360;
|
||||
}
|
||||
return [h / 360, sat, l];
|
||||
}
|
||||
|
||||
/** [h,s,l] → '#rrggbb' */
|
||||
function fbHslToHex(h: number, sat: number, l: number): string {
|
||||
const hh: number = h - Math.floor(h);
|
||||
if (sat <= 0) {
|
||||
const v: number = Math.round(l * 255);
|
||||
return fbHex2(v) + fbHex2(v) + fbHex2(v);
|
||||
}
|
||||
const q: number = l < 0.5 ? l * (1 + sat) : l + sat - l * sat;
|
||||
const p: number = 2 * l - q;
|
||||
const conv = (t0: number): number => {
|
||||
let t: number = t0 - Math.floor(t0);
|
||||
if (t < 1 / 6) {
|
||||
return p + (q - p) * 6 * t;
|
||||
}
|
||||
if (t < 1 / 2) {
|
||||
return q;
|
||||
}
|
||||
if (t < 2 / 3) {
|
||||
return p + (q - p) * (2 / 3 - t) * 6;
|
||||
}
|
||||
return p;
|
||||
};
|
||||
const r: number = Math.max(0, Math.min(255, Math.round(conv(hh + 1 / 3) * 255)));
|
||||
const g: number = Math.max(0, Math.min(255, Math.round(conv(hh) * 255)));
|
||||
const b: number = Math.max(0, Math.min(255, Math.round(conv(hh - 1 / 3) * 255)));
|
||||
return fbHex2(r) + fbHex2(g) + fbHex2(b);
|
||||
}
|
||||
|
||||
/** 0..255 → 两位大写十六进制 */
|
||||
function fbHex2(v: number): string {
|
||||
const t: string = v.toString(16).toUpperCase();
|
||||
return t.length < 2 ? '0' + t : t;
|
||||
}
|
||||
|
||||
/** 把一个主题色铺成三档 CSS 变量(客户端直接当 ResourceColor 用) */
|
||||
export function fbThemeVars(hex: string): FbThemeVars {
|
||||
const hsl: number[] = fbHexToHsl(hex.startsWith('#') ? hex : '#A84A28');
|
||||
const h: number = hsl[0];
|
||||
const sat: number = Math.max(0.28, Math.min(0.72, hsl[1]));
|
||||
return {
|
||||
brand: '#' + fbHslToHex(h, sat, 0.42),
|
||||
brand2: '#' + fbHslToHex(h, sat, 0.58),
|
||||
ink: '#' + fbHslToHex(h, sat, 0.28)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* 家书 FamilyBook · 数据类型定义(鸿蒙端)
|
||||
*
|
||||
* ⚠️ 命名前缀:ArkTS 把同一 hap 内所有文件编译进**共享全局作用域**,
|
||||
* 顶层 interface/class 重名会被判定为「声明合并」直接报错
|
||||
* (arkts-no-decl-merging)。所以本文件所有类型统一加 `Fb` 前缀。
|
||||
*
|
||||
* ⚠️ 这份类型表是**照 Web 端页面实际渲染的字段**归纳出来的(读 templates/ 与
|
||||
* app/ 各自的取数逻辑得到),不是凭空设计的 —— 后续接接口时,服务端返回的
|
||||
* JSON 应当能直接映射到这些字段上。字段名尽量与 Web 端保持一致(如
|
||||
* `starts_at` 改成 `startsAt` 只是风格统一,语义不变)。
|
||||
*/
|
||||
|
||||
// ============================================================
|
||||
// 账号 / 家庭(阶段 0:名册、账号、登录、权限模型)
|
||||
// ============================================================
|
||||
|
||||
/** 一个家庭成员账号(对应 Web 端 `people` + `accounts`) */
|
||||
export interface FbAccount {
|
||||
/** persons.id */
|
||||
id: number;
|
||||
/** 姓名(Web 端首页显示的是"我叫他什么",这里先用本名) */
|
||||
name: string;
|
||||
/** 「我」对他的称呼(relations 派生,如「爸爸」「儿子」) */
|
||||
kinship: string;
|
||||
/** 头像色(Web 端 AvatarColor 算法得出) */
|
||||
avatarColor: string;
|
||||
/** 角色:admin / member / guest(Web 端 accounts.role) */
|
||||
role: string;
|
||||
/** 是否为只读访客(Web 端 Auth::isGuest) */
|
||||
isGuest: boolean;
|
||||
/** 是否有写权限(只读账号在页面上要隐藏所有新建/编辑入口) */
|
||||
canWrite: boolean;
|
||||
}
|
||||
|
||||
/** 当前登录会话 */
|
||||
export interface FbSession {
|
||||
/** 是否已登录 */
|
||||
loggedIn: boolean;
|
||||
/** 家庭 id */
|
||||
familyId: number;
|
||||
/** 家庭名(首页大标题 Auth::homeTitle()) */
|
||||
familyTitle: string;
|
||||
/** 家庭格言 / slogan */
|
||||
slogan: string;
|
||||
/** 当前登录者 */
|
||||
me: FbAccount;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 首页(对应 templates/home.php 的几个区块)
|
||||
// ============================================================
|
||||
|
||||
/** 首页顶部信息面板里的「日子标记」(节气 / 节日 / 纪念日 / 生日) */
|
||||
export interface FbTodayMark {
|
||||
/** 标签文案,如「处暑」「中秋」「结婚纪念」 */
|
||||
word: string;
|
||||
/** 类别:solar_term / festival / anniversary / birthday */
|
||||
kind: string;
|
||||
}
|
||||
|
||||
/** 天气(Web 端 Home::weather 三级回落:自定义经纬度 → 内置城市表 → 不显示) */
|
||||
export interface FbWeather {
|
||||
/** 地点名 */
|
||||
place: string;
|
||||
/** 温度(摄氏度,整数) */
|
||||
temp: number;
|
||||
/** 天气描述,如「晴」「多云」 */
|
||||
text: string;
|
||||
/** 风向风力,如「东南风 3 级」 */
|
||||
wind: string;
|
||||
}
|
||||
|
||||
/** 首页统计块(近七天) */
|
||||
export interface FbWeekStat {
|
||||
/** 未来七天日程条数 */
|
||||
events: number;
|
||||
/** 待办件数 */
|
||||
todos: number;
|
||||
/** 逾期件数 */
|
||||
overdue: number;
|
||||
}
|
||||
|
||||
/** 首页「我的家人」里的一个人(带称谓与称呼) */
|
||||
export interface FbKinChip {
|
||||
id: number;
|
||||
/** 显示名(优先用「我叫他什么」) */
|
||||
shown: string;
|
||||
/** 称谓 */
|
||||
kinship: string;
|
||||
/** 头像色 */
|
||||
avatarColor: string;
|
||||
/** 是否管理员(首页徽标) */
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
/** 首页「模块卡片」—— 一块一块的入口,带一句关键数字 */
|
||||
export interface FbModuleCard {
|
||||
/** 模块 key(点击后切到哪个模块) */
|
||||
key: string;
|
||||
/** 模块名 */
|
||||
name: string;
|
||||
/** 大字:关键数字,如「12 部」「3 件在做」 */
|
||||
big: string;
|
||||
/** 小字说明(可多行) */
|
||||
lines: string[];
|
||||
/** 模块主题色 */
|
||||
color: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 名册 / 家谱
|
||||
// ============================================================
|
||||
|
||||
/** 名册里的一个人(people/index.php 的行) */
|
||||
export interface FbRosterItem {
|
||||
id: number;
|
||||
name: string;
|
||||
/** 称谓 */
|
||||
kinship: string;
|
||||
/** 分组名(people_groups) */
|
||||
group: string;
|
||||
/** 标签(person_tags) */
|
||||
tags: string[];
|
||||
/** 头像色 */
|
||||
avatarColor: string;
|
||||
/** 归属:member(家里的人)/ friend(亲友通讯录) */
|
||||
roster: string;
|
||||
/** 生日('MM-DD' 或 '') */
|
||||
birthday: string;
|
||||
/** 是否管理员徽标 */
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
/** 家谱连线(kin/relate:父母 / 配偶 / 子女) */
|
||||
export interface FbKinRelation {
|
||||
id: number;
|
||||
/** 从谁出发 */
|
||||
fromId: number;
|
||||
fromName: string;
|
||||
/** 到谁 */
|
||||
toId: number;
|
||||
toName: string;
|
||||
/** 关系类型:parent / spouse / child */
|
||||
kind: string;
|
||||
/** 称谓(Kinship::label 算出,如「爷爷」「外甥」) */
|
||||
label: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 观影(movies)
|
||||
// ============================================================
|
||||
|
||||
/** 观影记录(movies/index.php 的海报卡片) */
|
||||
export interface FbMovieItem {
|
||||
id: number;
|
||||
/** 片名 */
|
||||
title: string;
|
||||
/** 观影时间显示文案(Movie::formatWatch),如「2026-10-05 19:30」 */
|
||||
watchedAt: string;
|
||||
/** 影院名(可为空) */
|
||||
cinemaName: string;
|
||||
/** 影院色标(movies/cinemas) */
|
||||
cinemaColor: string;
|
||||
/** 平均评分(0–10,0 表示没有评价) */
|
||||
rating: number;
|
||||
/** 评价条数 */
|
||||
reviewCount: number;
|
||||
/** 同看的人 */
|
||||
viewers: FbKinChip[];
|
||||
/** 海报是否已有(没有则画占位) */
|
||||
hasPoster: boolean;
|
||||
}
|
||||
|
||||
/** 观影统计(movies/stats) */
|
||||
export interface FbMovieStat {
|
||||
/** 每月一部柱状图的月份标签 */
|
||||
months: string[];
|
||||
/** 对应月份的数量 */
|
||||
counts: number[];
|
||||
/** 年度总览的关键数字 */
|
||||
total: number;
|
||||
/** 平均分 */
|
||||
avgRating: number;
|
||||
/** 最常去的影院 */
|
||||
topCinema: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 旅程(trips)
|
||||
// ============================================================
|
||||
|
||||
/** 旅程列表项(trips/index.php) */
|
||||
export interface FbTripItem {
|
||||
id: number;
|
||||
/** 旅程名 */
|
||||
title: string;
|
||||
/** 副标题:城市 / 天数 */
|
||||
sub: string;
|
||||
/** 时间范围显示文案(从各段行程派生) */
|
||||
dateRange: string;
|
||||
/** 行程段数 */
|
||||
nodeCount: number;
|
||||
/** 已完成段数 */
|
||||
doneCount: number;
|
||||
}
|
||||
|
||||
/** 旅程里的一段行程(trips/show 的时间轴节点) */
|
||||
export interface FbTripNode {
|
||||
id: number;
|
||||
/** 段标题,如「出发」「抵达民宿」 */
|
||||
title: string;
|
||||
/** 时间文案 */
|
||||
when: string;
|
||||
/** 地点 */
|
||||
place: string;
|
||||
/** 阶段:plan(计划)/ actual(实际) */
|
||||
stage: string;
|
||||
/** 状态:todo / done / skip */
|
||||
status: string;
|
||||
/** 交通方式(trips/modes) */
|
||||
mode: string;
|
||||
/** 花费(分) */
|
||||
expenseCent: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 日程(calendar)
|
||||
// ============================================================
|
||||
|
||||
/** 一条日程(calendar/index.php 的格子/时间轴条目) */
|
||||
export interface FbCalEvent {
|
||||
id: number;
|
||||
/** 标题 */
|
||||
title: string;
|
||||
/** 日期 'YYYY-MM-DD' */
|
||||
date: string;
|
||||
/** 时间 'HH:mm'(全天事件为空串) */
|
||||
time: string;
|
||||
/** 所属日历本名 */
|
||||
calName: string;
|
||||
/** 日历本色标 */
|
||||
color: string;
|
||||
/** 是否全天 */
|
||||
allDay: boolean;
|
||||
/** 地点(可为空) */
|
||||
place: string;
|
||||
}
|
||||
|
||||
/** 日历本(calendar/calendars) */
|
||||
export interface FbCalBook {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
/** 我能不能写(Calendar::canWrite) */
|
||||
canWrite: boolean;
|
||||
/** 是否显示(Calendar::hiddenCalendars 的反面) */
|
||||
visible: boolean;
|
||||
/** 是否共享给别人 */
|
||||
shared: boolean;
|
||||
/** 是否是从 events 派生的「观影」「旅行」两本默认本 */
|
||||
derived: boolean;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 任务(tasks)
|
||||
// ============================================================
|
||||
|
||||
/** 任务卡片(tasks/index.php 看板) */
|
||||
export interface FbTaskItem {
|
||||
id: number;
|
||||
title: string;
|
||||
/** 状态:todo(待办)/ doing(进行中)/ done(已完成) */
|
||||
status: string;
|
||||
/** 所属看板名 */
|
||||
board: string;
|
||||
/** 看板主题色 */
|
||||
boardColor: string;
|
||||
/** 截止时间文案(空表示没有期限,不进日历) */
|
||||
dueText: string;
|
||||
/** 是否逾期 */
|
||||
overdue: boolean;
|
||||
/** 负责人显示名(空表示未指派) */
|
||||
assignee: string;
|
||||
/** 子任务完成数 / 总数 */
|
||||
childDone: number;
|
||||
childTotal: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 纪念日(days)
|
||||
// ============================================================
|
||||
|
||||
/** 纪念日(days/index.php + person 生日) */
|
||||
export interface FbAnniversaryItem {
|
||||
id: number;
|
||||
title: string;
|
||||
/** 日期 'MM-DD' 或 'YYYY-MM-DD' */
|
||||
date: string;
|
||||
/** 距今还有几天(0 = 今天) */
|
||||
daysLeft: number;
|
||||
/** 类别:holiday(节日)/ anniversary(纪念日)/ birthday(生日)/ solar_term(节气) */
|
||||
kind: string;
|
||||
/** 相关的人(生日才有) */
|
||||
people: FbKinChip[];
|
||||
/** 是否每年重复 */
|
||||
yearly: boolean;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 财务(ledger)
|
||||
// ============================================================
|
||||
|
||||
/** 一本账(ledger/index.php 顶部概览) */
|
||||
export interface FbLedgerBook {
|
||||
id: number;
|
||||
name: string;
|
||||
/** 净资产(分) */
|
||||
netCent: number;
|
||||
/** 我的权限:owner / read / write */
|
||||
myPerm: string;
|
||||
/** 账目条数 */
|
||||
entryCount: number;
|
||||
}
|
||||
|
||||
/** 一条账目 */
|
||||
export interface FbLedgerEntry {
|
||||
id: number;
|
||||
/** 账目名,如「工资卡」「房贷」 */
|
||||
name: string;
|
||||
/** 类别:deposit(存款)/ fund(基金)/ insurance(保险)/ house(房子)/ car(车子)/ debt(负债)… */
|
||||
kind: string;
|
||||
/** 金额(分;负债为负) */
|
||||
amountCent: number;
|
||||
/** 挂名的人 */
|
||||
ownerName: string;
|
||||
/** 记录/盘点时间 */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 资源(resources)
|
||||
// ============================================================
|
||||
|
||||
/** 一项资源(resources/index.php) */
|
||||
export interface FbResourceItem {
|
||||
id: number;
|
||||
/** 名称,如「户口本」 */
|
||||
name: string;
|
||||
/** 类别:document(证件)/ material(资料)/ other */
|
||||
kind: string;
|
||||
/** 归属人(空表示全家) */
|
||||
ownerName: string;
|
||||
/** 附件数 */
|
||||
fileCount: number;
|
||||
/** 是否共享给我 */
|
||||
shared: boolean;
|
||||
/** 更新时间 */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 读书(books)
|
||||
// ============================================================
|
||||
|
||||
/** 一本书(books/index.php 书架) */
|
||||
export interface FbBookItem {
|
||||
id: number;
|
||||
title: string;
|
||||
author: string;
|
||||
/** 状态:want(想读)/ reading(在读)/ done(读完) */
|
||||
status: string;
|
||||
/** 评分 0–5(0 表示未评) */
|
||||
rate: number;
|
||||
/** 笔记条数 */
|
||||
noteCount: number;
|
||||
/** 阅读进度(0–100,done 时为 100) */
|
||||
progress: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 导航(nav)
|
||||
// ============================================================
|
||||
|
||||
/** 一条个人导航(nav/index.php) */
|
||||
export interface FbNavLink {
|
||||
id: number;
|
||||
title: string;
|
||||
url: string;
|
||||
/** 分组名 */
|
||||
group: string;
|
||||
/** 图标名(web 端用 favicon,端上先留空用首字母) */
|
||||
icon: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 笔记(notes)
|
||||
// ============================================================
|
||||
|
||||
/** 一条笔记 */
|
||||
export interface FbNoteItem {
|
||||
id: number;
|
||||
title: string;
|
||||
/** 摘要(正文前若干字) */
|
||||
excerpt: string;
|
||||
/** 标签 */
|
||||
tags: string[];
|
||||
/** 更新时间 */
|
||||
updatedAt: string;
|
||||
/** 是否共享给别人 */
|
||||
shared: boolean;
|
||||
/** 附件数 */
|
||||
attachCount: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 通知(notifications)
|
||||
// ============================================================
|
||||
|
||||
/** 一条站内通知(Notification::mine) */
|
||||
export interface FbNotificationItem {
|
||||
id: number;
|
||||
title: string;
|
||||
body: string;
|
||||
/** 相对时间,如「3 分钟前」 */
|
||||
timeText: string;
|
||||
/** 是否未读 */
|
||||
unread: boolean;
|
||||
/** 触发人(头像色 + 显示名) */
|
||||
from: FbKinChip;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 通用:列表分组(多个页面共用「按字母/类别分组」的形态)
|
||||
// ============================================================
|
||||
|
||||
/** 一个分组(名册按分组、导航按分类、日历按日历本…都用它) */
|
||||
export interface FbGroup<T> {
|
||||
/** 分组键(唯一) */
|
||||
key: string;
|
||||
/** 分组标题 */
|
||||
title: string;
|
||||
/** 组内条目 */
|
||||
items: T[];
|
||||
/** 组标题旁的小字(如「12 人」) */
|
||||
count: string;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"module": {
|
||||
"name": "entry",
|
||||
"type": "entry",
|
||||
"description": "$string:module_desc",
|
||||
"mainElement": "EntryAbility",
|
||||
"deviceTypes": [
|
||||
"phone",
|
||||
"tablet"
|
||||
],
|
||||
"requestPermissions": [
|
||||
{
|
||||
"name": "ohos.permission.INTERNET"
|
||||
},
|
||||
{
|
||||
"name": "ohos.permission.GET_NETWORK_INFO"
|
||||
}
|
||||
],
|
||||
"deliveryWithInstall": true,
|
||||
"installationFree": false,
|
||||
"pages": "$profile:main_pages",
|
||||
"abilities": [
|
||||
{
|
||||
"name": "EntryAbility",
|
||||
"srcEntry": "./ets/entryability/EntryAbility.ets",
|
||||
"description": "$string:EntryAbility_desc",
|
||||
"icon": "$media:layered_image",
|
||||
"label": "$string:EntryAbility_label",
|
||||
"startWindowIcon": "$media:startIcon",
|
||||
"startWindowBackground": "$color:start_window_background",
|
||||
"exported": true,
|
||||
"orientation": "auto_rotation_restricted",
|
||||
"skills": [
|
||||
{
|
||||
"entities": [
|
||||
"entity.system.home"
|
||||
],
|
||||
"actions": [
|
||||
"ohos.want.action.home"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"extensionAbilities": [
|
||||
{
|
||||
"name": "EntryBackupAbility",
|
||||
"srcEntry": "./ets/entrybackupability/EntryBackupAbility.ets",
|
||||
"type": "backup",
|
||||
"exported": false,
|
||||
"metadata": [
|
||||
{
|
||||
"name": "ohos.extension.backup",
|
||||
"resource": "$profile:backup_config"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"color": [
|
||||
{ "name": "start_window_background", "value": "#F7F3EA" },
|
||||
{ "name": "page_bg", "value": "#F7F3EA" },
|
||||
{ "name": "surface", "value": "#FFFFFF" },
|
||||
{ "name": "surface_alt", "value": "#FBF8F1" },
|
||||
{ "name": "input_bg", "value": "#F2EDE2" },
|
||||
{ "name": "ink", "value": "#2C231D" },
|
||||
{ "name": "muted", "value": "#7E6B5A" },
|
||||
{ "name": "faint", "value": "#A8988A" },
|
||||
{ "name": "brand", "value": "#A84A28" },
|
||||
{ "name": "brand_2", "value": "#C96F45" },
|
||||
{ "name": "brand_ink", "value": "#6E3820" },
|
||||
{ "name": "line", "value": "#1A2C231D" },
|
||||
{ "name": "line_2", "value": "#0F2C231D" },
|
||||
{ "name": "green", "value": "#6F9E5C" },
|
||||
{ "name": "red", "value": "#C0533F" },
|
||||
{ "name": "blue", "value": "#2F6FB0" },
|
||||
{ "name": "yellow", "value": "#B8860B" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"float": [
|
||||
{ "name": "page_padding", "value": "16vp" },
|
||||
{ "name": "card_radius", "value": "16vp" },
|
||||
{ "name": "card_radius_s", "value": "10vp" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"string": [
|
||||
{
|
||||
"name": "module_desc",
|
||||
"value": "家书 · 家庭生活档案客户端"
|
||||
},
|
||||
{
|
||||
"name": "EntryAbility_desc",
|
||||
"value": "家书主界面"
|
||||
},
|
||||
{
|
||||
"name": "EntryAbility_label",
|
||||
"value": "家书"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 346 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 832 B |
|
After Width: | Height: | Size: 864 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 638 B |
|
After Width: | Height: | Size: 706 B |
|
After Width: | Height: | Size: 485 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 797 B |
|
After Width: | Height: | Size: 505 B |
|
After Width: | Height: | Size: 751 B |
|
After Width: | Height: | Size: 347 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 964 B |
|
After Width: | Height: | Size: 1011 B |
|
After Width: | Height: | Size: 363 B |
|
After Width: | Height: | Size: 857 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 792 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 327 B |
|
After Width: | Height: | Size: 775 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 992 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 880 B |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"allowToBackupRestore": true
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"src": [
|
||||
"pages/Index"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"color": [
|
||||
{ "name": "start_window_background", "value": "#1B1712" },
|
||||
{ "name": "page_bg", "value": "#1B1712" },
|
||||
{ "name": "surface", "value": "#262019" },
|
||||
{ "name": "surface_alt", "value": "#2E2720" },
|
||||
{ "name": "input_bg", "value": "#332B23" },
|
||||
{ "name": "ink", "value": "#F0E8DC" },
|
||||
{ "name": "muted", "value": "#B3A192" },
|
||||
{ "name": "faint", "value": "#8A7A6B" },
|
||||
{ "name": "brand", "value": "#C96F45" },
|
||||
{ "name": "brand_2", "value": "#DE8C63" },
|
||||
{ "name": "brand_ink", "value": "#E7A582" },
|
||||
{ "name": "line", "value": "#26F0E8DC" },
|
||||
{ "name": "line_2", "value": "#14F0E8DC" },
|
||||
{ "name": "green", "value": "#7FB16A" },
|
||||
{ "name": "red", "value": "#D46852" },
|
||||
{ "name": "blue", "value": "#5A93CF" },
|
||||
{ "name": "yellow", "value": "#D4A017" }
|
||||
]
|
||||
}
|
||||