commit 41e3aa3d9595623909f341b34e6b5e4a81c19572 Author: laoyang Date: Sun Oct 11 22:07:48 2026 +0800 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 类根因后归零。) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5234328 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +/build/ +/entry/build/ +/entry/.preview/ +/.hvigor/ +/oh_modules/ +/entry/oh_modules/ + +# IDE 配置 +/.idea/ +*.iml + +# 本地配置(含 SDK 本机绝对路径) +/local.properties +/.clangd +/.clang-format +/.clang-tidy + +# 签名材料:私钥 / 证书 / Profile / 口令 —— 严禁入库 +/sign/ +/signature/ +*.p12 +*.cer +*.p7b +*.csr +*.keystore +*.jks +password.txt +*password*.txt + +# 仓库根目录的签名构建配置:含本机绝对路径与加密口令,机器相关,不入公共仓库 +# (仅锚定根目录;entry/build-profile.json5 是普通模块构建配置,仍需入库) +/build-profile.json5 + +# hvigor 本地引擎 / 插件:构建期从 DevEco 恢复进来的机器相关副本,不入公共仓库 +# 注意 /hvigor/hvigor-config.json5 是项目构建配置,仍需入库 +/hvigor/* +!/hvigor/hvigor-config.json5 +/hvigor-ohos-plugin/ \ No newline at end of file diff --git a/AppScope/app.json5 b/AppScope/app.json5 new file mode 100644 index 0000000..4d48eff --- /dev/null +++ b/AppScope/app.json5 @@ -0,0 +1,11 @@ +{ + "app": { + "bundleName": "familybook.yangyq.net", + "vendor": "yangyq", + "versionCode": 100, + "versionName": "0.1.0", + "buildVersion": "1", + "icon": "$media:layered_image", + "label": "$string:app_name" + } +} \ No newline at end of file diff --git a/AppScope/resources/base/element/string.json b/AppScope/resources/base/element/string.json new file mode 100644 index 0000000..ead207e --- /dev/null +++ b/AppScope/resources/base/element/string.json @@ -0,0 +1,8 @@ +{ + "string": [ + { + "name": "app_name", + "value": "家书" + } + ] +} \ No newline at end of file diff --git a/AppScope/resources/base/media/layered_image.json b/AppScope/resources/base/media/layered_image.json new file mode 100644 index 0000000..fb49920 --- /dev/null +++ b/AppScope/resources/base/media/layered_image.json @@ -0,0 +1,7 @@ +{ + "layered-image": + { + "background" : "$media:background", + "foreground" : "$media:foreground" + } +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3a6327d --- /dev/null +++ b/LICENSE @@ -0,0 +1,73 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright 2026 laoyang + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8c1150c --- /dev/null +++ b/README.md @@ -0,0 +1,126 @@ +# FamilyBook_harmonyos · 家书鸿蒙客户端 + +**家书**([FamilyBook](https://code.yangyq.net/laoyang/FamilyBook))的 HarmonyOS 客户端。 +服务端是那套「自托管的家庭生活系统」,这个仓库是它的手机 / 平板端。 + +> 把一个家的「人」「日子」「去过的地方、看过的电影」收进同一本书里。 + +--- + +## 当前状态 + +**界面框架阶段(v0.1.0)** —— 骨架与全部模块页面已就位,**数据全部是演示数据**。 +服务端还没有客户端用的接口,所以这一轮只做「长什么样」,不做任何读写。 + +| 已完成 | 说明 | +| --- | --- | +| 工程骨架 | Stage 模型 + ArkTS / ArkUI,targetSdk 26.0.0 / compatibleSdk 6.1.1(24) | +| 左侧抽屉 | 宽屏常驻侧栏 / 窄屏滑出层,两套形态同一组件 | +| 13 个模块页面 | 首页、家谱、名册、观影、旅程、日程、资源、任务、纪念日、财务、读书、导航、笔记 | +| 附加页面 | 通知消息、个人设置 | +| 主题体系 | 宣纸米 / 砖红配色,深浅色两套资源,深浅自动跟随系统 | +| 模块主题色 | 与 Web 端 `Module::PRESET_COLORS` 同一套 12 色预设 | +| 编译验证 | `hvigorw assembleHap` **0 error / 0 warning** | + +| 待办(下一轮) | 说明 | +| --- | --- | +| 接口对接 | 服务端需要提供客户端用的 JSON 接口(登录、各模块列表与详情) | +| 登录态 | 会话持久化、令牌刷新、启动时的登录门禁 | +| 交互实现 | 新建 / 编辑 / 删除、搜索、筛选、拖拽排序 | +| 日程周/天视图 | 按半小时定位的时间轴 + 手势(本轮只做了月视图) | +| 附件能力 | 上传、图片/视频预览、裁剪 | +| 财务折线图 | 净资产与各账目的盘点变化曲线 | + +--- + +## 目录结构 + +``` +entry/src/main/ets/ + entryability/EntryAbility.ets 应用入口:主题跟随、系统栏配色 + entrybackupability/ 备份扩展 + common/ + Theme.ets 设计令牌(颜色 / 间距 / 字号)+ 模块调色板 + Store.ets 全局状态(V2:@ObservedV2 + @Trace) + model/ + Modules.ets 模块注册中心(对应 Web 端 App\Module) + Types.ets 全部数据类型(对应各模板渲染的字段) + DemoData.ets 演示数据(接上接口后可整个删除) + components/ + Widgets.ets 共享展示件(头像 / 标签 / 空态 / 圆点…) + Drawer.ets 左侧抽屉 + 骨架(并排 / 浮层两形态) + TopBar.ets 顶栏 + pages/ + Index.ets 主界面:模块分发 + home/ movies/ trips/ calendar/ tasks/ people/ + resources/ days/ ledger/ books/ misc/ settings/ +``` + +--- + +## 设计说明 + +### 为什么是抽屉 + +Web 端顶栏是一排横向模块入口,最多放 12 个模块 —— 手机宽度下必然溢出。 +改成左侧抽屉后,竖向排列不受数量限制,还能顺带放下「模块说明 + 主题色」 +这层信息,一眼看清每个模块是干什么的。 + +抽屉有**两套形态**,由窗口短边决定(≥ 600vp 为宽屏): + +- **宽屏**(平板 / 折叠屏展开 / 横屏手机):抽屉常驻,与内容并排; +- **窄屏**:内容铺满,抽屉从左侧滑出,点遮罩或选中模块即收起。 + +判据用「短边」而不是「宽度」—— 短边在旋转时不变,所以比例接近 1:1 的设备 +横竖屏布局保持一致。 + +### 模块注册中心 + +模块清单只有**一个来源**:`model/Modules.ets` 的 `FB_MODULES`, +与 Web 端 `app/Module.php` 的 `Module::LIST` 一一对应(key 必须等于路由名)。 + +加一个模块只改那一处 —— 抽屉、设置页的显隐开关、主题色选择全部自动出现。 +这是 Web 端踩过的坑(漏改一处就「有页面但导航里找不到入口」),端上照搬这套结构。 + +### 主题色 + +每个模块一个主题色,三档(按钮底 / hover / 文字色)由主色按 HSL 压明度算出来, +保证三档永远同色系。算法与 Web 端 `Module::cssVars()` 共用同一套公式。 + +预设色板 `FB_PRESET_COLORS` 也与 Web 端 `Module::PRESET_COLORS` **逐值一致** —— +否则同一个人在手机和电脑上选出的「观影紫」会是两种紫。 + +### 编译期约束(踩过的坑) + +| 坑 | 处理 | +| --- | --- | +| `Circle().fill()` 是 API 26 才有的重载 | compatibleSdk 是 24,改用「方块 + 圆角」画圆(`components/Widgets.ets::FbDot`),零版本风险 | +| `setWindowBackgroundColor` 对 phone/tablet 不可用 | 去掉,页面自身已用同色铺满整屏 | +| `window.getLastWindow()` 只有异步版 | 由 `EntryAbility` 用 `getMainWindowSync()` 取一次存进 AppStorage,页面侧同步读 | +| 跨文件顶层名冲突(`arkts-no-decl-merging`) | 所有导出名统一加 `Fb` 前缀 | +| `Button('label') { ... }` 报 10905202 | 一律用 `Button() { Row() { ... } }` | +| `@Param` 类型写成 `string` 导致传 `Resource` 报错 | 颜色类入参统一声明 `ResourceColor` | +| 嵌套目录下 import 相对路径层级 | 页面在 `pages/<模块>/`,公共件在 `components/`、`model/`,相对路径要多退一级 | + +--- + +## 构建 + +需要 HarmonyOS SDK(DevEco Studio 自带)。本机开发时: + +```bash +export NODE_HOME="/tools/node" +export DEVECO_SDK_HOME="/sdk" +/tools/ohpm/bin/ohpm install --all +/tools/hvigor/bin/hvigorw assembleHap --mode module -p product=default +``` + +产物在 `entry/build/default/outputs/default/entry-default-unsigned.hap`。 + +`local.properties` 里写本机 SDK 路径,**不入库**(见 `.gitignore`)。 + +--- + +## 开源协议 + +[Apache License 2.0](LICENSE) \ No newline at end of file diff --git a/entry/build-profile.json5 b/entry/build-profile.json5 new file mode 100644 index 0000000..14e1253 --- /dev/null +++ b/entry/build-profile.json5 @@ -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" + } + ] +} \ No newline at end of file diff --git a/entry/hvigorfile.ts b/entry/hvigorfile.ts new file mode 100644 index 0000000..56e6672 --- /dev/null +++ b/entry/hvigorfile.ts @@ -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. */ +} \ No newline at end of file diff --git a/entry/obfuscation-rules.txt b/entry/obfuscation-rules.txt new file mode 100644 index 0000000..cb9b2ca --- /dev/null +++ b/entry/obfuscation-rules.txt @@ -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 \ No newline at end of file diff --git a/entry/oh-package.json5 b/entry/oh-package.json5 new file mode 100644 index 0000000..585c3b5 --- /dev/null +++ b/entry/oh-package.json5 @@ -0,0 +1,9 @@ +{ + "name": "entry", + "version": "0.1.0", + "description": "家书 · 鸿蒙客户端主模块", + "main": "", + "author": "", + "license": "Apache-2.0", + "dependencies": {} +} \ No newline at end of file diff --git a/entry/src/main/ets/common/Store.ets b/entry/src/main/ets/common/Store.ets new file mode 100644 index 0000000..3939c18 --- /dev/null +++ b/entry/src/main/ets/common/Store.ets @@ -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; \ No newline at end of file diff --git a/entry/src/main/ets/common/Theme.ets b/entry/src/main/ets/common/Theme.ets new file mode 100644 index 0000000..b7de89d --- /dev/null +++ b/entry/src/main/ets/common/Theme.ets @@ -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'); + } +} \ No newline at end of file diff --git a/entry/src/main/ets/components/Drawer.ets b/entry/src/main/ets/components/Drawer.ets new file mode 100644 index 0000000..f71938b --- /dev/null +++ b/entry/src/main/ets/components/Drawer.ets @@ -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) + } +} \ No newline at end of file diff --git a/entry/src/main/ets/components/TopBar.ets b/entry/src/main/ets/components/TopBar.ets new file mode 100644 index 0000000..581d50b --- /dev/null +++ b/entry/src/main/ets/components/TopBar.ets @@ -0,0 +1,122 @@ +/** + * 家书 FamilyBook · 顶栏(鸿蒙端) + * + * 对应 Web 端 `templates/layout.php` 的 `
`,但按手机习惯调整: + * · 左:抽屉按钮(窄屏)/模块名(宽屏,此时抽屉常驻,不需要按钮) + * · 中:当前模块名 + 一句副标题(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 : ''; + } +} \ No newline at end of file diff --git a/entry/src/main/ets/components/Widgets.ets b/entry/src/main/ets/components/Widgets.ets new file mode 100644 index 0000000..e4366a5 --- /dev/null +++ b/entry/src/main/ets/components/Widgets.ets @@ -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`:小头像 + 称呼)。 + * + * ⚠️ 会出现在 内部的行内场景,所以整块不可点、纯展示。 + */ +@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) + } + } +} \ No newline at end of file diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets new file mode 100644 index 0000000..5dce1a8 --- /dev/null +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -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('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)已经用同色铺满整屏, + // 窗口底色这层是多余的。留着只会带来一条无意义的告警。 + } +} \ No newline at end of file diff --git a/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets b/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets new file mode 100644 index 0000000..982ccfd --- /dev/null +++ b/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets @@ -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 { + hilog.info(DOMAIN, 'FamilyBook', '%{public}s', 'onBackup ok'); + } + + async onRestore(bundleVersion: BundleVersion): Promise { + hilog.info(DOMAIN, 'FamilyBook', 'onRestore ok %{public}s', JSON.stringify(bundleVersion)); + } +} \ No newline at end of file diff --git a/entry/src/main/ets/model/DemoData.ets b/entry/src/main/ets/model/DemoData.ets new file mode 100644 index 0000000..8ae5eda --- /dev/null +++ b/entry/src/main/ets/model/DemoData.ets @@ -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[] { + return fbGroupBy(list, (it: FbRosterItem): string => it.group); +} + +/** 按 status 分组建任务(待办 / 进行中 / 已完成) */ +export function fbGroupTask(list: FbTaskItem[]): FbGroup[] { + const order: string[] = ['doing', 'todo', 'done']; + const titles: string[] = ['进行中', '待办', '已完成']; + const groups: FbGroup[] = []; + 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[] { + return fbGroupBy(list, (it: FbResourceItem): string => fbResourceKindName(it.kind)); +} + +/** 按 status 分组建书架(在读 / 想读 / 读完) */ +export function fbGroupBook(list: FbBookItem[]): FbGroup[] { + const order: string[] = ['reading', 'want', 'done']; + const titles: string[] = ['在读', '想读', '读完']; + const groups: FbGroup[] = []; + 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[] { + return fbGroupBy(list, (it: FbNavLink): string => it.group); +} + +/** 通用分组:保持首次出现的顺序(不做字典序,免得打乱业务排序) */ +function fbGroupBy(list: T[], keyOf: (it: T) => string): FbGroup[] { + 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[] = []; + 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; +} \ No newline at end of file diff --git a/entry/src/main/ets/model/Modules.ets b/entry/src/main/ets/model/Modules.ets new file mode 100644 index 0000000..f073cf8 --- /dev/null +++ b/entry/src/main/ets/model/Modules.ets @@ -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) + }; +} \ No newline at end of file diff --git a/entry/src/main/ets/model/Types.ets b/entry/src/main/ets/model/Types.ets new file mode 100644 index 0000000..a09fe80 --- /dev/null +++ b/entry/src/main/ets/model/Types.ets @@ -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 { + /** 分组键(唯一) */ + key: string; + /** 分组标题 */ + title: string; + /** 组内条目 */ + items: T[]; + /** 组标题旁的小字(如「12 人」) */ + count: string; +} \ No newline at end of file diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets new file mode 100644 index 0000000..2c4af8a --- /dev/null +++ b/entry/src/main/ets/pages/Index.ets @@ -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('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)); + } + } +} \ No newline at end of file diff --git a/entry/src/main/ets/pages/books/FbBooksPage.ets b/entry/src/main/ets/pages/books/FbBooksPage.ets new file mode 100644 index 0000000..18a9bff --- /dev/null +++ b/entry/src/main/ets/pages/books/FbBooksPage.ets @@ -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) => { + 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): 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[] { + 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; + } +} \ No newline at end of file diff --git a/entry/src/main/ets/pages/calendar/FbCalendarPage.ets b/entry/src/main/ets/pages/calendar/FbCalendarPage.ets new file mode 100644 index 0000000..86008cd --- /dev/null +++ b/entry/src/main/ets/pages/calendar/FbCalendarPage.ets @@ -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; + } +} \ No newline at end of file diff --git a/entry/src/main/ets/pages/days/FbDaysPage.ets b/entry/src/main/ets/pages/days/FbDaysPage.ets new file mode 100644 index 0000000..47e8082 --- /dev/null +++ b/entry/src/main/ets/pages/days/FbDaysPage.ets @@ -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); + } +} \ No newline at end of file diff --git a/entry/src/main/ets/pages/home/FbHomePage.ets b/entry/src/main/ets/pages/home/FbHomePage.ets new file mode 100644 index 0000000..f32653c --- /dev/null +++ b/entry/src/main/ets/pages/home/FbHomePage.ets @@ -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); + } +} \ No newline at end of file diff --git a/entry/src/main/ets/pages/ledger/FbLedgerPage.ets b/entry/src/main/ets/pages/ledger/FbLedgerPage.ets new file mode 100644 index 0000000..dac2566 --- /dev/null +++ b/entry/src/main/ets/pages/ledger/FbLedgerPage.ets @@ -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) => { + 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): 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[] { + 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[] = []; + 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; + } +} \ No newline at end of file diff --git a/entry/src/main/ets/pages/misc/FbMiscPages.ets b/entry/src/main/ets/pages/misc/FbMiscPages.ets new file mode 100644 index 0000000..ec12c69 --- /dev/null +++ b/entry/src/main/ets/pages/misc/FbMiscPages.ets @@ -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) => { + 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): 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[] { + 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; + } +} \ No newline at end of file diff --git a/entry/src/main/ets/pages/movies/FbMoviesPage.ets b/entry/src/main/ets/pages/movies/FbMoviesPage.ets new file mode 100644 index 0000000..374be1e --- /dev/null +++ b/entry/src/main/ets/pages/movies/FbMoviesPage.ets @@ -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) + } +} \ No newline at end of file diff --git a/entry/src/main/ets/pages/people/FbPeoplePage.ets b/entry/src/main/ets/pages/people/FbPeoplePage.ets new file mode 100644 index 0000000..8307bf5 --- /dev/null +++ b/entry/src/main/ets/pages/people/FbPeoplePage.ets @@ -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) => { + 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): 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[] { + 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'; + } +} \ No newline at end of file diff --git a/entry/src/main/ets/pages/resources/FbResourcesPage.ets b/entry/src/main/ets/pages/resources/FbResourcesPage.ets new file mode 100644 index 0000000..d088c4b --- /dev/null +++ b/entry/src/main/ets/pages/resources/FbResourcesPage.ets @@ -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) => { + 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): 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[] { + return fbGroupResource(this.filtered()); + } + + private sharedCount(): number { + return this.all.filter((r: FbResourceItem): boolean => r.shared).length; + } +} \ No newline at end of file diff --git a/entry/src/main/ets/pages/settings/FbSettingsPage.ets b/entry/src/main/ets/pages/settings/FbSettingsPage.ets new file mode 100644 index 0000000..c7b2deb --- /dev/null +++ b/entry/src/main/ets/pages/settings/FbSettingsPage.ets @@ -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'); + } + } +} \ No newline at end of file diff --git a/entry/src/main/ets/pages/tasks/FbTasksPage.ets b/entry/src/main/ets/pages/tasks/FbTasksPage.ets new file mode 100644 index 0000000..935d964 --- /dev/null +++ b/entry/src/main/ets/pages/tasks/FbTasksPage.ets @@ -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) => { + ListItemGroup({ header: this.groupHeader(g) }) { + ForEach(g.items, (t: FbTaskItem) => { + ListItem() { + this.taskCard(t) + } + }, (t: FbTaskItem): string => 'tk_' + t.id.toString()) + } + }, (g: FbGroup): 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) { + 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[] { + 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; + } +} \ No newline at end of file diff --git a/entry/src/main/ets/pages/trips/FbTripsPage.ets b/entry/src/main/ets/pages/trips/FbTripsPage.ets new file mode 100644 index 0000000..2798a8d --- /dev/null +++ b/entry/src/main/ets/pages/trips/FbTripsPage.ets @@ -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); + } +} \ No newline at end of file diff --git a/entry/src/main/module.json5 b/entry/src/main/module.json5 new file mode 100644 index 0000000..9c1c0fb --- /dev/null +++ b/entry/src/main/module.json5 @@ -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" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/entry/src/main/resources/base/element/color.json b/entry/src/main/resources/base/element/color.json new file mode 100644 index 0000000..09305eb --- /dev/null +++ b/entry/src/main/resources/base/element/color.json @@ -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" } + ] +} \ No newline at end of file diff --git a/entry/src/main/resources/base/element/float.json b/entry/src/main/resources/base/element/float.json new file mode 100644 index 0000000..bb8d79b --- /dev/null +++ b/entry/src/main/resources/base/element/float.json @@ -0,0 +1,7 @@ +{ + "float": [ + { "name": "page_padding", "value": "16vp" }, + { "name": "card_radius", "value": "16vp" }, + { "name": "card_radius_s", "value": "10vp" } + ] +} \ No newline at end of file diff --git a/entry/src/main/resources/base/element/string.json b/entry/src/main/resources/base/element/string.json new file mode 100644 index 0000000..afe142a --- /dev/null +++ b/entry/src/main/resources/base/element/string.json @@ -0,0 +1,16 @@ +{ + "string": [ + { + "name": "module_desc", + "value": "家书 · 家庭生活档案客户端" + }, + { + "name": "EntryAbility_desc", + "value": "家书主界面" + }, + { + "name": "EntryAbility_label", + "value": "家书" + } + ] +} \ No newline at end of file diff --git a/entry/src/main/resources/base/media/background.png b/entry/src/main/resources/base/media/background.png new file mode 100644 index 0000000..2f0e76c Binary files /dev/null and b/entry/src/main/resources/base/media/background.png differ diff --git a/entry/src/main/resources/base/media/fb_ic_back.png b/entry/src/main/resources/base/media/fb_ic_back.png new file mode 100644 index 0000000..5ede4e8 Binary files /dev/null and b/entry/src/main/resources/base/media/fb_ic_back.png differ diff --git a/entry/src/main/resources/base/media/fb_ic_bell.png b/entry/src/main/resources/base/media/fb_ic_bell.png new file mode 100644 index 0000000..c912927 Binary files /dev/null and b/entry/src/main/resources/base/media/fb_ic_bell.png differ diff --git a/entry/src/main/resources/base/media/fb_ic_book.png b/entry/src/main/resources/base/media/fb_ic_book.png new file mode 100644 index 0000000..83d884a Binary files /dev/null and b/entry/src/main/resources/base/media/fb_ic_book.png differ diff --git a/entry/src/main/resources/base/media/fb_ic_books.png b/entry/src/main/resources/base/media/fb_ic_books.png new file mode 100644 index 0000000..3aad061 Binary files /dev/null and b/entry/src/main/resources/base/media/fb_ic_books.png differ diff --git a/entry/src/main/resources/base/media/fb_ic_calendar.png b/entry/src/main/resources/base/media/fb_ic_calendar.png new file mode 100644 index 0000000..ce55cb4 Binary files /dev/null and b/entry/src/main/resources/base/media/fb_ic_calendar.png differ diff --git a/entry/src/main/resources/base/media/fb_ic_chart.png b/entry/src/main/resources/base/media/fb_ic_chart.png new file mode 100644 index 0000000..86b3994 Binary files /dev/null and b/entry/src/main/resources/base/media/fb_ic_chart.png differ diff --git a/entry/src/main/resources/base/media/fb_ic_check.png b/entry/src/main/resources/base/media/fb_ic_check.png new file mode 100644 index 0000000..a04e92f Binary files /dev/null and b/entry/src/main/resources/base/media/fb_ic_check.png differ diff --git a/entry/src/main/resources/base/media/fb_ic_close.png b/entry/src/main/resources/base/media/fb_ic_close.png new file mode 100644 index 0000000..e390e45 Binary files /dev/null and b/entry/src/main/resources/base/media/fb_ic_close.png differ diff --git a/entry/src/main/resources/base/media/fb_ic_days.png b/entry/src/main/resources/base/media/fb_ic_days.png new file mode 100644 index 0000000..70c85ff Binary files /dev/null and b/entry/src/main/resources/base/media/fb_ic_days.png differ diff --git a/entry/src/main/resources/base/media/fb_ic_dot.png b/entry/src/main/resources/base/media/fb_ic_dot.png new file mode 100644 index 0000000..0ba18b6 Binary files /dev/null and b/entry/src/main/resources/base/media/fb_ic_dot.png differ diff --git a/entry/src/main/resources/base/media/fb_ic_edit.png b/entry/src/main/resources/base/media/fb_ic_edit.png new file mode 100644 index 0000000..70c0465 Binary files /dev/null and b/entry/src/main/resources/base/media/fb_ic_edit.png differ diff --git a/entry/src/main/resources/base/media/fb_ic_filter.png b/entry/src/main/resources/base/media/fb_ic_filter.png new file mode 100644 index 0000000..87f7a5e Binary files /dev/null and b/entry/src/main/resources/base/media/fb_ic_filter.png differ diff --git a/entry/src/main/resources/base/media/fb_ic_forward.png b/entry/src/main/resources/base/media/fb_ic_forward.png new file mode 100644 index 0000000..b34a055 Binary files /dev/null and b/entry/src/main/resources/base/media/fb_ic_forward.png differ diff --git a/entry/src/main/resources/base/media/fb_ic_gear.png b/entry/src/main/resources/base/media/fb_ic_gear.png new file mode 100644 index 0000000..f3df61e Binary files /dev/null and b/entry/src/main/resources/base/media/fb_ic_gear.png differ diff --git a/entry/src/main/resources/base/media/fb_ic_home.png b/entry/src/main/resources/base/media/fb_ic_home.png new file mode 100644 index 0000000..49493ab Binary files /dev/null and b/entry/src/main/resources/base/media/fb_ic_home.png differ diff --git a/entry/src/main/resources/base/media/fb_ic_kin.png b/entry/src/main/resources/base/media/fb_ic_kin.png new file mode 100644 index 0000000..b33f135 Binary files /dev/null and b/entry/src/main/resources/base/media/fb_ic_kin.png differ diff --git a/entry/src/main/resources/base/media/fb_ic_ledger.png b/entry/src/main/resources/base/media/fb_ic_ledger.png new file mode 100644 index 0000000..7ed6e8b Binary files /dev/null and b/entry/src/main/resources/base/media/fb_ic_ledger.png differ diff --git a/entry/src/main/resources/base/media/fb_ic_lock.png b/entry/src/main/resources/base/media/fb_ic_lock.png new file mode 100644 index 0000000..ee962ce Binary files /dev/null and b/entry/src/main/resources/base/media/fb_ic_lock.png differ diff --git a/entry/src/main/resources/base/media/fb_ic_menu.png b/entry/src/main/resources/base/media/fb_ic_menu.png new file mode 100644 index 0000000..eb2ac23 Binary files /dev/null and b/entry/src/main/resources/base/media/fb_ic_menu.png differ diff --git a/entry/src/main/resources/base/media/fb_ic_movies.png b/entry/src/main/resources/base/media/fb_ic_movies.png new file mode 100644 index 0000000..31c59ce Binary files /dev/null and b/entry/src/main/resources/base/media/fb_ic_movies.png differ diff --git a/entry/src/main/resources/base/media/fb_ic_nav.png b/entry/src/main/resources/base/media/fb_ic_nav.png new file mode 100644 index 0000000..fd1626d Binary files /dev/null and b/entry/src/main/resources/base/media/fb_ic_nav.png differ diff --git a/entry/src/main/resources/base/media/fb_ic_notes.png b/entry/src/main/resources/base/media/fb_ic_notes.png new file mode 100644 index 0000000..30a2736 Binary files /dev/null and b/entry/src/main/resources/base/media/fb_ic_notes.png differ diff --git a/entry/src/main/resources/base/media/fb_ic_people.png b/entry/src/main/resources/base/media/fb_ic_people.png new file mode 100644 index 0000000..5342518 Binary files /dev/null and b/entry/src/main/resources/base/media/fb_ic_people.png differ diff --git a/entry/src/main/resources/base/media/fb_ic_plus.png b/entry/src/main/resources/base/media/fb_ic_plus.png new file mode 100644 index 0000000..f20c2ef Binary files /dev/null and b/entry/src/main/resources/base/media/fb_ic_plus.png differ diff --git a/entry/src/main/resources/base/media/fb_ic_resources.png b/entry/src/main/resources/base/media/fb_ic_resources.png new file mode 100644 index 0000000..a0cc8f3 Binary files /dev/null and b/entry/src/main/resources/base/media/fb_ic_resources.png differ diff --git a/entry/src/main/resources/base/media/fb_ic_search.png b/entry/src/main/resources/base/media/fb_ic_search.png new file mode 100644 index 0000000..5e75887 Binary files /dev/null and b/entry/src/main/resources/base/media/fb_ic_search.png differ diff --git a/entry/src/main/resources/base/media/fb_ic_share.png b/entry/src/main/resources/base/media/fb_ic_share.png new file mode 100644 index 0000000..388b532 Binary files /dev/null and b/entry/src/main/resources/base/media/fb_ic_share.png differ diff --git a/entry/src/main/resources/base/media/fb_ic_tasks.png b/entry/src/main/resources/base/media/fb_ic_tasks.png new file mode 100644 index 0000000..55bdcf7 Binary files /dev/null and b/entry/src/main/resources/base/media/fb_ic_tasks.png differ diff --git a/entry/src/main/resources/base/media/fb_ic_trash.png b/entry/src/main/resources/base/media/fb_ic_trash.png new file mode 100644 index 0000000..2103c0f Binary files /dev/null and b/entry/src/main/resources/base/media/fb_ic_trash.png differ diff --git a/entry/src/main/resources/base/media/fb_ic_trips.png b/entry/src/main/resources/base/media/fb_ic_trips.png new file mode 100644 index 0000000..4e8a132 Binary files /dev/null and b/entry/src/main/resources/base/media/fb_ic_trips.png differ diff --git a/entry/src/main/resources/base/media/fb_ic_user.png b/entry/src/main/resources/base/media/fb_ic_user.png new file mode 100644 index 0000000..c087dc0 Binary files /dev/null and b/entry/src/main/resources/base/media/fb_ic_user.png differ diff --git a/entry/src/main/resources/base/media/fb_ic_wallet.png b/entry/src/main/resources/base/media/fb_ic_wallet.png new file mode 100644 index 0000000..9bf68b1 Binary files /dev/null and b/entry/src/main/resources/base/media/fb_ic_wallet.png differ diff --git a/entry/src/main/resources/base/media/fb_logo.png b/entry/src/main/resources/base/media/fb_logo.png new file mode 100644 index 0000000..e26dfef Binary files /dev/null and b/entry/src/main/resources/base/media/fb_logo.png differ diff --git a/entry/src/main/resources/base/media/foreground.png b/entry/src/main/resources/base/media/foreground.png new file mode 100644 index 0000000..ac223d8 Binary files /dev/null and b/entry/src/main/resources/base/media/foreground.png differ diff --git a/entry/src/main/resources/base/media/startIcon.png b/entry/src/main/resources/base/media/startIcon.png new file mode 100644 index 0000000..02a14a2 Binary files /dev/null and b/entry/src/main/resources/base/media/startIcon.png differ diff --git a/entry/src/main/resources/base/profile/backup_config.json b/entry/src/main/resources/base/profile/backup_config.json new file mode 100644 index 0000000..78f40ae --- /dev/null +++ b/entry/src/main/resources/base/profile/backup_config.json @@ -0,0 +1,3 @@ +{ + "allowToBackupRestore": true +} \ No newline at end of file diff --git a/entry/src/main/resources/base/profile/main_pages.json b/entry/src/main/resources/base/profile/main_pages.json new file mode 100644 index 0000000..55c3f00 --- /dev/null +++ b/entry/src/main/resources/base/profile/main_pages.json @@ -0,0 +1,5 @@ +{ + "src": [ + "pages/Index" + ] +} \ No newline at end of file diff --git a/entry/src/main/resources/dark/element/color.json b/entry/src/main/resources/dark/element/color.json new file mode 100644 index 0000000..4ddaee8 --- /dev/null +++ b/entry/src/main/resources/dark/element/color.json @@ -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" } + ] +} \ No newline at end of file diff --git a/hvigor/hvigor-config.json5 b/hvigor/hvigor-config.json5 new file mode 100644 index 0000000..b6cde93 --- /dev/null +++ b/hvigor/hvigor-config.json5 @@ -0,0 +1,13 @@ +{ + "modelVersion": "26.0.0", + "dependencies": { + }, + "execution": { + }, + "logging": { + }, + "debugging": { + }, + "nodeOptions": { + } +} \ No newline at end of file diff --git a/hvigorfile.ts b/hvigorfile.ts new file mode 100644 index 0000000..47113e2 --- /dev/null +++ b/hvigorfile.ts @@ -0,0 +1,6 @@ +import { appTasks } from '@ohos/hvigor-ohos-plugin'; + +export default { + system: appTasks, /* Built-in plugin of Hvigor. It cannot be modified. */ + plugins: [] /* Custom plugin to extend the functionality of Hvigor. */ +} \ No newline at end of file diff --git a/oh-package-lock.json5 b/oh-package-lock.json5 new file mode 100644 index 0000000..c5a91ec --- /dev/null +++ b/oh-package-lock.json5 @@ -0,0 +1,28 @@ +{ + "meta": { + "stableOrder": true, + "enableUnifiedLockfile": false + }, + "lockfileVersion": 3, + "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", + "specifiers": { + "@ohos/hamock@1.0.0": "@ohos/hamock@1.0.0", + "@ohos/hypium@1.0.25": "@ohos/hypium@1.0.25" + }, + "packages": { + "@ohos/hamock@1.0.0": { + "name": "@ohos/hamock", + "version": "1.0.0", + "integrity": "sha512-K6lDPYc6VkKe6ZBNQa9aoG+ZZMiwqfcR/7yAVFSUGIuOAhPvCJAo9+t1fZnpe0dBRBPxj2bxPPbKh69VuyAtDg==", + "resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hamock/-/hamock-1.0.0.har", + "registryType": "ohpm" + }, + "@ohos/hypium@1.0.25": { + "name": "@ohos/hypium", + "version": "1.0.25", + "integrity": "sha512-l6uO2pjl8HyEKdekLqQt7tUpWbDqX/42zoAzkagtUVZAW9jT6lMvbe54MVjoLxq/RwQGygRvi6j4GpypSMFSHw==", + "resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hypium/-/hypium-1.0.25.har", + "registryType": "ohpm" + } + } +} \ No newline at end of file diff --git a/oh-package.json5 b/oh-package.json5 new file mode 100644 index 0000000..ca2f15a --- /dev/null +++ b/oh-package.json5 @@ -0,0 +1,11 @@ +{ + "modelVersion": "26.0.0", + "name": "familybook_harmonyos", + "version": "0.1.0", + "description": "家书 FamilyBook · 鸿蒙客户端", + "dependencies": {}, + "devDependencies": { + "@ohos/hypium": "1.0.25", + "@ohos/hamock": "1.0.0" + } +} \ No newline at end of file