asset-light 文档索引

这份文档集解决什么问题

  • 如果你是第一次学 Rust:按顺序阅读 Rust 学习指南,并在本项目里完成每章的小练习。
  • 如果你要维护/扩展本项目:优先阅读项目技术文档,了解模块边界、数据流、数据库与调试方式。
  • 如果你在回看需求与设计:使用现有的 BMM 规划/设计文档作为“为什么做”和“要做到什么程度”的依据。

快速导航

BMM 规划/设计文档(已有)

推荐阅读顺序(第一次学 Rust)

  1. 先看 docs/rust-guide/README.md 的学习路线与章节说明。
  2. 跑通项目(cargo run),把应用窗口跑起来。
  3. 从“模块结构/所有权/错误处理”三章开始边读边改,形成 Rust 心智模型。
  4. 再进入“rusqlite 数据库”和 “Dioxus UI 与状态”的实战章节。

文档网站(GitHub Pages + mdBook)

本仓库提供 mdBook 构建配置,可将 docs/ 发布为静态网站:

  • 本地预览
    • 安装 mdbook 后运行:mdbook serve
    • 默认会在本地启动预览并自动刷新
  • 线上发布(GitHub Actions)
    • push 到 master/main 且变更包含 docs/** 时,会自动构建并推送到 docs 分支
    • 需要在 GitHub 仓库设置里启用 Pages(Source 选择 docs 分支)

文档约定

  • 以本仓库代码为准:若规划/设计文档与当前实现存在差异,以代码为最终事实,并在技术文档中说明差异与原因。
  • 每章都带练习:指南会尽量提供可以在本项目中直接完成的最小练习(可通过编译或 UI 行为验证)。

Rust 学习指南(结合 asset-light 项目)

目标读者

你熟悉 JS/TS/Java 前后端技术栈,但第一次系统学习 Rust,并希望通过 asset-light 这个真实项目建立 Rust 心智模型、掌握工程化工作流与常见库(Dioxus、rusqlite、serde、chrono、uuid、rust_decimal 等)。

使用方式(推荐)

  • 每章 30-90 分钟:先读“概念与对照”,再做“本项目练习”。
  • 边读边改:学习 Rust 最有效的方式是不断经历「编译器报错 → 理解原因 → 修正设计」。
  • 以小步提交为节奏:每次只做一个小改动,保证能 cargo check 通过(或至少明确当前失败原因)。

章节目录(循序渐进)

  1. 学习路线与心智模型(从 JS/Java 到 Rust)
  2. 工具链与 Cargo 工作流(你每天会用到的命令)
  3. crate / module / 可见性(看懂项目结构与 mod
  4. 所有权与借用(Rust 的“语法税”从哪里来)
  5. 数据建模:struct / enum / derive / Option(结合本项目模型)
  6. 错误处理:Result / ? / thiserror(让代码可维护)
  7. SQLite 与 rusqlite:Repository 模式与类型转换
  8. Dioxus 入门:组件、props、Signal、路由与数据流
  9. 练习清单与进阶路线(把 Rust 用熟)

你会在这个项目里学到什么(按能力分层)

  • 语言层:所有权/借用、pattern matching、trait 的基本用法、Option/Result、宏(derive)。
  • 工程层:Cargo 工作流、模块边界、错误类型设计、单元测试/集成测试思路。
  • 项目层:本地 SQLite、Repository、Dioxus UI、全局状态与数据加载策略。

推荐的“最小跑通”验证

  • cargo run 启动桌面应用。
  • 能在“资产管理”新增一条资产,刷新列表并落库。
  • 能发起一次盘点并在“历史”看到快照。
  • 能在“配置”切换激活方案,并在“首页”看到偏离提示变化。

01 学习路线与心智模型(从 JS/TS/Java 到 Rust)

本章目标

  • 建立一套可落地的 Rust 学习路线(不是“看完语法”,而是“能独立改项目”)。
  • 用你熟悉的 JS/TS/Java 心智模型对照理解:Rust 到底“难”在哪里、值得在哪里。
  • 结合 asset-light 这类“带 UI + 本地 DB”的项目,明确你要掌握哪些核心能力。

Rust 与你熟悉语言的差异(快速对照)

  • 内存管理方式
    • JS/TS/Java:GC(垃圾回收)+ 运行时开销可控但不可见
    • Rust:编译期所有权(Ownership)+ 零成本抽象(把“内存规则”变成编译器校验)
  • 错误处理
    • JS:throw / try-catch(运行时)
    • Java:checked/unchecked exception(类型系统里有一部分)
    • RustResult<T, E> / Option<T>(错误与缺失值默认都是“类型的一部分”)
  • 并发与共享
    • JS:单线程事件循环 + message passing(Web Worker 等)
    • Java:多线程 + 锁/并发结构
    • Rust:多线程 + 借用规则 + Send/Sync(类型系统约束并发安全)
  • 抽象方式
    • TS:结构类型 + 泛型 + union
    • Java:名义类型 + interface/class + 泛型
    • Rust:trait + 泛型 + enum(表达力很强,代价是需要更多“类型思维”)

你需要建立的 3 个 Rust 心智模型

1) “值 = 资源”,所有权是默认规则

在 Rust 里,StringVec<T>、数据库连接、文件句柄都可以视为“资源”。
资源默认只能有一个拥有者,当拥有者离开作用域,资源就被释放。

你会经常遇到“move(移动语义)”:

  • 把一个 String 赋给另一个变量,默认是移动,旧变量不能再用。
  • 想继续用?你要么传引用(借用),要么显式 clone()(付出复制成本)。

2) “类型 = 约束”,把不变量写进类型

Rust 鼓励把业务不变量写进类型与 API:

  • Option<T>:可能为空(不再用 null/undefined 兜底)
  • Result<T, E>:可能失败(错误必须被处理或传播)
  • enum:用“可枚举的状态集合”替代“字符串魔法值”

asset-light 里,分类体系就体现得很强:

  • Category:资产大类(稳定枚举 + 稳定编码)
  • AssetScope:口径(投组 vs 净资产项)
  • VehicleType:工具类型(载体维度)
  • AssetSubCategory:子资产类别(稳定编码)

对应文件:src/models/category.rssrc/models/asset_scope.rssrc/models/vehicle_type.rssrc/models/asset_sub_category.rs

3) “编译器 = 教练”,让报错成为学习反馈

Rust 的学习体验非常像“被严格的教练带着做题”:

  • 你写的代码不符合规则,编译器会阻止你继续
  • 你一开始会觉得慢,但当你熟悉模式后,bug 会大幅减少。

建议把 Rust 报错当作“反馈提示”而不是“障碍”:

  • 先读第一段总结(通常就告诉你核心矛盾)
  • 再看它标出的两处代码位置(borrow 的来源与冲突点)
  • 最后再决定:借用?clone?重构 API?

结合 asset-light 的学习路线(推荐顺序)

这份指南不是按语法目录写的,而是按“能把项目改起来”写的。

第 0 步:先跑起来(30 分钟)

  • 运行入口:src/main.rs
  • 根组件:src/app.rs
  • 路由:src/router.rs
  • 数据库初始化:src/db/mod.rssrc/db/connection.rs

你要能回答:

  • 应用从哪里启动?
  • DB 什么时候初始化?DB 路径在哪里?
  • 页面切换是谁控制的?

第 1 步:读懂模块结构(1-2 小时)

先把“文件夹就是模块”的结构读顺:

  • src/models/*(领域模型)
  • src/db/*(Repository + 连接)
  • src/pages/*(页面)
  • src/components/*(UI 组件)
  • src/state/*(全局状态)

第 2 步:啃下所有权/借用(2-5 小时,分多次)

这是 Rust 的核心门槛,也是一旦跨过就能持续受益的部分。
建议配合本项目里这两个“真实场景”来学:

  • Signal<AppState>.read() / .write()(UI 状态读写)
  • Mutex<Connection> + OnceLock<Database>(全局 DB 单例 + 可变共享)

第 3 步:掌握 DB 访问与类型转换(2-4 小时)

从“能读能写”入手,再讨论“优雅与健壮”:

  • AssetRepositoryinsert/update/find_all
  • SnapshotRepositorycreate/find_all
  • PlanRepositorycreate/update/set_active

对应目录:src/db/*

第 4 步:理解 Dioxus 的组件与数据流(2-4 小时)

你要能熟练定位:

  • 页面:src/pages/*
  • 组件:src/components/*
  • 路由/导航:src/router.rssrc/components/layout/sidebar.rs

本章练习(不改业务逻辑,先建立“地图”)

练习 A:从入口走一遍执行链

  1. 打开 src/main.rs,找到:
    • db::init_database()
    • dioxus::launch(app::App)
  2. 打开 src/db/mod.rs,确认:
    • OnceLock<Database> 是怎么实现“全局单例”的
  3. 打开 src/router.rs,确认:
    • Route 枚举定义了哪些页面
  4. 打开 src/components/layout/sidebar.rs,确认:
    • Sidebar 导航项和 Route 的对应关系

练习 B:找到 DB 路径逻辑

打开 src/db/connection.rsDatabase::db_path(),回答:

  • 默认 DB 文件落在哪里?
  • 你如何通过环境变量指定 DB 路径?(提示:ASSET_LIGHT_DB_PATH

02 工具链与 Cargo 工作流(你每天会用到的命令)

本章目标

  • 你能熟练使用 Cargo 完成开发闭环:编译、运行、检查、格式化、静态检查、测试。
  • 你能读懂 Cargo.toml / Cargo.lock,知道依赖和 feature 怎么影响编译。
  • 你能把“工具链”变成肌肉记忆,让学习 Rust 不被环境问题消耗。

必备工具(建议安装/开启)

  • Rust toolchainrustup / cargo / rustc
  • IDE:VS Code + rust-analyzer(或 JetBrains Rust)
  • 格式化rustfmt
  • 静态检查clippy

rustfmt / clippy 通常随 toolchain 安装,但可能需要 rustup component add rustfmt clippy

Cargo 常用命令(按使用频率排序)

1) cargo check

  • 只做类型检查,不产物最终二进制,速度快,适合频繁运行。
  • 你写 Rust 的大多数错误,cargo check 都能第一时间告诉你。

2) cargo run

  • 编译并运行当前 crate 的二进制(本项目会启动 Dioxus 桌面应用)。
  • 常用参数:
    • cargo run --release:发布模式(优化后更快,但编译更慢)

3) cargo build

  • 只编译不运行。
  • 常用于 CI 或你想确认产物可生成。

4) cargo fmt

  • 使用 rustfmt 自动格式化。
  • 建议开启 IDE 保存时自动格式化,减少风格讨论成本。

5) cargo clippy

  • Clippy 是“更严格”的 lint 工具,会给出很多“更 Rust”的建议。
  • 你学习期非常适合用它纠正习惯(比如不必要的 clone、低效写法、可读性问题)。

6) cargo test

  • 跑单元测试与集成测试。
  • 本项目目前测试较少,但你可以把练习写成测试(学习效果很好)。

依赖与锁文件:Cargo.toml vs Cargo.lock

  • Cargo.toml:你声明“我需要什么依赖”
  • Cargo.lock:Cargo 解析出的“我实际用的版本”(确保可复现构建)

asset-light 中,依赖定义见 Cargo.toml

  • dioxus:UI 框架(desktop + router feature)
  • rusqlite:SQLite 访问(bundled feature 便于本地开发)
  • serde:序列化
  • chrono:时间
  • uuid:唯一 ID
  • rust_decimal:金额/比例的精确计算
  • thiserror:更优雅的错误类型(当前代码仍有进一步统一空间)

本项目开发中“最常用”的命令组合

开发循环(最推荐)

  1. cargo fmt
  2. cargo check
  3. cargo run

只验证质量(更严格)

  1. cargo fmt
  2. cargo clippy
  3. cargo test

环境变量(对调试非常有用)

1) ASSET_LIGHT_DB_PATH

用于指定 sqlite 文件路径,常用于:

  • 临时跑一个干净的 DB
  • 做手工验证而不污染真实数据
  • 写测试时使用临时目录

实现位置:src/db/connection.rsDatabase::db_path()

常见坑与解决思路

1) 第一次编译下载依赖很慢/失败

  • 第一次 cargo build / cargo run 会下载 crates。
  • 如果网络环境不稳定,会出现解析/下载失败。

建议:

  • 先确保能访问 crates.io(必要时配置代理)
  • 尽量让构建机/CI 具备稳定网络

2) 你改了很多文件,cargo run 很慢

  • 先用 cargo check 快速拿到类型错误反馈
  • 修到 cargo check 通过后再 cargo run

3) “跑起来了但看不到数据”

这是本地 app 常见问题:

  • 数据存在 DB 文件里,换了 DB 路径相当于换了数据源
  • 或者迁移逻辑做了破坏性升级(清空数据)

排查:

  • 是否设置了 ASSET_LIGHT_DB_PATH
  • src/db/connection.rs 的迁移逻辑是否 drop 过表?

本章练习(把工具链练熟)

  1. 运行一次 cargo fmt,观察有哪些文件被格式化。
  2. 运行一次 cargo check,熟悉输出结构(警告/错误定位方式)。
  3. 用一个临时 DB 路径启动应用:
ASSET_LIGHT_DB_PATH=/tmp/asset-light-dev.db cargo run

然后在 UI 里新增资产,确认重新启动后数据仍存在该 DB 文件中。

03 crate / module / 可见性(看懂项目结构与 mod

本章目标

  • 你能从 src/main.rs 一眼读懂“这个 crate 由哪些模块组成”。
  • 你能理解 modusepubpub use 的组合用法,并在项目里正确组织代码边界。
  • 你能在新增文件/目录后让编译器“找到它”,避免常见的模块路径错误。

术语速览(用你熟悉的语言类比)

  • crate:一个编译单元(类似一个 npm package / 一个 Maven 模块)
  • module:crate 内部的命名空间(类似 TS 的 module/namespace + 文件系统约定)
  • item 可见性pub 决定“外部模块是否可用”(类似 public/private,但粒度更细)

src/main.rs 读懂 crate 根

本项目的 crate root 是二进制入口:src/main.rs
你会看到类似这样的声明:

  • mod app;
  • mod components;
  • mod db;
  • mod models;
  • mod pages;
  • mod router;
  • mod services;
  • mod state;
  • mod utils;

这意味着:

  • crate 顶层模块包括:crate::appcrate::dbcrate::models
  • 对应文件/目录通常是:
    • src/app.rs(单文件模块)
    • src/db/mod.rs + src/db/*.rs(目录模块)

“目录模块”是怎么工作的(为什么需要 mod.rs

Rust 的模块系统会把文件系统映射为模块树:

  • src/db/mod.rs 对应模块:crate::db
  • src/db/asset_repo.rs 对应模块:crate::db::asset_repo

src/db/mod.rs 中,通过 mod asset_repo; 把子模块挂载进来,随后可以:

  • db/mod.rs 内部 use asset_repo::...
  • 或者 pub use asset_repo::AssetRepository; 作为对外 API(更常见)

use / pub / pub use 的组合(工程里最常用)

1) use:在当前模块引入名字

#![allow(unused)]
fn main() {
use crate::db::AssetRepository;
}

仅影响当前模块的可读性,不改变对外可见性。

2) pub:让 item 对外可见

#![allow(unused)]
fn main() {
pub struct AssetRepository;
}

其他模块才能通过 crate::db::asset_repo::AssetRepository 使用它。

3) pub use:重新导出(re-export)

这是大型项目里非常常见的“门面模式”:

  • 内部可以有 crate::db::asset_repocrate::db::plan_repo 等细粒度模块
  • 对外只暴露 crate::db::{AssetRepository, PlanRepository, SnapshotRepository}

你可以在 src/db/mod.rs 里看到类似写法:

  • pub use asset_repo::AssetRepository;
  • pub use plan_repo::PlanRepository;

这样 pages 层就不需要了解 db 的文件拆分细节。

路径系统:crate:: / super:: / self::

这是初学者经常迷路的地方,建议记住这三种“稳定定位”:

  • crate::xxx:从 crate 根开始(最稳)
  • super::xxx:从父模块开始(重构文件时容易受影响)
  • self::xxx:当前模块(用于更明确的内部引用)

src/models/asset_sub_category.rs 里就有典型用法:

  • use super::Category;:从同目录的父模块 models 引入 Category

常见报错与解决方式

1) file not found for module ...

原因通常是:

  • 新增了 src/foo/bar.rs,但没有在 src/foo/mod.rsmod bar;
  • 或者 mod 声明和文件名不一致

2) cannot find type/struct/function ... in this scope

原因通常是:

  • 忘了 use ... 引入
  • 或者 item 没有 pub,跨模块访问被禁止

本章练习(在项目里动手巩固)

练习 A:画出模块树(建议真的画出来)

src/main.rs 出发,把顶层模块写出来,然后继续展开:

  • dbconnectionasset_repoplan_reposnapshot_repo
  • modelsassetcategoryasset_scopevehicle_typeasset_sub_category
  • componentsassetplansnapshotanalysislayoutcommon

练习 B:新增一个 utils 子模块(不改业务)

  1. 新建文件:src/utils/debug.rs
  2. src/utils/mod.rs 增加:pub mod debug;
  3. debug.rs 写一个小函数:
    • pub fn dump_routes():打印路由列表(字符串即可)
  4. src/main.rs 或某个页面里临时调用它验证编译通过

你会经历一次完整的“新增模块 → 挂载 → 导入 → 使用”的流程。

04 所有权与借用(Rust 的“核心门槛”与核心价值)

本章目标

  • 你能解释:为什么 Rust 不需要 GC 也能安全释放内存。
  • 你能在真实代码里解决常见编译错误:move、借用冲突、生命周期相关提示。
  • 你能理解 asset-light 里两类典型“共享可变”场景:
    • UI 状态:Signal<AppState>.read() / .write()
    • DB 连接:Mutex<Connection> + OnceLock<Database>

关键概念(先把话说清楚)

1) 所有权(Ownership)

  • 每个值在任意时刻只有一个“拥有者”(owner)
  • owner 离开作用域,值会被 drop(资源释放)

2) 移动(Move)

对“拥有资源”的类型(如 StringVec<T>):

  • 赋值 / 传参默认是 move
  • move 之后,旧变量失效(防止“双重释放”)

3) 借用(Borrow)与引用(Reference)

  • &T:不可变借用(多读)
  • &mut T:可变借用(单写)

Rust 的核心规则:

  • 同一时间:
    • 要么有任意多个 &T
    • 要么只有一个 &mut T

与 JS/Java 的直觉冲突点(你一定会遇到)

  • “我只是传个参数,为啥变量就不能用了?”
    因为你传的是 move(把所有权交出去了),不是引用。
  • “我就想同时读写一下,为啥不行?”
    因为 Rust 要在编译期保证没有数据竞争/悬垂引用。
  • “我明明只是在 UI 里读 state,为啥又报借用冲突?”
    因为 .read()/.write() 可能拿到内部借用句柄,作用域没结束就会冲突。

String vs &str(Rust 初学者最常见困惑)

  • String:拥有所有权、可增长、堆分配
  • &str:字符串切片引用(通常指向 String 或静态字符串)

经验法则:

  • API 接收参数时,优先用 &str(更通用、避免不必要的 clone)
  • 存储字段时,用 String(拥有数据,生命周期更简单)

在 asset-light 中的“共享可变”怎么做到安全

1) UI 全局状态:Signal<AppState>

文件:src/state/app_state.rs

  • provide_app_state()use_context_provider 注入一个 Signal<AppState>
  • 组件内用 use_app_state() 获取它
  • .read():拿到只读视图
  • .write():拿到可写视图(通常是短作用域)

你在页面里经常看到类似写法:

  • let mut state = use_app_state();
  • let assets = state.read().assets.clone();
  • state.write().assets = assets;

为什么常见 clone()
因为 UI 渲染往往需要拥有数据(或需要跨作用域),而借用规则不允许你把 &T 引用带出读取作用域。

学习阶段先接受 clone 的存在,等掌握后再优化。

2) DB 单例与连接:OnceLock<Database> + Mutex<Connection>

文件:src/db/mod.rssrc/db/connection.rs

  • OnceLock<Database>:保证数据库只初始化一次(全局单例)
  • Database 内部用 Mutex<Connection>
    • 互斥锁保证同一时间只有一个线程/调用在访问 Connection
    • 让“全局可变”在类型系统下变得安全

你会看到:

  • let conn = self.conn.lock().unwrap();

这行 unwrap() 在工程上可以进一步改进(见后续“错误处理”章节),但它体现了“锁拿到了才能继续用连接”的共享可变模式。

常见编译错误“翻译器”(带你读懂报错)

1) use of moved value

你把值 move 走了,然后又用旧变量。

解决思路(按优先级):

  1. 改成传引用:fn foo(s: &str) / fn foo(v: &Vec<T>)
  2. 必要时 clone:let b = a.clone();
  3. 重新设计数据流:把 ownership 留在更高层

2) cannot borrow ... as mutable because it is also borrowed as immutable

同一个值同时存在不可变借用与可变借用。

解决思路:

  1. 缩小借用作用域:用代码块 { ... } 包住 .read() 的使用
  2. 先把数据拷贝出来再写:let x = state.read().foo.clone(); state.write().foo = x;
  3. 重构:拆成两个 signal/字段,减少冲突

3) 生命周期相关提示(先不硬啃)

学习期建议:

  • 先用拥有类型(String/Vec<T>)把功能做出来
  • 当你开始做性能优化/减少 clone 时,再回头系统学习 lifetime 标注

本章练习(用真实代码练 borrow 思维)

练习 A:把 “读写同一个 state” 的作用域缩小

找一个页面(例如 src/pages/assets.rs)观察这种模式:

  • state.read() 拿数据
  • 后面 state.write() 更新数据

尝试把读取逻辑包进一个更小的作用域块,确保你理解“借用结束点”:

  • { let assets = state.read().assets.clone(); ... }
  • 再进行写入

目的不是改功能,而是让你建立“借用在何时结束”的直觉。

练习 B:识别哪些类型是 Copy,哪些会 move

在本项目里找几个字段:

  • CategoryCopy:枚举,Clone, Copy
  • Uuid(非 Copy,通常 move / clone)
  • Decimal(非 Copy,通常 clone 或引用)
  • String(move 语义最典型)

你需要能解释:为什么 Category 可以随便传来传去,而 String 不行。

练习 C:理解为什么 DB 连接需要 Mutex

打开 src/db/connection.rs,回答:

  • 如果没有 Mutex,我们如何在多个调用点安全地复用一个 Connection
  • 为什么 Rust 不允许“全局可变静态变量直接拿来改”?

05 数据建模:struct / enum / derive / Option(结合本项目模型)

本章目标

  • 你能用 Rust 的类型系统把“业务不变量”表达清楚(避免到处塞字符串和魔法值)。
  • 你能读懂并扩展 asset-light 的领域模型(Asset / Snapshot / AllocationPlan)。
  • 你能理解“稳定编码(code)”与“展示名(display_name)”分离的工程价值。

1. Rust 里怎么“建模”

1.1 struct:描述“一个东西长什么样”

Rust 的 struct 更像是:

  • TStype Asset = { ... } + 一些 helper function
  • Java:POJO + 静态工厂方法 + getter/setter(但 Rust 更倾向不可变/显式修改)

本项目的 Asset 定义在:src/models/asset.rs

它把“资产条目”拆成多个正交维度:

  • scope: AssetScope:口径(是否纳入再平衡)
  • category: Category:资产大类(风险暴露维度)
  • sub_asset_class: String:子资产类别稳定编码(例如 EQ_BROAD
  • vehicle_type: VehicleType:工具类型(ETF/基金/股票等“持有载体”)

这四个字段非常关键:它们让 UI/分析/DB 都可以围绕同一套“结构化语义”工作。

1.2 enum:描述“一个东西只能是这几种之一”

Rust 的 enum 是“可穷举的联合类型”(Sum Type):

  • TStype Category = "CASH" | "FI" | ...(但 TS 运行时还是字符串)
  • Javaenum Category { ... }

Rust 的强项在于:match 必须覆盖所有分支,使得“漏处理”在编译期就能被抓住。

本项目大量使用 enum:

  • src/models/category.rsCategory
  • src/models/asset_scope.rsAssetScope
  • src/models/vehicle_type.rsVehicleType

1.3 derive:把“样板代码”交给编译器生成

你会在模型上看到非常多的 #[derive(...)]

  • Debug:方便打印/调试(println!("{:?}", x)
  • Clone:允许显式复制(UI 层经常需要)
  • Copy:轻量值语义复制(常用于小枚举,如 Category
  • PartialEq/Eq/Hash:用于比较、作为 HashMap key 等
  • Serialize/Deserialize:与 serde 结合做序列化(未来做导出/导入会很有用)

例子:CategoryCopy 的(Clone, Copy),而 Asset 不是(包含 StringDecimal 等堆/大对象)。


2. 本项目的建模策略:稳定编码 vs 展示名

2.1 为什么需要稳定编码

UI 展示名可能会变(文案、翻译、缩写),但数据库里存的数据应该尽量稳定。
因此本项目通常采用:

  • DB 存:稳定编码(CASH / FI / EQ_BROAD / ETF …)
  • UI 展示:中文名/图标/颜色(display_name() / icon() / color()

示例:Category 同时提供:

  • as_str():稳定编码(持久化)
  • from_str():从编码解析(读库/兼容旧数据)
  • display_name():展示名(UI)

对应实现见:src/models/category.rs

2.2 “子资产类别”为什么在 Asset 里是 String

你会注意到:

  • 子资产类别标准枚举在:src/models/asset_sub_category.rsAssetSubCategory
  • Asset.sub_asset_classString

这是一个典型工程权衡:
String 存储稳定编码让系统更“前向兼容”(未来增加子类别不必立刻升级所有数据结构),同时仍然可以用 AssetSubCategory 提供:

  • 编码全集(all()
  • 按大类枚举(all_for_category()
  • 从 code 解析(from_code()
  • 校验编码属于哪个大类(is_valid_for_category()

你可以把它理解为:“DB 里是稳定字符串协议,代码里用 enum 提供字典与校验”。


3. 结合代码:读懂 Asset / Snapshot / Plan

3.1 Asset(资产条目)

文件:src/models/asset.rs

重点:

  • Asset::new(...) 是“构造器/工厂方法”,统一初始化 id/created_at/updated_at
  • notes: Option<String> 明确表达“可能没有备注”

典型构造方式(见测试代码):

  • 子资产类别:AssetSubCategory::CashMmf.code().to_string()
  • 金额:用 rust_decimal_macros::dec!() 避免浮点误差

3.2 Snapshot(盘点快照)

文件:src/models/snapshot.rs

关键设计:

  • SnapshotItem 会冗余存储 asset_name/scope/category/sub_asset_class/vehicle_type
    这样即使未来资产条目被改名/改分类,历史快照仍然能反映当时状态(可审计)。
  • Snapshot::new(items) 自动计算 total_value

3.3 AllocationPlan(配置方案)

文件:src/models/plan.rs

关键设计:

  • Allocation.category: Category 用 enum 表达“配置只能基于大类”
  • target_percentage: Decimal 表达比例,避免 f64 的误差
  • get_target(&Category) 提供便利查询(服务层/页面层都会用)

你可以把 Plan 理解成“目标权重向量”,而资产是“当前权重向量”,偏离分析就是两者差异。

对应偏离计算见:src/services/analysis_service.rs


4. 你需要掌握的建模技巧(从易到难)

4.1 用 enum 替代字符串魔法值

优先让业务状态成为 enum

  • 更可读
  • 更可 refactor
  • 更少“拼错字符串”的隐患

本项目里 DB 仍然需要字符串编码,但代码内部尽量用 enum 表达语义。

4.2 用 Option 表达“可缺失字段”

不要用空字符串 "" 代表“没有值”。
本项目里:

  • notes: Option<String>
  • AllocationPlan.description: Option<String>

4.3 用 Display / FromStr 把“协议转换”集中管理

一个很实用的工程习惯:

  • 解析/格式化逻辑不要散落在 Repository / UI 里
  • 集中在模型自身(例如 Category::from_strCategory::as_str

当前项目主要使用自定义的 from_str/as_str 方法;进阶阶段你可以把它们升级为实现标准 trait:

  • impl std::fmt::Display for Category
  • impl std::str::FromStr for Category

Category 已经实现了 Display


5. 本章练习(强烈建议做)

练习 A:给 VehicleType 实现 Display

当前 VehicleTypedisplay_name(),但没有实现 Display
你可以在 src/models/vehicle_type.rs 里增加:

  • impl std::fmt::Display for VehicleType { ... }

目标:让你习惯“把展示逻辑集中在类型上”,UI 里直接 {vehicle_type} 更自然。

练习 B:给 Asset 增加一个轻量校验方法 validate()

src/models/asset.rs 增加:

  • 校验 sub_asset_class 是否属于 category(用 AssetSubCategory::is_valid_for_category

这会让你理解:

  • 业务不变量写在模型里,而不是到处 scattered if
  • 校验失败最好用 Result<(), E> 表达(为下一章“错误处理”做铺垫)

练习 C(进阶):把 sub_asset_class: String 包装成 newtype

比如:

  • pub struct SubAssetClassCode(String);

好处:

  • 更明确的语义(避免把任意 String 当成 code)
  • 更容易集中实现校验/显示

代价:

  • 需要更新 DB 读写与序列化/反序列化逻辑

建议你先做 A/B,熟练后再挑战 C。

06 错误处理:Result / ? / thiserror(让代码可维护)

本章目标

  • 你能熟练使用 Option / Result / ? 来表达"可能失败/可能缺失"。
  • 你能区分"可以 panic 的地方"和"必须返回错误的地方"。
  • 你能看懂本项目的错误处理设计(已使用 thiserror 定义统一的 AppError)。

1. Rust 的错误处理哲学(和 JS/Java 的差别)

1.1 Rust 默认不鼓励用异常控制流程

Rust 当然也有 panic(相当于“不可恢复异常”),但日常工程里更推荐:

  • 可预期失败:用 Result<T, E>
  • 可缺失值:用 Option<T>

这让函数签名本身就能告诉你“调用者需要处理什么情况”。

1.2 ? 运算符:把错误向上传播

当你拿到 Result<T, E>

  • 成功:取出 T
  • 失败:立刻 return Err(E)

这就是 ? 的含义。

理解 ? 会极大提升你写 Rust 的速度,因为你不需要层层 match


2. Option vs Result:什么时候用哪个?

2.1 Option<T>:没有就是没有(不是“错误”)

例子:

  • notes: Option<String>:资产可以没有备注
  • find_latest() -> Result<Option<Snapshot>, String>:查询可能成功但“没有数据”

这里“没有数据”不是错误,所以用 Option

2.2 Result<T, E>:操作可能失败

例子:

  • DB 访问失败(IO/SQL/锁/约束)
  • 数据解析失败(Uuid/Decimal/DateTime 字符串不合法)
  • 业务校验失败(比例不等于 100,盘点次数超限等)

3. 本项目当前错误处理现状(你需要先读懂)

3.1 已实现:统一错误类型 AppError

本项目在 src/error.rs 中定义了统一的错误类型:

#![allow(unused)]
fn main() {
#[derive(Error, Debug)]
pub enum AppError {
    #[error("数据库错误: {0}")]
    Database(#[from] rusqlite::Error),

    #[error("数据解析错误: {0}")]
    Parse(String),

    #[error("{resource}未找到: {id}")]
    NotFound { resource: &'static str, id: String },

    #[error("{0}")]
    Business(String),

    #[error("IO错误: {0}")]
    Io(#[from] std::io::Error),

    #[error("数据库未初始化")]
    DatabaseNotInitialized,
}

pub type AppResult<T> = Result<T, AppError>;
}

Repository 层(如 AssetRepositoryPlanRepositorySnapshotRepository)现已返回 AppResult<T>,利用 ? 运算符自动转换 rusqlite::ErrorAppError::Database

3.2 待改进:row_to_xxx 仍使用 unwrap_or_default()

虽然 Repository 函数返回类型已升级,但在 row_to_asset 等内部方法中仍有:

  • Uuid::parse_str(&id).unwrap_or_default()
  • Decimal::from_str(&value_str).unwrap_or_default()

这会把"脏数据"悄悄变成默认值,导致后续分析结果不可信。详见第 4 节的改进建议。

3.3 UI 层目前多用 eprintln! 打印错误

例如 src/pages/assets.rs 保存失败时:

  • eprintln!("保存资产失败: {}", e);

这在学习期 OK,但如果想做成"可用的产品",需要把错误反馈到 UI(AppState.error 或 toast)。


4. 当前设计与改进方向

4.1 已完成:统一错误类型 AppError

本项目已在 src/error.rs 定义了应用级错误:

  • Database(rusqlite::Error):数据库操作错误
  • Parse(String):数据解析错误
  • NotFound { resource, id }:资源未找到
  • Business(String):业务规则错误
  • Io(std::io::Error):IO 错误
  • DatabaseNotInitialized:数据库未初始化

收益:

  • DB 层保留了结构化错误(自动从 rusqlite::Error 转换)
  • UI 层可以统一把 AppError 格式化成用户可读文本
  • 未来可做更细粒度处理(例如 NotFound 走黄色提示,Database 错误走红色)

4.2 待改进:不要用 unwrap_or_default() 静默吞掉解析失败

AssetRepository::row_to_asset 里,目前策略是:

  • Uuid::parse_str(&id).unwrap_or_default()
  • Decimal::from_str(&value_str).unwrap_or_default()

这会把"脏数据"悄悄变成默认值,导致后续分析结果不可信。

更好的策略(从易到难):

  1. 学习期(低成本):用 expect("...") 明确崩溃原因(至少别静默)
  2. 工程期(推荐):把解析失败转成 AppError::Parse(...) 向上传播

5. unwrap / expect / panic!:什么时候可以用?

经验规则(适合这个项目):

  • 测试代码:可以用 unwrap()(失败就失败,快速定位)
  • “绝不可能失败”的地方:可以用 expect("不可能失败的原因说明")
  • 用户输入/DB/IO:不要 panic,应该返回 Result 给上层处理

在本项目里,以下位置是“典型可改进点”:

  • Mutex::lock().unwrap():可用 expect("数据库连接锁被 poison") 至少增强可诊断性
  • parse_from_rfc3339(...).unwrap_or_else(|_| Utc::now()):会把时间解析失败吞掉,建议改为错误传播或显式 fallback 记录日志

6. 本章练习(建议按顺序做)

练习 A:把一个 unwrap_or_default() 改成显式错误

选择一个 Repository(例如 AssetRepository::row_to_asset),把:

  • Uuid::parse_str(&id).unwrap_or_default()

改为:

  • 学习期:Uuid::parse_str(&id).expect("assets.id 必须是合法 uuid")

你会马上感受到"更早失败、更容易定位"。

练习 B:把 row_to_asset 的解析错误传播出去

目标:利用已有的 AppError::Parse,把 row_to_asset 中的 unwrap_or_default() 升级为结构化错误传播。

建议步骤:

  1. 修改 row_to_asset 返回类型为 Result<Asset, AppError>(而非 rusqlite::Result<Asset>
  2. Uuid::parse_str(&id).unwrap_or_default() 改为:
    #![allow(unused)]
    fn main() {
    Uuid::parse_str(&id)
        .map_err(|_| AppError::parse(format!("无效的资产 ID: {}", id)))?
    }
  3. 同理处理 Decimal::from_strDateTime::parse_from_rfc3339
  4. 调整调用处(query_map 的闭包)以适配新的返回类型

注:AppError 已定义在 src/error.rs,Repository 已使用 AppResult<T>

练习 C:把错误展示到 UI(而不是只打印)

本项目 AppStateerror: Option<String> 字段。你可以在:

  • 写入失败时:state.write().error = Some(e.to_string())
  • 页面顶部:渲染一个小红条/Toast(先做最简单版本)

这会让你把错误处理从"工程内部"扩展到"产品体验"。

07 SQLite 与 rusqlite:Repository 模式与类型转换

本章目标

  • 你能读懂 asset-light 的数据库初始化/迁移/表结构。
  • 你能理解 rusqlite 的基本用法:参数化查询、preparequery_map、类型转换。
  • 你能看出当前实现的工程权衡点,并能做小步改进(例如事务、错误类型、测试)。

1. 本项目的 DB 初始化链路

1.1 初始化入口

入口:src/main.rs

  • 启动时先调用 db::init_database()
  • 通过后才启动 Dioxus UI

DB 初始化逻辑:src/db/mod.rs

  • OnceLock<Database> 保存全局单例
  • Database::new() 建立连接
  • db.run_migrations() 执行迁移
  • db.ensure_default_plan() 确保默认配置方案存在

1.2 DB 文件路径与环境变量

路径逻辑:src/db/connection.rsDatabase::db_path()

  • 支持 ASSET_LIGHT_DB_PATH 环境变量(用于测试/临时 DB)
  • 默认用 dirs::data_local_dir(),在 macOS 通常会落到用户目录的 Application Support 下

建议你把它当作“调试开关”记住:
想要一个干净 DB,不必删真实数据,只要换一个 DB 路径即可。


2. 迁移策略:schema_version 与破坏性升级

迁移函数:src/db/connection.rsDatabase::run_migrations()

关键点:

  • 创建 schema_meta 表维护 schema_version
  • 当前实现存在一次性“破坏性升级”:
    • current_version < 2 时会 DROP TABLE ...(会清空历史数据)
    • 然后重建 assets/snapshots/... 等表

这在“学习/快速迭代阶段”很合理(你能快速重塑数据结构),
但当你开始把它当成长期使用的个人工具时,应该升级为“非破坏性迁移”:

  • 新增列/表:ALTER TABLE ADD COLUMN ...
  • 数据回填:在迁移里做一次性 UPDATE
  • 保留历史数据:避免 drop

3. 表结构与字段编码(理解为什么都存 TEXT)

3.1 为什么大量字段使用 TEXT

SQLite 本身类型系统比较宽松,而 Rust 的类型系统很严格。
为了减少“数据库类型与 Rust 类型对齐”的复杂度,本项目选择:

  • Uuid:存 TEXT(uuid.to_string()
  • Decimal:存 TEXT(decimal.to_string(),避免浮点误差)
  • DateTime<Utc>:存 RFC3339 TEXT(to_rfc3339()
  • NaiveDate:存 YYYY-MM-DD TEXT(to_string()
  • enum:存稳定编码 TEXT(Category::as_str() / AssetScope::as_str() / VehicleType::as_str()

这让持久化变成“稳定字符串协议”,而解析逻辑集中在 Repository。

3.2 核心表(概念图)

(以 connection.rs 的建表 SQL 为准)

  • assets:资产条目
  • snapshots:盘点快照(主表)
  • snapshot_items:盘点快照明细
  • allocation_plans:配置方案(主表)
  • allocations:配置项(方案明细)
  • schema_meta:schema 版本

4. Repository 模式:把 SQL 与类型转换封装起来

本项目的 Repository 位于:src/db/*_repo.rs

共同特点:

  • 通过 get_database().with_conn(|conn| { ... }) 获取连接并执行闭包
  • SQL 一律使用参数化绑定(?1 ?2 ...),避免 SQL 注入与字符串拼接错误
  • 查询时使用 prepare + query_map,并在 row_to_xxx 中集中做类型转换

4.1 示例:AssetRepository(读写资产表)

文件:src/db/asset_repo.rs

常见方法:

  • insert(&Asset)
  • update(&Asset)
  • find_all() -> Vec<Asset>
  • find_by_id(Uuid) -> Option<Asset>

重点看 row_to_asset(row)

  • 把 DB 的字符串字段解析成 Uuid/Decimal/DateTime
  • scope/category/vehicle_type 的编码解析回 enum

4.2 示例:SnapshotRepository(快照主表 + 明细表)

文件:src/db/snapshot_repo.rs

值得注意的点:

  • find_all() 先查快照主表,再按 snapshot_id 查明细
  • 明细查询用内部函数 find_items_internal(conn, snapshot_id),复用同一连接

这种写法的优点是实现简单,但数据量很大时会产生 N+1 查询问题。
学习期先接受它,后续可用 join/批量查询优化。

4.3 示例:PlanRepository(方案与配置项)

文件:src/db/plan_repo.rs

值得注意的点:

  • set_active(plan_id) 先把所有方案置为非激活,再激活目标方案(保证“只有一个激活”)
  • 方案更新时先删旧 allocations 再插入新 allocations(简单可靠)

5. 事务(Transaction):让“多步写入”变成原子操作

一个非常典型的改进点:SnapshotRepository::create(snapshot)

它会:

  1. 插入 snapshots
  2. 循环插入多条 snapshot_items

如果中途失败,就会出现“主表写进去了,明细只写了一部分”的不一致。
更稳健的方式是使用事务:

  • BEGIN
  • 全部 insert 成功 → COMMIT
  • 任一步失败 → ROLLBACK

rusqlite 支持 conn.transaction()(你可以把这作为本章的进阶练习)。


6. 本章练习(从小到大)

练习 A:用一个临时 DB 跑通 CRUD

  1. ASSET_LIGHT_DB_PATH=/tmp/asset-light-dev.db 启动应用
  2. 新增资产 → 关闭 → 再启动 → 确认数据仍在
  3. 删除资产 → 再启动 → 确认删除生效

练习 B:给 SnapshotRepository::create 加事务(进阶)

目标:让创建快照“要么全成功,要么全失败”,避免半写入。

练习 C:写一个最小集成测试(进阶)

思路:

  • 测试里设置 ASSET_LIGHT_DB_PATH 指向临时文件
  • 调用 db::init_database()
  • 调用 AssetRepository::insert/find_all 断言结果

你会同时练到:

  • 环境变量注入
  • DB 初始化
  • Repository 行为验证

08 Dioxus 入门:组件、props、Signal、路由与数据流

本章目标

  • 你能读懂 Dioxus 的组件写法(#[component] + rsx!)。
  • 你能理解本项目的路由结构与布局组件。
  • 你能掌握 Signal 的基本用法:全局状态(Context)与页面局部状态(use_signal)。
  • 你能在 asset-light 里新增一个简单页面/组件并接入导航。

1. Dioxus 的核心概念(用 React 类比)

如果你熟悉 React,可以这样对照:

  • 组件函数:React function component ⇔ #[component] fn Foo() -> Element
  • JSX:JSX ⇔ rsx! { ... }
  • Props:props ⇔ #[derive(Props)] 的 struct 或组件参数
  • State:useState ⇔ use_signal
  • Effect:useEffect ⇔ use_effect
  • Router:react-router ⇔ dioxus_routerRoutable + Router

2. 项目路由与布局

2.1 根组件

文件:src/app.rs

关键点:

  • provide_app_state() 注入全局状态(Context)
  • Router::<Route> {} 渲染路由

2.2 路由枚举

文件:src/router.rs

你会看到:

  • #[derive(Routable)] pub enum Route { ... }
  • #[layout(Layout)]:用布局组件包裹多个页面

这相当于:所有页面都嵌在同一个 Layout(含 Sidebar)中。

2.3 Sidebar 导航

文件:src/components/layout/sidebar.rs

关键点:

  • Link { to: Route::HomePage {}, ... } 实现导航
  • use_route::<Route>() 获取当前路由
  • std::mem::discriminant 做“当前页面高亮”(按 enum variant 判断)

3. 状态管理:全局 Signal 与局部 Signal

3.1 全局状态 AppState

文件:src/state/app_state.rs

  • provide_app_state():提供 Signal<AppState>
  • use_app_state():在任意组件中获取它

你会在页面里看到典型写法:

  • let mut state = use_app_state();
  • let assets = state.read().assets.clone();
  • state.write().assets = new_assets;

关键学习点(和所有权/借用相关):

  • .read() / .write() 持有内部借用句柄,尽量缩短作用域
  • UI 渲染往往会 clone() 数据用于展示(学习期可以接受)

3.2 页面局部状态:use_signal

典型场景:模态框开关、编辑态、选择态。

例如 src/pages/assets.rs 里:

  • show_form: Signal<bool> 控制表单 modal
  • editing_asset: Signal<Option<Asset>> 当前编辑的资产
  • inventory_mode: Signal<bool> 盘点模式开关

你可以把它类比为 React 的:

  • const [showForm, setShowForm] = useState(false)

4. 副作用:加载数据与刷新

4.1 use_effect:在渲染之外做 IO

例子:src/pages/history.rs / src/pages/analysis.rs 使用 use_effect 在初始渲染时加载 DB 数据。

这类逻辑建议尽量放在 use_effect 里,避免“在 render 路径里写 state”导致的重复执行或难排查问题。

4.2 本项目里的一种“loaded flag”模式

你会在 src/pages/home.rs 看到:

  • loaded: Signal<bool> 保护“只执行一次”的加载逻辑

这在学习期能跑通,但从工程最佳实践看,你可以把它作为练习:

把 HomePage 的初始加载重构为 use_effect,并理解它与 loaded-flag 的差别。


5. 事件处理与回调(EventHandler)

在 Dioxus 里,事件回调通常是 closure:

  • on_click: move |_| { ... }

而组件间传递回调,常用 EventHandler<T>(你会在多处组件 props 中见到)。

这和 React 传 onSave={(x) => ...} 很像,但 Rust 的 closure 需要更明确的所有权移动(move)。


6. 本章练习(强烈推荐做)

练习 A:新增一个“设置页”占位并接入导航

目标:走一遍“新增页面 → 路由 → Sidebar”的完整流程。

建议步骤:

  1. 新建 src/pages/settings.rs,写一个最小页面组件(只显示标题即可)
  2. src/pages/mod.rs 里导出它
  3. src/router.rsRoute enum 增加一个 SettingsPage {} 路由
  4. src/components/layout/sidebar.rs 增加一个 NavItem 链接

练习 B:在页面顶部渲染一个全局错误提示条

目标:把上一章的“错误处理”落到 UI 体验中。

思路:

  • AppStateerror: Option<String>
  • 在 Layout 或每个页面顶部,如果有 error 就渲染一个红色 banner,并提供关闭按钮(清空 error)

练习 C(进阶):把 HomePage 的加载逻辑改成 use_effect

目标:理解“副作用与渲染”的边界,避免重复加载与不可控状态写入。

09 练习清单与进阶路线(把 Rust 用熟)

本章目标

  • 给你一条“可持续推进”的练习路线:每次 30-90 分钟,小步可验证。
  • 让你在 asset-light 这个真实项目里,逐步从“能改”到“改得稳、改得优雅”。

1. 你已经具备的能力(完成前 8 章后)

如果你已经按顺序读完并至少做过每章 1 个练习,你应该能:

  • 看懂模块树,能新增模块/文件并正确挂载
  • 用所有权/借用解决常见编译错误
  • 读懂并扩展 struct/enum/derive/Option/Result
  • 能在 rusqlite 里新增查询/更新逻辑
  • 能在 Dioxus 里新增页面/组件并接入路由与导航

2. 练习路线(从 P0 到 P2)

P0(1-2 天):把“工程节奏”练熟

目标:形成稳定开发闭环(fmt → check → run)。

  • 练习 1:新增 Settings 页面并接入 Sidebar(08 章练习 A)
  • 练习 2:给 VehicleType 实现 Display(05 章练习 A)
  • 练习 3:把一个 unwrap_or_default() 改成显式错误(06 章练习 A)

验收标准:

  • 每次改动后能 cargo check 通过
  • 能解释每个改动在模块树中的位置

P1(3-5 天):把“数据一致性与错误反馈”做扎实

目标:让 app 更像产品,而不是 demo。

  • 练习 4:给 SnapshotRepository::create 增加事务(07 章练习 B)
  • 练习 5:把错误展示到 UI(08 章练习 B)
  • 练习 6:给 Asset 增加 validate() 并在表单保存前校验(05 章练习 B + 06 章)

验收标准:

  • 创建快照不会出现半写入
  • 用户能在 UI 看到明确错误提示(而不是只看控制台)
  • 表单能拦截明显不合法输入(例如 sub_asset_class 与 category 不匹配)

P2(1-2 周):工程化升级(选做,但非常适合学习)

目标:把项目从“能用”升级为“可维护、可演进”。

  • 练习 7:引入 AppError(thiserror),逐步替换 Result<T, String>(06 章练习 B)
  • 练习 8:补一套最小集成测试(DB + Repository)(07 章练习 C)
  • 练习 9:把 HomePage 的 loaded-flag 加载方式改成 use_effect(08 章练习 C)
  • 练习 10:为导出/备份准备接口(例如导出 JSON/CSV)(产品向扩展)

验收标准:

  • DB 层错误类型结构化,可定位、可分支处理
  • 有基本测试护航,重构不慌
  • 页面加载逻辑不会出现重复执行/难排查副作用

3. 你下一步应该读什么(学习素材建议)

按你在项目里遇到的困难选择阅读方向:

  • 如果你经常卡在 borrow:继续加强所有权/借用(04),同时练习缩小借用作用域
  • 如果你经常卡在类型转换:加强建模(05)与错误处理(06),让解析失败显式化
  • 如果你想提升工程质量:把 Result<T, String> 升级为 AppError,并补测试
  • 如果你想提升 UI 组织:把样式与组件拆分更清晰,减少页面文件过长

4. 一个推荐的“每周节奏”(适合持续学习)

  • 周一/周二:做 1 个 P0 练习 + 复盘编译器报错(把报错记成笔记)
  • 周三/周四:做 1 个 P1 练习(事务/校验/错误 UI)
  • 周末:做 1 个 P2 练习(错误类型/测试/重构)

坚持 2-4 周,你会非常明显地从“写得慢”变成“写得稳且有把握”。

asset-light 项目技术文档

适用场景

  • 你要维护/扩展 asset-light(新增页面、功能、数据结构、优化性能等)。
  • 你要理解当前实现(模块划分、数据流、数据库 schema、UI 状态管理)。
  • 你要把项目当作 Rust 学习素材,但希望先有“工程地图”。

文档目录

与规划/设计文档的关系

docs/ 下已有 PRD、UI 设计、架构设计等文档(主要回答“为什么做 / 做什么”)。
本目录的技术文档更偏向“怎么做 / 现在怎么做 / 怎么改更稳”。

项目总览:从代码看 asset-light

技术栈与依赖(以当前代码为准)

  • UI:Dioxus Desktop(Rust 声明式 UI)
  • 本地数据库:SQLite(rusqlite,嵌入式,无需服务端)
  • 序列化serde
  • 时间chrono
  • IDuuid
  • 金额/比例rust_decimal(避免浮点误差)

依赖定义见:Cargo.toml

运行入口

  • 应用入口:src/main.rs
    • 初始化数据库:db::init_database()
    • 启动 UI:dioxus::launch(app::App)
  • 根组件:src/app.rs
    • 提供全局状态:provide_app_state()
    • 路由渲染:Router::<Route> {}

目录结构(核心)

  • src/main.rs:进程入口,初始化依赖(数据库)后启动 UI
  • src/app.rs:根组件,注入全局状态并挂载路由
  • src/router.rs:路由枚举(页面入口 + layout 包裹)
  • src/error.rs:统一错误类型 AppErrorAppResult<T>
  • src/pages/*:页面(Home / Assets / History / Plans / Analysis)
  • src/components/*:页面内部 UI 组件(按业务域拆分)
  • src/models/*:领域模型(资产分类体系、资产/快照/方案等)
  • src/db/*:数据库访问层(连接、迁移、Repository,返回 AppResult<T>
  • src/state/*:全局状态(Dioxus Signal Context)
  • src/services/*:业务计算/服务(例如分析计算)
  • src/utils/*:格式化等工具函数

分层与职责(建议的理解方式)

  • UI 层(components/pages/router/app)
    负责渲染与交互,不直接拼 SQL;从 Repository 拉取数据或触发写入;在需要时更新 AppState
  • 数据访问层(db/ + repositories)*
    负责 SQL、类型转换与持久化规则(例如字符串到 Decimal / Uuid / chrono 的解析)。
  • 领域模型(models/*)
    负责“数据结构 + 稳定编码”,例如资产大类 Category、口径 AssetScope、工具类型 VehicleType、子资产类别编码等。
  • 业务服务层(services/*,可选)
    负责跨模块的计算逻辑,避免把复杂计算堆进 UI。

数据库位置与环境变量

数据库文件路径由 dirs::data_local_dir() 决定(macOS 通常在用户目录下的 Application Support)。
项目支持通过环境变量指定 DB 路径(便于测试/调试):

  • ASSET_LIGHT_DB_PATH:指向一个 sqlite 文件路径

对应实现见:src/db/connection.rsDatabase::db_path()

迁移策略(重要)

当前迁移逻辑内置于 src/db/connection.rsDatabase::run_migrations()
会维护 schema_meta 表中的 schema_version,并在版本升级时进行表结构处理。

注意:当 schema_version < 2 时,迁移会执行 DROP TABLE(会清空历史数据)。
这适用于早期迭代阶段“允许破坏性升级”的策略,若要进入长期使用阶段,需要改为真正的“非破坏性迁移”。

扩展一个新功能的建议切入点

以“新增一个页面/功能”为例,推荐按顺序改动:

  1. models:先定义稳定的数据结构与编码(必要时为 DB 做准备)
  2. db:增加/扩展 Repository 方法(读写与类型转换)
  3. pages/components:增加 UI 与交互
  4. state/services:补全全局状态同步或业务计算(需要时)

数据库与迁移(SQLite / schema_version / Repository)

1) 快速定位

  • 初始化入口src/main.rsdb::init_database()
  • DB 单例src/db/mod.rsOnceLock<Database> + get_database()
  • 连接与迁移src/db/connection.rsDatabase::new/run_migrations/ensure_default_plan
  • Repositorysrc/db/asset_repo.rssrc/db/snapshot_repo.rssrc/db/plan_repo.rs

2) DB 文件路径与环境变量

路径规则见:src/db/connection.rsDatabase::db_path()

  • 默认dirs::data_local_dir() 下的 asset-light/data.db
  • 可覆盖:环境变量 ASSET_LIGHT_DB_PATH=/path/to/data.db

建议:

  • 调试/测试优先用 ASSET_LIGHT_DB_PATH 指向临时 DB,避免污染真实数据。

3) 迁移策略(重要:当前包含破坏性升级)

迁移入口:Database::run_migrations()src/db/connection.rs

实现要点:

  • schema_meta 表维护 schema_version
  • schema_version < 2 时,会执行 DROP TABLE ... 重建表结构(会清空历史数据)

适用场景:

  • 学习期/快速迭代期:允许破坏性升级以快速调整数据模型

不适用场景:

  • 长期使用:需要演进为“非破坏性迁移”(新增列/表、回填数据、保留历史记录)

4) 表结构概览(以 connection.rs 为准)

4.1 assets

资产条目。字段类型以 TEXT 为主:

  • id:UUID string
  • scopeINVESTABLE / NON_INVESTABLE
  • categoryCASH/FI/EQ/...
  • sub_asset_class:稳定编码(如 EQ_BROAD
  • vehicle_typeETF/MUTUAL_FUND/...
  • current_value:Decimal string
  • created_at/updated_at:RFC3339

4.2 snapshots / snapshot_items

快照主表 + 明细表:

  • 主表记录盘点时间与总额
  • 明细表冗余存储资产当时的分类/口径/工具类型与价值(保证历史可审计)

4.3 allocation_plans / allocations

方案主表 + 配置项表:

set_active() 会强制保证“仅一个激活方案”。


5) 编码与类型转换(Rust ⇄ SQLite)

本项目采用"稳定字符串协议"的策略:

  • Uuid ⇄ TEXT
  • Decimal ⇄ TEXT(避免浮点误差)
  • DateTime<Utc> ⇄ RFC3339 TEXT
  • NaiveDateYYYY-MM-DD TEXT
  • enum ⇄ 稳定编码 TEXT(as_str/from_str

转换逻辑集中在 Repository 的 row_to_xxx 与模型的 as_str/from_str

已完成改进:Repository 层现已使用 AppResult<T>(定义在 src/error.rs),DB 错误自动转换为 AppError::Database

待改进row_to_xxx 内部仍使用 unwrap_or_default() 静默吞解析失败,建议升级为 AppError::Parse 传播(详见 docs/rust-guide/06-error-handling.md)。


6) 原子性与事务(建议改进点)

SnapshotRepository::create() 会写入 1 条主表 + N 条明细。

建议:

  • 使用 rusqlite transaction,保证要么全部成功,要么全部失败

7) 数据重置/备份建议

学习期最简单的“重置数据”方式:

  • 换一个 DB 路径(ASSET_LIGHT_DB_PATH 指向新文件)

长期使用期建议:

  • 增加导出(JSON/CSV)与备份策略
  • 将破坏性迁移替换为非破坏性迁移

状态与数据流(页面加载策略、全局状态、刷新边界)

1) 快速定位

  • 全局状态定义src/state/app_state.rs
  • 状态注入src/app.rsprovide_app_state()
  • 页面数据加载src/pages/*
  • 数据访问src/db/*_repo.rs

2) AppState 的职责与边界

AppStatesrc/state/app_state.rs)包含:

  • assets: Vec<Asset>
  • snapshots: Vec<Snapshot>
  • plans: Vec<AllocationPlan>
  • active_plan: Option<AllocationPlan>
  • loading: bool
  • error: Option<String>
  • is_inventory_mode: bool(当前更多是 UI 语义,部分页面也有局部 state)

推荐理解方式:

  • 数据库是事实来源(source of truth)
  • AppState 是 UI 缓存(cache)
    • 读 DB 后写入 state,页面渲染从 state 读取
    • 写 DB 成功后刷新 state(避免脏 UI)

3) 目前的加载策略(以代码为准)

不同页面采用了不同的“初始加载”风格:

  • HistoryPage / AnalysisPage:使用 use_effect 在初始渲染时加载 DB 数据
  • HomePage:使用 loaded signal 做“一次性执行”保护,并在渲染路径中读取 DB(学习期能跑通,但工程上更推荐 use_effect)
  • AssetsPage:更多依赖 AppState.assets,并在保存/删除后触发 refresh
  • PlansPage:激活/保存/删除后重新拉取 plans 并写入 state

这说明项目仍处于“可快速迭代”的阶段,统一数据加载层(Service/Store)仍有空间。


4) 推荐的数据流模式(适合逐步演进)

4.1 最小可控模式(现阶段推荐)

  • 页面初始化:从 Repository 拉数据 → 写入 AppState
  • 发生写操作(保存/删除/激活):写 DB 成功 → 重新查询 → 写入 AppState

优点:

  • 简单可靠,不需要复杂缓存一致性策略

缺点:

  • 重复查询较多(但对个人资产规模通常足够)

4.2 进阶模式(后续可做)

  • 引入 services/* 作为业务入口:
    • 页面只调用 service
    • service 负责 DB + 校验 + 错误类型 + 刷新 state

这会显著减少页面层的重复逻辑。


5) 与所有权/借用相关的注意点(Dioxus Signal)

Signal<AppState>.read()/.write() 会持有内部借用句柄。

建议:

  • 缩小借用作用域:读完就 drop,再写
  • 必要时 clone:UI 渲染需要拥有数据(尤其跨作用域/闭包)时,用 clone 换取简单性
  • 避免在渲染路径写 state:优先 use_effect

6) 典型业务流(从入口到落库)

6.1 新增资产

  • UI:AssetsPageAssetFormsrc/pages/assets.rs / src/components/asset/asset_form.rs
  • 写库:AssetRepository::insert(&asset)
  • 刷新:AssetRepository::find_all()state.write().assets = ...

6.2 发起盘点(生成快照)

  • UI:InventoryModesrc/components/snapshot/inventory_mode.rs
  • 写库:SnapshotRepository::create(&snapshot) + 更新资产市值(若实现)
  • 刷新:重新读取 assets/snapshots 写入 state

6.3 切换激活方案

  • UI:PlansPage 触发
  • 写库:PlanRepository::set_active(plan_id)
  • 刷新:PlanRepository::find_all() → 更新 state.plans(并推导 active_plan

7) 建议的可观测性(学习期非常有帮助)

  • 把写库失败写入 state.error 并在 Layout 顶部展示(而不是只打印)
  • 对重要操作(创建快照、激活方案)记录一条可读日志(先 println!,后续可引入日志库)

UI 与路由(Dioxus 组件组织、路由结构、页面职责)

1) 快速定位

  • 根组件src/app.rs
  • 路由定义src/router.rs
  • 布局组件src/components/layout/mod.rs
  • (备用)Sidebar 组件src/components/layout/sidebar.rs
  • 页面src/pages/*
  • 业务域组件src/components/{asset,snapshot,plan,analysis,dashboard,common}/*

2) 路由结构(以代码为准)

路由枚举:src/router.rsRoute

特点:

  • 使用 #[layout(Layout)] 包裹多个页面
  • 每个页面是一个 enum variant(HomePage {} / AssetsPage {} 等)

这意味着:

  • Layout 会始终渲染(侧边栏/容器/背景等属于 Layout)
  • 页面内容通过 Outlet::<Route> {} 注入

3) Layout 与 Sidebar:当前实现的实际情况

目前存在两套“侧边栏”相关实现:

  1. Layout 内联导航(当前实际使用)
    文件:src/components/layout/mod.rs
    Layout 自己写了一个 nav { Link { ... } } 的简化版本导航。

  2. Sidebar 组件(目前未接线/未使用)
    文件:src/components/layout/sidebar.rs
    具备更完整的 UI 与 active 高亮逻辑(使用 use_route + discriminant)。

建议(未来可做的重构):

  • Layout 直接使用 Sidebar {} 组件,避免重复实现导航与样式分叉。

4) 页面职责(建议的理解方式)

4.1 Home(首页)

文件:src/pages/home.rs

职责:

  • 总览统计(总资产、资产数、当前方案)
  • 配置对比与偏离提示(基于可再平衡口径)
  • 投资建议(增配/减配)

4.2 Assets(资产管理)

文件:src/pages/assets.rs

职责:

  • 资产列表(按类别分组)
  • 新增/编辑/删除资产(modal + confirm)
  • 发起盘点(进入 inventory mode)

4.3 History(盘点历史)

文件:src/pages/history.rs

职责:

  • 展示快照时间线
  • 查看快照详情(detail modal)

4.4 Plans(配置方案)

文件:src/pages/plans.rs

职责:

  • 方案列表
  • 创建/编辑/删除
  • 切换激活方案(保证仅一个激活)

4.5 Analysis(收益分析)

文件:src/pages/analysis.rs

职责:

  • 周期筛选快照
  • 计算收益、归因与趋势(基于快照数据)
  • 空状态处理(不足两次快照时提示)

5) 组件组织方式

src/components/ 按业务域拆分:

  • common/:按钮、输入框、modal、confirm 等通用 UI
  • asset/:资产表单、列表、条目、分组等
  • snapshot/:盘点模式、时间线、快照卡片、详情等
  • plan/:方案列表、卡片、编辑器等
  • analysis/:周期选择、趋势图、归因表等

建议:

  • 页面只负责拼装与数据流协调
  • 组件尽量纯粹(props → UI),减少直接访问 DB/全局状态

6) 常见 UI 改动的推荐切入点

  • 新增一个“业务域组件”:放在对应 domain 目录,并在 mod.rs 导出
  • 新增页面:src/pages/xxx.rs + src/pages/mod.rs 导出 + src/router.rs 增加 Route + Layout/Sidebar 增加 Link
  • 通用交互(toast/banner/loading):优先做在 Layout 或 common 组件中统一管理

编码约定(模块边界、命名、错误处理、可测试性)

1) 目标

本约定的目标是让项目在持续迭代中保持:

  • 可读(容易理解)
  • 可改(容易扩展)
  • 可测(容易验证)
  • 可诊断(出问题容易定位)

2) 模块边界(建议的分层)

  • models/:领域模型 + 稳定编码/解析/展示逻辑(不要在这里写 SQL)
  • db/:SQL + 类型转换 + Repository(不要在这里写 UI)
  • services/:跨模块业务计算(偏离/收益等),避免页面堆业务逻辑
  • state/:全局状态(Signal Context),尽量保持“数据结构 + 少量纯函数”
  • pages/:页面级装配与数据流协调(加载/刷新/路由入口)
  • components/:可复用 UI 组件(尽量纯 props 驱动)
  • utils/:与业务无关的工具函数(格式化、通用 helper)
  • error.rs:统一错误类型 AppErrorAppResult<T>

3) 命名与可读性(贴近《代码整洁之道》)

  • 函数名优先用动词短语:
    • find_allfind_by_idset_active
  • 布尔变量表达意图:
    • is_activeinventory_mode
  • 避免缩写与魔法字符串:
    • 类别/口径/工具类型使用 enum + as_str/from_str

4) 错误处理(当前设计与改进方向)

项目已在 src/error.rs 定义统一错误类型:

  • AppError(使用 thiserror
  • type AppResult<T> = Result<T, AppError>

Repository 层(src/db/*_repo.rs)现已返回 AppResult<T>

原则:

  • 不要静默吞错(例如 row_to_xxx 中的 unwrap_or_default() 把脏数据变默认值,仍待改进)
  • 用户输入/DB/IO 必须返回 Result,不要 panic
  • 测试里可以 unwrap,但要保证失败信息可读(expect 更好)

5) 数据建模与持久化

原则:

  • DB 里存稳定编码(TEXT)
  • 模型负责提供编码 ↔ enum 的转换函数
  • 重要不变量写进模型(例如 Asset::validate() / 方案占比合计 100%)

6) Dioxus 组件与状态管理

建议:

  • 页面负责“加载/刷新”,组件负责“渲染/交互”
  • 尽量不要在渲染路径写 state(优先 use_effect
  • 缩小 .read() / .write() 的借用作用域,避免借用冲突

7) 测试策略(学习期可从最小集开始)

建议从两类测试开始:

  • 模型纯函数测试AssetSubCategory::from_codeCategory::from_str、收益/偏离计算
  • DB 集成测试:设置 ASSET_LIGHT_DB_PATH 指向临时文件,跑 init_database,验证 CRUD

目标不是追求覆盖率,而是建立“重构有安全网”的能力。

常见问题排查(构建、数据库、数据重置、调试)

1) 构建/依赖下载失败

现象:

  • cargo build / cargo run 下载 crates 失败
  • 报错类似 “couldn't resolve host / failed to download”

原因:

  • 网络环境无法访问 crates.io 或 DNS/代理问题

解决建议:

  • 确认网络可访问 crates(必要时配置代理)
  • CI 环境确保网络稳定
  • 先用 cargo check 快速定位类型问题(避免每次都 full build)

2) 应用能启动,但数据“看起来丢了”

常见原因:

  • 你切换了 DB 路径(设置了 ASSET_LIGHT_DB_PATH
  • 迁移发生了破坏性升级,表被 drop(学习期允许)

排查步骤:

  1. 检查是否设置了 ASSET_LIGHT_DB_PATH
  2. 查看 src/db/connection.rsrun_migrations() 是否 drop 过表(schema_version < 2

3) 想要重置数据(不影响真实 DB)

推荐方式:换一个临时 DB 路径启动

ASSET_LIGHT_DB_PATH=/tmp/asset-light-dev.db cargo run

4) UI 没刷新/数据没更新

排查思路:

  • 写库成功后是否刷新 state?
    • 例如保存资产后是否调用 AssetRepository::find_all() 并写入 state.assets
  • 是否存在“读写借用作用域过长”导致逻辑没执行到?
    • 尽量缩小 .read()/.write() 的使用范围

5) 快照创建中途失败,数据不一致

现状:

  • SnapshotRepository::create() 是“多步写入”(主表 + N 条明细)
  • 目前未显式使用事务

建议:

  • 用 rusqlite transaction 改为原子操作(见 docs/rust-guide/07-sqlite-and-rusqlite.md

6) 如何快速定位某个功能在哪实现

建议从“页面入口”反查:

  • 路由:src/router.rs
  • 页面:src/pages/*.rs
  • 页面组合组件:src/components/*
  • 数据访问:src/db/*_repo.rs
  • 领域模型:src/models/*
  • 计算逻辑:src/services/*

7) 调试建议(学习期最实用)

  • 先用 println!/eprintln! 快速定位数据流(之后再引入日志库)
  • 在关键写入点打印:
    • “写库前数据是什么”
    • “写库结果是什么”
    • “刷新 state 后数据长度/关键字段是什么”

Product Brief: asset-light

Date: 2025-12-20 Author: BMad Context: 个人项目 + 技术学习


Executive Summary

asset-light 是一款基于 Dioxus (Rust) 技术栈的个人资产管理桌面应用,旨在解决个人投资者在资产盘点、变动追踪和配置优化过程中遇到的效率和洞察力问题。

核心用户痛点是:现有的表格工具(如飞书表格/Excel)操作繁琐,无法追踪资产历史变化,也无法分析各项资产的真实收益贡献。用户需要一个能够引导资产配置向目标方案靠近实时监控配置偏离度、并提供收益归因分析的专业工具。

该项目同时承载技术学习目标——通过实战项目深入掌握 Dioxus/Rust 桌面应用开发生态。


Core Vision

Problem Statement

当前状态:

个人投资者使用飞书表格/Excel 管理资产时面临以下挑战:

  1. 操作效率低:每次资产盘点需要手动更新多个单元格,缺乏结构化的数据录入流程
  2. 历史追踪缺失:资产的历史变化(买入/卖出/调仓/分红等)没有被系统记录,无法回溯分析
  3. 收益归因不清:无法区分哪些资产在产生收益,哪些在拖后腿,整体收益来源不透明
  4. 配置管理困难
    • 无法直观对比当前配置与目标配置的偏离程度
    • 缺乏再平衡提醒和建议
    • 难以执行"高抛低吸"的纪律性投资策略

核心矛盾:

用户拥有成熟的资产配置理念(如核心+卫星策略),但缺乏一个能够将理念转化为可执行、可追踪、可分析的系统化工具

Why Existing Solutions Fall Short

现有方案不足之处
飞书表格/Excel操作繁琐、无历史追踪、无分析能力、需要大量手动计算
记账类 APP侧重消费记录,资产管理功能弱,无法自定义配置方案
券商/银行 APP账户分散、无法聚合全部资产、无配置偏离分析
专业资管软件面向机构,过于复杂,对个人用户不友好

Proposed Solution

asset-light 将提供:

  1. 结构化资产盘点:按资产类别(现金类/稳健类/进阶类)快速录入和更新资产
  2. 全量历史追踪:记录每一笔资产变动,支持时间线回溯
  3. 收益归因分析:清晰展示各资产/类别的收益贡献
  4. 配置偏离监控:可视化对比当前配置与目标配置,自动计算偏离度
  5. 再平衡建议:基于偏离阈值提供调仓建议,帮助用户维持投资纪律

Key Differentiators

  • 个人视角:专为个人投资者设计,聚焦"一个人的资产全景"
  • 配置驱动:以目标配置方案为核心,所有功能围绕"向目标靠近"展开
  • 本地优先:Rust 桌面应用,数据本地存储,隐私安全
  • 轻量专注:不做记账、不做社交,只做资产管理这一件事

Target Users

Primary Users

用户画像:

  • 个人投资者,有一定的理财知识和资产配置理念
  • 当前使用表格类工具管理资产,但感到效率低下
  • 资产分布在多个平台(银行、券商、支付宝、理财平台等)
  • 希望实现资产的聚合视图和系统化管理
  • 有纪律性投资的意愿,需要工具辅助执行

行为特征:

  • 每月/每季度进行资产盘点
  • 关注资产增值而非日常消费
  • 有明确的资产配置目标(如核心+卫星策略)
  • 偏好桌面应用的专注和效率

核心诉求:

  • 盘点资产时快速、准确、省心
  • 随时了解当前配置与目标的差距
  • 清晰看到哪些资产在赚钱

MVP Scope

Core Features(MVP 必备)

1. 资产盘点模块

功能说明
资产条目管理新增/编辑/删除资产条目
资产分类按类别组织:现金类、稳健类、进阶类(支持自定义子类别)
快照式盘点手动输入当前市值,系统自动记录盘点时间戳
盘点历史保存每次盘点的完整快照,支持历史查看

2. 配置方案模块

功能说明
多方案支持用户可自定义多套目标配置方案
层级配置支持核心/卫星两层结构,每层可细分子类别和占比
方案切换可切换当前激活的目标方案

3. 资产视图模块

功能说明
总览视图展示资产总额、各类别占比
配置对比当前配置 vs 目标配置的可视化对比
偏离提示显示各类别的偏离百分比(如"稳健类偏离 +5%")

4. 收益分析模块

功能说明
周期收益计算季度/年度收益率
分类收益各资产类别的收益贡献对比
历史趋势资产总额的历史变化曲线

Out of Scope for MVP(暂不实现)

功能延后原因
自动获取净值需要对接外部 API,增加复杂度
分红/调仓记录MVP 采用快照模式,简化数据模型
具体调仓建议"卖出 XX 基金 X 元" 需要更复杂的计算逻辑
月度收益分析MVP 聚焦季度/年度周期
多端同步本地优先,暂不考虑云同步
导入/导出可作为后续迭代功能

MVP Success Criteria

  • 用户能在 3 分钟内 完成一次资产盘点(10 个资产条目以内)
  • 用户能清晰看到 当前配置与目标配置的偏离情况
  • 用户能查看 季度/年度收益率 及各类别收益贡献
  • 用户能 自定义至少 2 套 配置方案并切换
  • 应用启动速度 < 2 秒,操作响应流畅

Future Vision(后续迭代方向)

Phase 2:精细化追踪

  • 支持分红、调仓、申购/赎回等操作记录
  • 真实收益率计算(考虑资金流入流出)

Phase 3:智能化

  • 自动获取基金净值
  • 具体再平衡调仓建议
  • 配置健康度评分

Phase 4:生态扩展

  • 数据导入/导出(CSV、JSON)
  • 投资组合分析报告生成
  • 可选的云同步功能

Technical Preferences

技术栈选择

层面技术选型选择理由
框架DioxusRust 生态的现代 UI 框架,支持桌面应用
语言Rust性能、安全性、学习目标
平台Desktop (macOS 优先)专注沉浸式操作体验
数据存储本地文件 (SQLite/JSON)隐私优先,离线可用

技术学习目标

本项目同时作为 Dioxus/Rust 生态的学习载体,期望掌握:

  • Dioxus 组件化开发模式
  • Rust 桌面应用的数据持久化方案
  • 状态管理与响应式 UI
  • Rust 项目工程化实践

Success Metrics

产品成功指标

作为个人项目,成功的定义是双重的

维度一:实用性(自己用起来比飞书表格更顺手)

指标目标
盘点效率单次盘点时间 < 飞书表格的 50%
使用频率每季度至少进行 1 次完整盘点
功能覆盖能完全替代飞书表格的资产管理功能
数据洞察能回答"哪些资产在赚钱"这个问题

维度二:技术学习(走通 Dioxus 桌面应用开发流程)

指标目标
项目完整性完成从 0 到 1 的桌面应用开发全流程
技术掌握熟练使用 Dioxus 组件、状态管理、数据持久化
代码质量遵循 Rust 最佳实践,代码结构清晰
可维护性后续迭代时能快速理解和修改代码

Asset Classification Reference

目标配置结构(核心+卫星策略)

资产总览
├── 核心资产 (70%-80%) ─────────────────────────────────────
│   │
│   ├── 稳健固收类 (40%-50%)
│   │   ├── 大额存单/国债/国债逆回购 (10%-15%)
│   │   │   └── 安全垫,绝对安全 + 高流动性
│   │   ├── 银行理财-中低风险 (15%-20%)
│   │   │   └── R2/R3 等级,收益稳定
│   │   └── 债券型基金/纯债基金 (15%)
│   │       └── 长期持有,波动小,可考虑"固收+"
│   │
│   └── 长期权益类 (30%)
│       ├── 宽基指数基金定投 (20%)
│       │   └── 沪深300、中证500,平滑风险
│       └── 优质主动管理型基金 (10%)
│           └── 长期业绩优秀,基金经理稳定
│
└── 卫星资产 (20%-30%) ─────────────────────────────────────
    │
    ├── 行业/主题基金 (10%-15%)
    │   └── 科技、消费、医药、新能源等 ETF
    ├── 个股投资 (5%-10%)
    │   └── 3-5 支理解深刻的优质公司
    └── 另类投资 (5%)
        ├── 黄金 ETF(避险对冲)
        └── REITs(不动产收益)

现有资产分类映射

系统分类对应配置层级示例资产
现金类核心 > 稳健固收余额宝、活期存款、定期存单、借出款项
稳健类核心 > 稳健固收债券基金
进阶类核心 > 权益类 / 卫星股票基金、混合基金、ETF

Risks and Assumptions

关键假设

假设验证方式
快照式盘点足以满足 MVP 需求实际使用中验证是否需要更细粒度的记录
手动输入市值可接受评估是否影响使用意愿和数据准确性
Dioxus 成熟度足够支撑项目开发过程中评估框架稳定性和生态完善度
季度/年度收益分析周期合理使用中验证是否需要更灵活的时间范围

潜在风险

风险影响缓解策略
Dioxus 学习曲线较陡开发进度延迟参考官方文档和社区资源,必要时简化功能
数据模型设计不合理后续迭代困难在开发初期做好数据结构设计
功能蔓延MVP 无法完成严格遵守 Out of Scope 边界

Appendix

术语定义

术语定义
快照式盘点每次记录资产当前市值的完整状态,不追踪中间变动
配置偏离度当前资产配置与目标配置的百分比差异
核心资产追求稳定和安全的资产,占比 70-80%
卫星资产追求更高收益、容忍更高波动的资产,占比 20-30%
再平衡调整资产配置使其回归目标比例的操作

This Product Brief captures the vision and requirements for asset-light.

It was created through collaborative discovery and reflects the unique needs of this 个人项目 + 技术学习 project.

Next: PRD workflow will transform this brief into detailed product requirements.

Product Requirements Document (PRD)

asset-light - 个人资产管理桌面应用

版本: 1.0.0 (MVP)
日期: 2025-12-20
状态: Validated
作者: PM Agent
关联文档: Product Brief


1. 概述

1.1 文档目的

本文档详细定义 asset-light MVP 版本的产品需求,将 Product Brief 中的愿景转化为可执行的功能规格,为后续的架构设计和开发实施提供明确指导。

1.2 产品定位

asset-light 是一款基于 Dioxus (Rust) 技术栈的个人资产管理桌面应用,核心价值主张:

让个人投资者能够系统化地管理资产配置,追踪配置偏离度,并获得清晰的收益归因分析。

1.3 目标用户

特征描述
身份个人投资者,具备一定理财知识
现状使用表格工具管理资产,感到效率低下
痛点无法追踪历史变化、无法分析收益来源、无法监控配置偏离
期望快速盘点、偏离监控、收益分析、纪律执行

2. 功能需求

2.1 功能模块总览

asset-light MVP
├── F1. 资产盘点模块
├── F2. 配置方案模块
├── F3. 资产视图模块
└── F4. 收益分析模块

2.2 F1: 资产盘点模块

2.2.1 功能描述

提供结构化的资产录入和盘点能力,支持按分类管理资产条目,记录每次盘点的完整快照。

2.2.2 用户故事

ID用户故事优先级
US-F1-01作为用户,我希望能够新增一个资产条目(包含名称、类别、当前市值),以便记录我持有的资产P0
US-F1-02作为用户,我希望能够编辑已有资产条目的信息,以便修正录入错误或更新资产状态P0
US-F1-03作为用户,我希望能够删除不再持有的资产条目,以便保持资产列表的准确性P0
US-F1-04作为用户,我希望资产条目能够按类别(现金类/稳健类/进阶类)组织展示,以便快速定位和管理P0
US-F1-05作为用户,我希望能够执行一次"盘点"操作,批量更新所有资产的当前市值,并自动记录盘点时间戳P0
US-F1-06作为用户,我希望能够查看历史盘点记录列表,以便回顾资产变化轨迹P0
US-F1-07作为用户,我希望能够查看某次历史盘点的详细快照,以便了解当时的资产状态P1
US-F1-08作为用户,我希望能够自定义资产子类别(如将"进阶类"细分为"宽基指数"和"行业ETF"),以便更精细地管理资产P1

2.2.3 功能规格

FR-F1-01: 资产条目数据模型

字段类型必填说明
idUUID资产条目唯一标识
nameString资产名称(如"余额宝"、"沪深300ETF")
categoryEnum一级分类:Cash / Stable / Advanced
sub_categoryString用户自定义子类别
current_valueDecimal当前市值(单位:元)
notesString备注信息
created_atDateTime创建时间
updated_atDateTime最后更新时间

FR-F1-02: 资产类别定义

类别英文标识描述预设子类别
现金类Cash高流动性、低风险资产活期存款、货币基金、定期存单、借出款项
稳健类Stable固定收益、中低风险资产银行理财、债券基金、纯债基金
进阶类Advanced权益类、中高风险资产宽基指数、行业ETF、主动基金、个股

FR-F1-03: 盘点快照数据模型

字段类型必填说明
idUUID盘点快照唯一标识
snapshot_dateDate盘点日期
created_atDateTime快照创建时间
total_valueDecimal资产总额
itemsArray资产条目快照列表

FR-F1-04: 盘点快照条目结构

字段类型说明
asset_idUUID关联的资产条目 ID
asset_nameString资产名称(快照时冗余存储)
categoryEnum资产类别
sub_categoryString子类别
valueDecimal盘点时的市值

2.2.4 业务规则

规则ID描述
BR-F1-01资产名称不可为空,最大长度 100 字符
BR-F1-02市值必须为非负数,精度为小数点后 2 位
BR-F1-03删除资产条目时,历史快照中的该条目数据保留(快照不可变)
BR-F1-04每天最多允许创建 5 次盘点快照(防止误操作)
BR-F1-05盘点时至少需要有 1 个资产条目存在

2.2.5 界面要求

UI-F1-01: 资产列表页

  • 按类别分组展示所有资产条目
  • 每个类别显示:类别名称、资产数量、类别总市值
  • 每个资产条目显示:名称、子类别(如有)、当前市值
  • 支持操作:新增资产、编辑资产、删除资产、发起盘点
  • 类别折叠/展开支持

UI-F1-02: 资产编辑表单

  • 模态框或侧边栏形式
  • 字段:名称(输入框)、类别(下拉选择)、子类别(输入框/下拉)、市值(数字输入)、备注(文本域)
  • 保存/取消按钮
  • 实时验证反馈

UI-F1-03: 盘点流程

  • 盘点按钮触发进入盘点模式
  • 盘点模式下,资产列表变为可编辑状态,市值字段可直接修改
  • 显示上次盘点市值作为参考
  • 完成盘点按钮:保存快照并退出盘点模式
  • 取消盘点按钮:放弃本次修改

UI-F1-04: 盘点历史页

  • 时间线形式展示历史盘点记录
  • 每条记录显示:盘点日期、资产总额、相比上次变化金额/百分比
  • 点击可查看该快照的详细资产明细

2.3 F2: 配置方案模块

2.3.1 功能描述

支持用户定义多套目标资产配置方案,采用核心+卫星的层级结构,可切换当前激活的方案用于偏离度计算。

2.3.2 用户故事

ID用户故事优先级
US-F2-01作为用户,我希望能够创建一个配置方案,定义各资产类别的目标占比,以便作为资产配置的参照标准P0
US-F2-02作为用户,我希望能够编辑已有的配置方案,以便根据投资策略变化调整目标配置P0
US-F2-03作为用户,我希望能够删除不再使用的配置方案,以便保持方案列表简洁P1
US-F2-04作为用户,我希望能够将某个方案设为"当前激活",用于计算配置偏离度P0
US-F2-05作为用户,我希望配置方案能够支持两层结构(核心/卫星),以便实现更精细的配置管理P1
US-F2-06作为用户,我希望系统提供一个预设的"平衡型"配置方案作为起点,以便快速开始使用P1

2.3.3 功能规格

FR-F2-01: 配置方案数据模型

字段类型必填说明
idUUID方案唯一标识
nameString方案名称(如"保守型"、"进取型")
descriptionString方案描述
is_activeBoolean是否为当前激活方案
allocationsArray配置项列表
created_atDateTime创建时间
updated_atDateTime更新时间

FR-F2-02: 配置项结构(MVP 简化版)

字段类型说明
categoryEnum资产类别:Cash / Stable / Advanced
target_percentageDecimal目标占比(0-100)
min_percentageDecimal最低占比(可选,用于偏离提示)
max_percentageDecimal最高占比(可选,用于偏离提示)

FR-F2-03: 配置项结构(扩展版 - 支持子类别)

字段类型说明
categoryEnum资产类别
layerEnum层级:Core(核心)/ Satellite(卫星)
sub_categoryString子类别(可选)
target_percentageDecimal目标占比
toleranceDecimal容忍偏离度(默认 5%)

2.3.4 业务规则

规则ID描述
BR-F2-01方案名称不可为空,最大长度 50 字符
BR-F2-02同一方案内,所有配置项的 target_percentage 之和必须等于 100%
BR-F2-03系统始终有且仅有一个激活方案
BR-F2-04不能删除当前激活的方案(需先切换激活方案)
BR-F2-05系统至少保留一个配置方案

2.3.5 界面要求

UI-F2-01: 配置方案列表页

  • 展示所有配置方案,当前激活方案高亮显示
  • 每个方案显示:名称、描述摘要、激活状态
  • 支持操作:新建方案、编辑方案、删除方案、激活方案

UI-F2-02: 配置方案编辑器

  • 方案基础信息:名称、描述
  • 配置项可视化编辑:
    • 使用滑块或输入框调整各类别占比
    • 实时显示占比总和(必须 = 100%)
    • 饼图预览目标配置
  • 保存时自动校验规则

UI-F2-03: 预设方案模板

MVP 提供以下预设模板供用户快速创建:

模板名称CashStableAdvanced
保守型30%50%20%
平衡型20%40%40%
进取型10%20%70%

2.4 F3: 资产视图模块

2.4.1 功能描述

提供资产的全局视图,展示当前资产配置与目标配置的对比,可视化偏离情况,帮助用户快速了解资产健康状态。

2.4.2 用户故事

ID用户故事优先级
US-F3-01作为用户,我希望在首页看到资产总额和各类别的分布情况,以便快速了解资产全貌P0
US-F3-02作为用户,我希望能够直观对比当前配置与目标配置的差异,以便判断是否需要调整P0
US-F3-03作为用户,我希望当某类别偏离目标超过阈值时,系统能够高亮提示,以便及时关注P0
US-F3-04作为用户,我希望看到各类别的偏离百分比(如"+5%"、"-3%"),以便量化偏离程度P0
US-F3-05作为用户,我希望能够一键查看再平衡所需的资金调整方向,以便规划调仓P2

2.4.3 功能规格

FR-F3-01: 资产总览数据

指标计算方式
资产总额所有资产条目 current_value 之和
类别市值该类别下所有资产条目 current_value 之和
类别占比类别市值 / 资产总额 × 100%
偏离度当前占比 - 目标占比
偏离状态超出容忍阈值则标记为"偏离"

FR-F3-02: 偏离度计算逻辑

deviation = current_percentage - target_percentage

if abs(deviation) > tolerance:
    status = "deviating" (偏离)
else:
    status = "normal" (正常)

if deviation > 0:
    direction = "overweight" (超配)
else:
    direction = "underweight" (低配)

2.4.4 界面要求

UI-F3-01: 资产总览页(首页)

布局结构:

┌─────────────────────────────────────────────────────────────┐
│  资产总额                                                    │
│  ¥ 1,234,567.89                          上次盘点: 2025-12-15│
├─────────────────────────────────────────────────────────────┤
│                                                              │
│   [饼图: 当前配置]              [饼图: 目标配置]             │
│                                                              │
├─────────────────────────────────────────────────────────────┤
│  类别配置对比                                                │
│  ┌──────────┬──────────┬──────────┬──────────┬───────────┐  │
│  │ 类别     │ 当前占比  │ 目标占比  │ 偏离度    │ 状态      │  │
│  ├──────────┼──────────┼──────────┼──────────┼───────────┤  │
│  │ 现金类   │ 25%      │ 20%      │ +5%      │ ⚠️ 超配   │  │
│  │ 稳健类   │ 38%      │ 40%      │ -2%      │ ✓ 正常    │  │
│  │ 进阶类   │ 37%      │ 40%      │ -3%      │ ✓ 正常    │  │
│  └──────────┴──────────┴──────────┴──────────┴───────────┘  │
└─────────────────────────────────────────────────────────────┘

UI-F3-02: 偏离提示样式

偏离状态视觉表现
正常(偏离度
轻度偏离(偏离度
严重偏离(偏离度

UI-F3-03: 配置对比可视化

  • 双饼图并列展示:左侧当前配置,右侧目标配置
  • 同类别使用相同颜色,便于对比
  • 鼠标悬停显示具体金额和百分比

2.5 F4: 收益分析模块

2.5.1 功能描述

基于盘点快照数据,计算和展示资产收益情况,支持按时间周期和类别维度分析收益来源。

2.5.2 用户故事

ID用户故事优先级
US-F4-01作为用户,我希望看到选定时间段内的资产总收益(金额和百分比),以便了解整体收益情况P0
US-F4-02作为用户,我希望能够按季度/年度查看收益,以便对比不同周期的表现P0
US-F4-03作为用户,我希望看到各资产类别的收益贡献,以便了解哪些类别在产生收益P0
US-F4-04作为用户,我希望看到资产总额的历史变化趋势图,以便直观感受资产增长轨迹P1
US-F4-05作为用户,我希望能够选择任意两个盘点时间点进行收益对比,以便灵活分析P2

2.5.3 功能规格

FR-F4-01: 收益计算逻辑(快照模式简化版)

# 时间段收益计算
period_return = end_snapshot.total_value - start_snapshot.total_value
period_return_rate = period_return / start_snapshot.total_value × 100%

# 类别收益计算
category_return = end_category_value - start_category_value
category_contribution = category_return / period_return × 100%  # 收益贡献度

注意: MVP 采用快照模式,不追踪资金流入/流出,因此计算的是"市值变动"而非"真实收益率"。

FR-F4-02: 时间周期定义

周期定义
季度自然季度(Q1: 1-3月, Q2: 4-6月, Q3: 7-9月, Q4: 10-12月)
年度自然年(1月1日 - 12月31日)
自定义用户选择的起止日期

FR-F4-03: 收益分析数据结构

字段类型说明
periodObject时间段 {start_date, end_date}
start_valueDecimal期初总市值
end_valueDecimal期末总市值
absolute_returnDecimal绝对收益金额
return_rateDecimal收益率(%)
category_breakdownArray各类别收益明细

FR-F4-04: 类别收益明细结构

字段类型说明
categoryEnum资产类别
start_valueDecimal期初市值
end_valueDecimal期末市值
absolute_returnDecimal绝对收益
contribution_rateDecimal收益贡献度(%)

2.5.4 业务规则

规则ID描述
BR-F4-01至少需要 2 个盘点快照才能计算收益
BR-F4-02期初市值为 0 时,收益率显示为"N/A"
BR-F4-03季度/年度收益使用该周期内最早和最晚的快照计算
BR-F4-04若某周期内无快照数据,该周期收益显示为"暂无数据"

2.5.5 界面要求

UI-F4-01: 收益分析页

布局结构:

┌─────────────────────────────────────────────────────────────┐
│  周期选择器: [季度 ▾]  [2025年 ▾]  [Q4 ▾]     [自定义日期]   │
├─────────────────────────────────────────────────────────────┤
│  本期收益概览                                                │
│  ┌─────────────────┐  ┌─────────────────┐                   │
│  │ 收益金额         │  │ 收益率           │                   │
│  │ +¥ 12,345.67    │  │ +3.2%           │                   │
│  │ (期初 → 期末)    │  │                  │                   │
│  └─────────────────┘  └─────────────────┘                   │
├─────────────────────────────────────────────────────────────┤
│  收益归因分析                                                │
│  ┌──────────┬──────────┬──────────┬──────────┐              │
│  │ 类别     │ 收益金额  │ 收益率    │ 贡献度   │              │
│  ├──────────┼──────────┼──────────┼──────────┤              │
│  │ 现金类   │ +¥ 1,234 │ +1.5%    │ 10%     │              │
│  │ 稳健类   │ +¥ 3,456 │ +2.8%    │ 28%     │              │
│  │ 进阶类   │ +¥ 7,655 │ +5.2%    │ 62%     │              │
│  └──────────┴──────────┴──────────┴──────────┘              │
├─────────────────────────────────────────────────────────────┤
│  资产趋势图                                                  │
│  [折线图: 历史盘点时间点的资产总额变化]                        │
│                                                              │
└─────────────────────────────────────────────────────────────┘

UI-F4-02: 收益归因可视化

  • 横向条形图展示各类别的收益贡献
  • 正收益绿色,负收益红色
  • 支持点击展开查看该类别下各资产的收益明细

UI-F4-03: 资产趋势图

  • X 轴:盘点日期时间线
  • Y 轴:资产总额
  • 数据点标注具体金额
  • 支持缩放和时间范围选择

3. 非功能需求

3.1 性能需求

ID需求指标
NFR-P-01应用启动时间< 2 秒(冷启动)
NFR-P-02页面切换响应< 200ms
NFR-P-03盘点保存时间< 500ms(100 个资产条目以内)
NFR-P-04数据加载时间< 1s(1000 条历史快照)

3.2 可用性需求

ID需求说明
NFR-U-01首次使用引导提供简洁的功能引导,帮助用户快速上手
NFR-U-02键盘快捷键支持常用操作的快捷键(如 Cmd+N 新增资产)
NFR-U-03操作反馈所有操作提供明确的成功/失败反馈
NFR-U-04数据校验输入时实时校验,防止无效数据
NFR-U-05确认机制删除等破坏性操作需二次确认

3.3 数据需求

ID需求说明
NFR-D-01本地存储所有数据存储在本地,不依赖网络
NFR-D-02数据格式SQLite 数据库或 JSON 文件
NFR-D-03数据备份支持手动导出数据文件
NFR-D-04数据完整性操作失败时保证数据不被损坏

3.4 安全需求

ID需求说明
NFR-S-01隐私保护数据仅存储在用户本地设备
NFR-S-02无网络传输MVP 版本不进行任何网络通信

3.5 兼容性需求

ID需求说明
NFR-C-01macOS 支持支持 macOS 12.0+
NFR-C-02分辨率适配支持 1280×720 及以上分辨率
NFR-C-03深色模式支持系统深色模式(P1)

4. 信息架构

4.1 页面结构

asset-light
├── 首页(资产总览)
│   ├── 资产总额卡片
│   ├── 配置对比图表
│   └── 偏离状态列表
│
├── 资产管理
│   ├── 资产列表(按类别分组)
│   ├── 新增/编辑资产表单
│   └── 盘点模式
│
├── 盘点历史
│   ├── 历史时间线
│   └── 快照详情
│
├── 配置方案
│   ├── 方案列表
│   └── 方案编辑器
│
└── 收益分析
    ├── 周期选择器
    ├── 收益概览
    ├── 收益归因表
    └── 资产趋势图

4.2 导航设计

  • 采用侧边栏导航
  • 一级导航项:首页、资产管理、盘点历史、配置方案、收益分析
  • 当前激活项高亮显示
  • 支持键盘导航

5. 数据模型概览

5.1 核心实体关系

┌─────────────┐     ┌─────────────────┐
│   Asset     │◄────│ SnapshotItem    │
│  (资产条目)  │     │  (快照明细)      │
└─────────────┘     └────────┬────────┘
                             │
                             ▼
                    ┌─────────────────┐
                    │   Snapshot      │
                    │   (盘点快照)     │
                    └─────────────────┘

┌─────────────┐     ┌─────────────────┐
│ AllocationPlan │◄────│ Allocation   │
│  (配置方案)  │     │   (配置项)      │
└─────────────┘     └─────────────────┘

5.2 数据量估算

实体预估数量(个人用户)
资产条目10 - 50 个
盘点快照50 - 200 条(5年使用)
配置方案2 - 5 套

6. MVP 成功标准

6.1 功能完成标准

标准验收条件
资产盘点能够在 3 分钟内完成 10 个资产条目的盘点
配置管理能够创建、编辑、切换至少 2 套配置方案
偏离监控能够清晰看到当前配置与目标配置的偏离情况
收益分析能够查看季度/年度收益率及各类别收益贡献

6.2 技术完成标准

标准验收条件
应用稳定性无崩溃或数据丢失
性能达标满足 3.1 节性能指标
代码质量Clippy 无警告,代码结构清晰

6.3 用户体验标准

标准验收条件
易用性无需文档即可完成基本操作
视觉一致性UI 风格统一,无明显违和感
操作反馈所有操作有明确的状态反馈

7. 范围外事项(Out of Scope)

以下功能明确不在 MVP 范围内:

功能延后原因计划版本
自动获取净值需对接外部 APIPhase 3
分红/调仓记录增加数据模型复杂度Phase 2
具体调仓建议需要更复杂计算逻辑Phase 3
月度收益分析MVP 聚焦季度/年度Phase 2
多端同步本地优先Phase 4
数据导入/导出可作为独立迭代Phase 4
Windows/Linux 支持macOS 优先TBD

8. 开放问题

问题状态备选方案
数据存储格式选择 SQLite vs JSON?待定倾向 SQLite(查询能力更强)
是否支持多币种?决定:MVP 仅支持人民币后续版本考虑
深色模式是否为 MVP 必需?待定建议作为 P1 功能
子类别层级是否支持嵌套?决定:MVP 仅支持一层子类别后续可扩展

9. 术语表

术语定义
快照式盘点每次记录资产当前市值的完整状态,不追踪中间变动
配置偏离度当前资产配置与目标配置的百分比差异
核心资产追求稳定和安全的资产,通常占比 70-80%
卫星资产追求更高收益、容忍更高波动的资产,通常占比 20-30%
再平衡调整资产配置使其回归目标比例的操作
收益归因分析各资产/类别对整体收益的贡献程度

10. 修订历史

版本日期作者变更说明
1.0.02025-12-20PM Agent初始版本,定义 MVP 需求

This PRD transforms the Product Brief into detailed, implementable requirements.

Next: Architecture design workflow will define the technical solution based on these requirements.

UI/UX Design Document: asset-light

版本: 1.0.0
日期: 2025-12-20
设计师: UX Designer Agent


1. 设计概述

1.1 设计目标

为 asset-light 打造一个专业、清晰、高效的桌面应用界面,让用户能够:

  • 快速完成资产盘点操作
  • 一目了然地了解配置偏离情况
  • 轻松分析收益来源

1.2 设计原则

原则说明
数据优先财务数据是核心,设计服务于数据的清晰呈现
操作高效减少点击次数,常用操作触手可及
视觉层次重要信息突出,次要信息收敛
状态明确偏离、正常、警告等状态有清晰的视觉区分

2. 视觉风格

2.1 色彩系统

主色调:深蓝专业风

Primary Colors (主色)
├── primary-900: #0F172A  (深色背景/侧边栏)
├── primary-800: #1E293B  (卡片背景)
├── primary-700: #334155  (次级背景)
├── primary-600: #475569  (边框)
├── primary-100: #F1F5F9  (浅色背景)
└── primary-50:  #F8FAFC  (页面背景)

Accent Colors (强调色)
├── accent-blue:   #3B82F6  (主操作按钮、链接)
├── accent-cyan:   #06B6D4  (图表高亮)
└── accent-purple: #8B5CF6  (次要强调)

Semantic Colors (语义色)
├── success:  #10B981  (正常/正收益/低配可增加)
├── warning:  #F59E0B  (轻度偏离/注意)
├── danger:   #EF4444  (严重偏离/负收益/超配需减少)
└── neutral:  #6B7280  (中性/禁用)

Category Colors (类别专用色)
├── cash:     #10B981  (现金类 - 绿色系)
├── stable:   #3B82F6  (稳健类 - 蓝色系)
└── advanced: #8B5CF6  (进阶类 - 紫色系)

2.2 字体系统

Font Family
├── 中文: "PingFang SC", "Noto Sans SC", sans-serif
└── 数字: "JetBrains Mono", "SF Mono", monospace (金额专用)

Font Sizes
├── display:  32px / 40px  (资产总额等大数字)
├── heading1: 24px / 32px  (页面标题)
├── heading2: 18px / 24px  (区块标题)
├── heading3: 16px / 22px  (卡片标题)
├── body:     14px / 20px  (正文)
├── caption:  12px / 16px  (辅助文字)
└── micro:    10px / 14px  (标签/徽章)

2.3 间距系统

Spacing Scale (4px 基准)
├── xs:  4px
├── sm:  8px
├── md:  16px
├── lg:  24px
├── xl:  32px
└── 2xl: 48px

Component Spacing
├── 页面内边距: 32px
├── 卡片内边距: 24px
├── 卡片间距:   16px
├── 列表项间距: 12px
└── 表单项间距: 16px

2.4 圆角和阴影

Border Radius
├── none: 0
├── sm:   4px   (按钮、输入框)
├── md:   8px   (卡片、模态框)
├── lg:   12px  (大卡片)
└── full: 9999px (徽章、头像)

Box Shadow
├── sm:   0 1px 2px rgba(0,0,0,0.05)
├── md:   0 4px 6px rgba(0,0,0,0.07)
├── lg:   0 10px 15px rgba(0,0,0,0.1)
└── inner: inset 0 2px 4px rgba(0,0,0,0.05)

3. 布局结构

3.1 整体布局

┌──────────────────────────────────────────────────────────────┐
│                        Window Title Bar                       │
├────────────┬─────────────────────────────────────────────────┤
│            │                                                  │
│            │                                                  │
│   Sidebar  │                  Main Content                    │
│   (220px)  │                    Area                          │
│            │                                                  │
│            │                                                  │
│            │                                                  │
│            │                                                  │
└────────────┴─────────────────────────────────────────────────┘

最小窗口尺寸: 1280 x 720
推荐窗口尺寸: 1440 x 900

3.2 侧边栏设计

┌────────────┐
│  ◉ asset   │  <- Logo + App Name
│    light   │
├────────────┤
│            │
│  ▣ 首页    │  <- Navigation Items
│  ▢ 资产    │     - Icon + Label
│  ▢ 历史    │     - Active state highlight
│  ▢ 配置    │     - Hover effect
│  ▢ 收益    │
│            │
├────────────┤
│            │
│  ⚙ 设置    │  <- Bottom actions
│            │
└────────────┘

Sidebar Specs:
- Width: 220px (fixed)
- Background: primary-900
- Text: white/gray
- Active item: accent-blue background
- Icon size: 20px
- Item height: 44px
- Item padding: 16px horizontal

4. 页面设计

4.1 首页 (资产总览)

页面目标: 一眼看清资产全貌和配置偏离情况

┌─────────────────────────────────────────────────────────────┐
│  资产总览                                    上次盘点: 12-15 │
├─────────────────────────────────────────────────────────────┤
│  ┌─────────────────────────────────────────────────────────┐│
│  │                     ¥ 1,234,567.89                      ││
│  │                        资产总额                          ││
│  │         较上期 +¥12,345.67 (+1.01%)  ↑                  ││
│  └─────────────────────────────────────────────────────────┘│
├─────────────────────────────────────────────────────────────┤
│  ┌──────────────────────┐  ┌──────────────────────┐        │
│  │     当前配置          │  │     目标配置          │        │
│  │    ┌────────┐        │  │    ┌────────┐        │        │
│  │    │        │        │  │    │        │        │        │
│  │    │ 饼图   │        │  │    │ 饼图   │        │        │
│  │    │        │        │  │    │        │        │        │
│  │    └────────┘        │  │    └────────┘        │        │
│  │  ● 现金 25%          │  │  ● 现金 20%          │        │
│  │  ● 稳健 38%          │  │  ● 稳健 40%          │        │
│  │  ● 进阶 37%          │  │  ● 进阶 40%          │        │
│  └──────────────────────┘  └──────────────────────┘        │
├─────────────────────────────────────────────────────────────┤
│  配置偏离分析                              方案: 平衡型 ▾    │
│  ┌──────────┬──────────┬──────────┬──────────┬───────────┐ │
│  │ 类别     │ 当前      │ 目标      │ 偏离      │ 状态      │ │
│  ├──────────┼──────────┼──────────┼──────────┼───────────┤ │
│  │ 现金类   │ 25%      │ 20%      │ +5%      │ ⚠️ 超配   │ │
│  │ 稳健类   │ 38%      │ 40%      │ -2%      │ ✓ 正常    │ │
│  │ 进阶类   │ 37%      │ 40%      │ -3%      │ ✓ 正常    │ │
│  └──────────┴──────────┴──────────┴──────────┴───────────┘ │
└─────────────────────────────────────────────────────────────┘

组件说明:

组件规格
总额卡片高度 160px, 居中布局, 金额使用 display 字号
饼图卡片并列双卡片, 各占 50% 宽度, 饼图直径 180px
偏离表格全宽, 斑马纹背景, 状态列使用语义色

4.2 资产管理页

页面目标: 管理资产条目,执行盘点操作

┌─────────────────────────────────────────────────────────────┐
│  资产管理                                                    │
│  ──────────────────────────────────                         │
│  共 10 项资产  总市值 ¥500,000.00      [开始盘点] [+ 新增]   │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  ▼ 现金类                                   3项 ¥100,000.00 │
│  ┌──────────────────────────────────────────────────────┐   │
│  │ ◉ 余额宝                                 ¥50,000.00   │   │
│  │   货币基金                            [编辑] [删除]   │   │
│  ├──────────────────────────────────────────────────────┤   │
│  │ ◉ 活期存款                               ¥30,000.00   │   │
│  │   银行活期                            [编辑] [删除]   │   │
│  ├──────────────────────────────────────────────────────┤   │
│  │ ◉ 定期存单                               ¥20,000.00   │   │
│  │   大额存单                            [编辑] [删除]   │   │
│  └──────────────────────────────────────────────────────┘   │
│                                                              │
│  ▼ 稳健类                                   2项 ¥150,000.00 │
│  ┌──────────────────────────────────────────────────────┐   │
│  │ ...                                                   │   │
│  └──────────────────────────────────────────────────────┘   │
│                                                              │
│  ▼ 进阶类                                   5项 ¥250,000.00 │
│  ┌──────────────────────────────────────────────────────┐   │
│  │ ...                                                   │   │
│  └──────────────────────────────────────────────────────┘   │
│                                                              │
└─────────────────────────────────────────────────────────────┘

盘点模式:

┌─────────────────────────────────────────────────────────────┐
│  ⚡ 盘点模式                        [完成盘点] [取消盘点]    │
│  ───────────────────────────────────────────────────────    │
│  请更新各资产的当前市值                                      │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  ▼ 现金类                                                   │
│  ┌──────────────────────────────────────────────────────┐   │
│  │ ◉ 余额宝                                              │   │
│  │   上次: ¥50,000.00  →  当前: [¥ 51,234.56    ]       │   │
│  ├──────────────────────────────────────────────────────┤   │
│  │ ◉ 活期存款                                            │   │
│  │   上次: ¥30,000.00  →  当前: [¥ 30,100.00    ]       │   │
│  └──────────────────────────────────────────────────────┘   │
│  ...                                                        │
└─────────────────────────────────────────────────────────────┘

盘点模式特殊样式:
- 顶部提示条: accent-blue 背景
- 输入框高亮: accent-blue 边框
- 上次市值: 灰色小字

4.3 新增/编辑资产表单

表单模态框设计:

┌─────────────────────────────────────────┐
│  新增资产                           ✕   │
├─────────────────────────────────────────┤
│                                         │
│  资产名称 *                             │
│  ┌─────────────────────────────────┐    │
│  │ 请输入资产名称                   │    │
│  └─────────────────────────────────┘    │
│                                         │
│  资产类别 *                             │
│  ┌─────────────────────────────────┐    │
│  │ 请选择类别                    ▾ │    │
│  └─────────────────────────────────┘    │
│                                         │
│  子类别                                 │
│  ┌─────────────────────────────────┐    │
│  │ 可选,如"宽基指数"              │    │
│  └─────────────────────────────────┘    │
│                                         │
│  当前市值 *                             │
│  ┌─────────────────────────────────┐    │
│  │ ¥ 0.00                          │    │
│  └─────────────────────────────────┘    │
│                                         │
│  备注                                   │
│  ┌─────────────────────────────────┐    │
│  │                                 │    │
│  │ 可选备注信息                    │    │
│  │                                 │    │
│  └─────────────────────────────────┘    │
│                                         │
├─────────────────────────────────────────┤
│                    [取消]  [保存资产]   │
└─────────────────────────────────────────┘

Modal Specs:
- Width: 480px
- Padding: 24px
- Background: white
- Shadow: lg
- Border radius: md (8px)

4.4 盘点历史页

┌─────────────────────────────────────────────────────────────┐
│  盘点历史                                                    │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  ┌─ 2025 ───────────────────────────────────────────────┐   │
│  │                                                       │   │
│  │  ● 12月15日                                          │   │
│  │    ┌─────────────────────────────────────────────┐   │   │
│  │    │ 总市值 ¥1,234,567.89                        │   │   │
│  │    │ 较上期 +¥12,345.67 (+1.01%)            [>] │   │   │
│  │    └─────────────────────────────────────────────┘   │   │
│  │                                                       │   │
│  │  ● 11月15日                                          │   │
│  │    ┌─────────────────────────────────────────────┐   │   │
│  │    │ 总市值 ¥1,222,222.22                        │   │   │
│  │    │ 较上期 +¥8,888.88 (+0.73%)             [>] │   │   │
│  │    └─────────────────────────────────────────────┘   │   │
│  │                                                       │   │
│  │  ● 10月15日                                          │   │
│  │    ┌─────────────────────────────────────────────┐   │   │
│  │    │ 总市值 ¥1,213,333.34                        │   │   │
│  │    │ 首次盘点                               [>] │   │   │
│  │    └─────────────────────────────────────────────┘   │   │
│  │                                                       │   │
│  └───────────────────────────────────────────────────────┘   │
│                                                              │
└─────────────────────────────────────────────────────────────┘

时间线样式:
- 左侧垂直线: 2px, primary-600
- 时间节点: 12px 圆点, accent-blue
- 正收益: success 色 + ↑ 箭头
- 负收益: danger 色 + ↓ 箭头

4.5 配置方案页

┌─────────────────────────────────────────────────────────────┐
│  配置方案                                    [+ 新建方案]   │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  ★ 平衡型                                  [当前激活] │    │
│  │  ─────────────────────────────────────────────────  │    │
│  │  适合追求稳健增长的投资者                            │    │
│  │                                                      │    │
│  │  ┌──────────────────────────────────────────────┐   │    │
│  │  │ ■ 现金类 20%  ■ 稳健类 40%  ■ 进阶类 40%    │   │    │
│  │  │ ████████████████████████████████████████████ │   │    │
│  │  └──────────────────────────────────────────────┘   │    │
│  │                                                      │    │
│  │                              [编辑]                  │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                              │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  保守型                                              │    │
│  │  ─────────────────────────────────────────────────  │    │
│  │  适合风险厌恶型投资者                                │    │
│  │                                                      │    │
│  │  ┌──────────────────────────────────────────────┐   │    │
│  │  │ ■ 现金类 30%  ■ 稳健类 50%  ■ 进阶类 20%    │   │    │
│  │  │ ████████████████████████████████████████████ │   │    │
│  │  └──────────────────────────────────────────────┘   │    │
│  │                                                      │    │
│  │                    [设为当前] [编辑] [删除]          │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                              │
└─────────────────────────────────────────────────────────────┘

方案卡片:
- 激活方案: accent-blue 边框 + 星标
- 配置条: 横向堆叠条形图, 使用类别专用色

4.6 配置方案编辑器

┌─────────────────────────────────────────┐
│  编辑配置方案                       ✕   │
├─────────────────────────────────────────┤
│                                         │
│  方案名称 *                             │
│  ┌─────────────────────────────────┐    │
│  │ 平衡型                          │    │
│  └─────────────────────────────────┘    │
│                                         │
│  方案描述                               │
│  ┌─────────────────────────────────┐    │
│  │ 适合追求稳健增长的投资者        │    │
│  └─────────────────────────────────┘    │
│                                         │
│  ─────────────────────────────────      │
│  目标配置                  总计: 100%   │
│  ─────────────────────────────────      │
│                                         │
│  现金类                                 │
│  ○────────────●──────────○     20%     │
│  0%                      100%           │
│                                         │
│  稳健类                                 │
│  ○──────────────────●────○     40%     │
│  0%                      100%           │
│                                         │
│  进阶类                                 │
│  ○──────────────────●────○     40%     │
│  0%                      100%           │
│                                         │
│  ┌─────────────────────────────────┐    │
│  │        [饼图预览]                │    │
│  └─────────────────────────────────┘    │
│                                         │
├─────────────────────────────────────────┤
│                    [取消]  [保存方案]   │
└─────────────────────────────────────────┘

配置编辑器:
- 滑块: accent-blue 轨道
- 总计 != 100% 时: 显示 danger 色警告
- 饼图: 实时预览配置分布

4.7 收益分析页

┌─────────────────────────────────────────────────────────────┐
│  收益分析                                                    │
│  ─────────                                                  │
│  周期: [季度 ▾]  年份: [2025 ▾]  季度: [Q4 ▾]  [自定义]    │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  ┌──────────────────────────┐ ┌──────────────────────────┐  │
│  │      本期收益             │ │      收益率              │  │
│  │                          │ │                          │  │
│  │   +¥ 12,345.67           │ │      +3.21%              │  │
│  │                          │ │                          │  │
│  │   10/01 → 12/20          │ │   期初 ¥384,567          │  │
│  └──────────────────────────┘ └──────────────────────────┘  │
│                                                              │
├─────────────────────────────────────────────────────────────┤
│  收益归因                                                    │
│  ┌──────────┬────────────┬──────────┬──────────┬─────────┐ │
│  │ 类别     │ 收益金额    │ 收益率    │ 贡献度    │ 可视化  │ │
│  ├──────────┼────────────┼──────────┼──────────┼─────────┤ │
│  │ 现金类   │ +¥1,234    │ +1.5%    │ 10%     │ ███     │ │
│  │ 稳健类   │ +¥3,456    │ +2.8%    │ 28%     │ ██████  │ │
│  │ 进阶类   │ +¥7,655    │ +5.2%    │ 62%     │ █████████│ │
│  └──────────┴────────────┴──────────┴──────────┴─────────┘ │
│                                                              │
├─────────────────────────────────────────────────────────────┤
│  资产趋势                                                    │
│  ┌─────────────────────────────────────────────────────┐    │
│  │                                           ●          │    │
│  │                                   ●                  │    │
│  │                           ●                          │    │
│  │                   ●                                  │    │
│  │           ●                                          │    │
│  │   ●                                                  │    │
│  ├─────────────────────────────────────────────────────┤    │
│  │  10/15  10/30  11/15  11/30  12/15  12/20           │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                              │
└─────────────────────────────────────────────────────────────┘

收益卡片:
- 正收益: success 色 + 向上箭头
- 负收益: danger 色 + 向下箭头
- 折线图: accent-cyan 色, 数据点 8px 圆点

5. 组件库

5.1 按钮

Primary Button (主按钮)
┌──────────────────┐
│    保存资产       │  bg: accent-blue, text: white
└──────────────────┘  hover: darken 10%
                      height: 40px, padding: 0 20px, radius: 4px

Secondary Button (次按钮)
┌──────────────────┐
│      取消        │  bg: transparent, text: primary-700
└──────────────────┘  border: 1px primary-600
                      hover: bg primary-100

Danger Button (危险按钮)
┌──────────────────┐
│      删除        │  bg: danger, text: white
└──────────────────┘  hover: darken 10%

Ghost Button (幽灵按钮)
┌──────────────────┐
│      编辑        │  bg: transparent, text: accent-blue
└──────────────────┘  hover: bg accent-blue/10

5.2 输入框

Text Input (文本输入)
┌─────────────────────────────────┐
│ 请输入内容                       │  height: 40px
└─────────────────────────────────┘  border: 1px primary-600
                                     focus: border accent-blue
                                     error: border danger

Number Input (数字输入 - 金额)
┌─────────────────────────────────┐
│ ¥ 12,345.67                     │  font: monospace
└─────────────────────────────────┘  text-align: right

Select (下拉选择)
┌─────────────────────────────────┐
│ 请选择                        ▾ │
└─────────────────────────────────┘

5.3 卡片

Base Card (基础卡片)
┌─────────────────────────────────┐
│                                 │  bg: white
│                                 │  border: 1px primary-100
│                                 │  radius: 8px
│                                 │  shadow: sm
│                                 │  padding: 24px
└─────────────────────────────────┘

Stat Card (统计卡片)
┌─────────────────────────────────┐
│  标题                           │  居中布局
│  ──────                         │  大数字突出
│       ¥ 1,234,567               │
│       说明文字                   │
└─────────────────────────────────┘

5.4 状态标签

Status Tags (状态标签)

[✓ 正常]     bg: success/10, text: success, radius: full
[⚠️ 超配]    bg: warning/10, text: warning, radius: full
[❗ 严重偏离] bg: danger/10, text: danger, radius: full
[当前激活]   bg: accent-blue/10, text: accent-blue, radius: full

height: 24px, padding: 0 12px, font-size: 12px

5.5 模态框

Modal (模态框)
┌─────────────────────────────────────────┐
│  标题                               ✕   │  <- Header: border-bottom
├─────────────────────────────────────────┤
│                                         │
│           内容区域                       │  <- Body: padding 24px
│                                         │
├─────────────────────────────────────────┤
│                    [次按钮]  [主按钮]   │  <- Footer: border-top
└─────────────────────────────────────────┘

width: 480px (form) / 640px (detail)
max-height: 80vh
背景遮罩: rgba(0,0,0,0.5)

6. 交互规范

6.1 加载状态

场景交互
页面加载Skeleton 占位符
按钮加载按钮内 spinner + 禁用
列表加载列表区域 spinner
保存操作按钮文字变为"保存中..."

6.2 反馈提示

Toast 消息 (右上角, 自动消失 3s)
┌────────────────────────────────┐
│ ✓ 资产保存成功                  │  success
└────────────────────────────────┘

┌────────────────────────────────┐
│ ✕ 保存失败,请重试              │  danger
└────────────────────────────────┘

6.3 确认对话框

删除确认
┌─────────────────────────────────────────┐
│  确认删除                           ✕   │
├─────────────────────────────────────────┤
│                                         │
│  ⚠️ 确定删除资产「余额宝」吗?           │
│                                         │
│  此操作不可撤销。                        │
│                                         │
├─────────────────────────────────────────┤
│                    [取消]  [确认删除]   │
└─────────────────────────────────────────┘

6.4 空状态

无资产时:
┌─────────────────────────────────────────┐
│                                         │
│           📦                            │
│                                         │
│      暂无资产数据                        │
│                                         │
│   添加您的第一个资产,开始管理财富        │
│                                         │
│           [+ 添加资产]                   │
│                                         │
└─────────────────────────────────────────┘

7. 响应式考虑

窗口宽度布局调整
≥ 1440px标准布局
1280-1439px内容区缩窄,保持比例
< 1280px不支持(最小宽度限制)

8. 深色模式 (P1)

深色模式色彩映射:

元素浅色模式深色模式
页面背景primary-50primary-900
卡片背景whiteprimary-800
正文颜色primary-900primary-100
边框颜色primary-200primary-700
强调色保持不变保持不变

修订历史

版本日期作者变更说明
1.0.02025-12-20UX Designer初始版本

Technical Architecture Document: asset-light

版本: 1.0.0
日期: 2025-12-20
架构师: Architect Agent


1. 架构概述

1.1 技术栈

层面技术选型版本说明
UI 框架Dioxus0.5.xRust 生态的声明式 UI 框架
语言Rust1.75+系统级编程语言
平台Desktop (macOS)-基于 WebView 渲染
数据库SQLite3.x本地嵌入式数据库
ORMrusqlite0.31.xSQLite Rust 绑定
序列化serde + serde_json1.xJSON 序列化
日期时间chrono0.4.x日期时间处理
UUIDuuid1.x唯一标识生成
精确数值rust_decimal1.x金融级精度计算

1.2 架构模式

采用分层架构 + 组件化 UI模式:

┌─────────────────────────────────────────────────────────────┐
│                    Presentation Layer                        │
│                  (Dioxus Components)                         │
├─────────────────────────────────────────────────────────────┤
│                    Application Layer                         │
│              (State Management + Hooks)                      │
├─────────────────────────────────────────────────────────────┤
│                     Domain Layer                             │
│              (Models + Business Logic)                       │
├─────────────────────────────────────────────────────────────┤
│                  Infrastructure Layer                        │
│             (Database + File System)                         │
└─────────────────────────────────────────────────────────────┘

2. 项目结构

asset-light/
├── Cargo.toml
├── Dioxus.toml                 # Dioxus 配置
├── assets/                     # 静态资源
│   ├── icons/
│   └── styles/
├── src/
│   ├── main.rs                 # 应用入口
│   ├── app.rs                  # 根组件
│   ├── router.rs               # 路由定义
│   │
│   ├── components/             # UI 组件
│   │   ├── mod.rs
│   │   ├── layout/             # 布局组件
│   │   │   ├── mod.rs
│   │   │   ├── sidebar.rs
│   │   │   └── page_container.rs
│   │   ├── common/             # 通用组件
│   │   │   ├── mod.rs
│   │   │   ├── button.rs
│   │   │   ├── input.rs
│   │   │   ├── modal.rs
│   │   │   ├── card.rs
│   │   │   ├── toast.rs
│   │   │   └── empty_state.rs
│   │   ├── asset/              # 资产相关组件
│   │   │   ├── mod.rs
│   │   │   ├── asset_list.rs
│   │   │   ├── asset_form.rs
│   │   │   ├── asset_item.rs
│   │   │   └── category_group.rs
│   │   ├── snapshot/           # 盘点相关组件
│   │   │   ├── mod.rs
│   │   │   ├── inventory_mode.rs
│   │   │   ├── snapshot_timeline.rs
│   │   │   └── snapshot_detail.rs
│   │   ├── plan/               # 配置方案组件
│   │   │   ├── mod.rs
│   │   │   ├── plan_list.rs
│   │   │   ├── plan_card.rs
│   │   │   └── plan_editor.rs
│   │   ├── dashboard/          # 首页仪表盘组件
│   │   │   ├── mod.rs
│   │   │   ├── total_card.rs
│   │   │   ├── pie_chart.rs
│   │   │   └── deviation_table.rs
│   │   └── analysis/           # 收益分析组件
│   │       ├── mod.rs
│   │       ├── period_selector.rs
│   │       ├── return_card.rs
│   │       ├── attribution_table.rs
│   │       └── trend_chart.rs
│   │
│   ├── pages/                  # 页面组件
│   │   ├── mod.rs
│   │   ├── home.rs             # 首页
│   │   ├── assets.rs           # 资产管理
│   │   ├── history.rs          # 盘点历史
│   │   ├── plans.rs            # 配置方案
│   │   └── analysis.rs         # 收益分析
│   │
│   ├── models/                 # 数据模型
│   │   ├── mod.rs
│   │   ├── asset.rs
│   │   ├── snapshot.rs
│   │   ├── plan.rs
│   │   └── category.rs
│   │
│   ├── services/               # 业务服务
│   │   ├── mod.rs
│   │   ├── asset_service.rs
│   │   ├── snapshot_service.rs
│   │   ├── plan_service.rs
│   │   └── analysis_service.rs
│   │
│   ├── state/                  # 状态管理
│   │   ├── mod.rs
│   │   └── app_state.rs
│   │
│   ├── db/                     # 数据库层
│   │   ├── mod.rs
│   │   ├── connection.rs
│   │   ├── migrations.rs
│   │   ├── asset_repo.rs
│   │   ├── snapshot_repo.rs
│   │   └── plan_repo.rs
│   │
│   └── utils/                  # 工具函数
│       ├── mod.rs
│       ├── decimal.rs
│       ├── date.rs
│       └── format.rs
│
├── migrations/                 # SQL 迁移文件
│   ├── 001_create_assets.sql
│   ├── 002_create_snapshots.sql
│   └── 003_create_plans.sql
│
└── docs/                       # 项目文档

3. 数据模型

3.1 核心实体

Asset (资产条目)

#![allow(unused)]
fn main() {
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use uuid::Uuid;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum Category {
    Cash,      // 现金类
    Stable,    // 稳健类
    Advanced,  // 进阶类
}

impl Category {
    pub fn display_name(&self) -> &str {
        match self {
            Category::Cash => "现金类",
            Category::Stable => "稳健类",
            Category::Advanced => "进阶类",
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Asset {
    pub id: Uuid,
    pub name: String,
    pub category: Category,
    pub sub_category: Option<String>,
    pub current_value: Decimal,
    pub notes: Option<String>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

impl Asset {
    pub fn new(name: String, category: Category, current_value: Decimal) -> Self {
        let now = Utc::now();
        Self {
            id: Uuid::new_v4(),
            name,
            category,
            sub_category: None,
            current_value,
            notes: None,
            created_at: now,
            updated_at: now,
        }
    }
}
}

Snapshot (盘点快照)

#![allow(unused)]
fn main() {
use chrono::{DateTime, NaiveDate, Utc};
use rust_decimal::Decimal;
use uuid::Uuid;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Snapshot {
    pub id: Uuid,
    pub snapshot_date: NaiveDate,
    pub created_at: DateTime<Utc>,
    pub total_value: Decimal,
    pub items: Vec<SnapshotItem>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotItem {
    pub asset_id: Uuid,
    pub asset_name: String,
    pub category: Category,
    pub sub_category: Option<String>,
    pub value: Decimal,
}

impl Snapshot {
    pub fn new(items: Vec<SnapshotItem>) -> Self {
        let total_value = items.iter()
            .map(|item| item.value)
            .sum();
        
        Self {
            id: Uuid::new_v4(),
            snapshot_date: Utc::now().date_naive(),
            created_at: Utc::now(),
            total_value,
            items,
        }
    }
    
    pub fn category_total(&self, category: &Category) -> Decimal {
        self.items
            .iter()
            .filter(|item| &item.category == category)
            .map(|item| item.value)
            .sum()
    }
}
}

AllocationPlan (配置方案)

#![allow(unused)]
fn main() {
use rust_decimal::Decimal;
use uuid::Uuid;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AllocationPlan {
    pub id: Uuid,
    pub name: String,
    pub description: Option<String>,
    pub is_active: bool,
    pub allocations: Vec<Allocation>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Allocation {
    pub category: Category,
    pub target_percentage: Decimal,
    pub min_percentage: Option<Decimal>,
    pub max_percentage: Option<Decimal>,
}

impl AllocationPlan {
    pub fn default_balanced() -> Self {
        Self {
            id: Uuid::new_v4(),
            name: "平衡型".to_string(),
            description: Some("适合追求稳健增长的投资者".to_string()),
            is_active: true,
            allocations: vec![
                Allocation {
                    category: Category::Cash,
                    target_percentage: Decimal::new(20, 0),
                    min_percentage: None,
                    max_percentage: None,
                },
                Allocation {
                    category: Category::Stable,
                    target_percentage: Decimal::new(40, 0),
                    min_percentage: None,
                    max_percentage: None,
                },
                Allocation {
                    category: Category::Advanced,
                    target_percentage: Decimal::new(40, 0),
                    min_percentage: None,
                    max_percentage: None,
                },
            ],
            created_at: Utc::now(),
            updated_at: Utc::now(),
        }
    }
}
}

3.2 派生类型

#![allow(unused)]
fn main() {
// 偏离分析结果
#[derive(Debug, Clone)]
pub struct DeviationResult {
    pub category: Category,
    pub current_percentage: Decimal,
    pub target_percentage: Decimal,
    pub deviation: Decimal,
    pub status: DeviationStatus,
    pub direction: DeviationDirection,
}

#[derive(Debug, Clone, PartialEq)]
pub enum DeviationStatus {
    Normal,  // |偏离| <= 5%
    Mild,    // 5% < |偏离| <= 10%
    Severe,  // |偏离| > 10%
}

#[derive(Debug, Clone, PartialEq)]
pub enum DeviationDirection {
    Overweight,   // 超配
    Underweight,  // 低配
    Balanced,     // 平衡
}

// 收益分析结果
#[derive(Debug, Clone)]
pub struct ReturnAnalysis {
    pub start_date: NaiveDate,
    pub end_date: NaiveDate,
    pub start_value: Decimal,
    pub end_value: Decimal,
    pub absolute_return: Decimal,
    pub return_rate: Decimal,
    pub category_breakdown: Vec<CategoryReturn>,
}

#[derive(Debug, Clone)]
pub struct CategoryReturn {
    pub category: Category,
    pub start_value: Decimal,
    pub end_value: Decimal,
    pub absolute_return: Decimal,
    pub return_rate: Decimal,
    pub contribution_rate: Decimal,
}
}

4. 数据库设计

4.1 表结构

assets 表

CREATE TABLE IF NOT EXISTS assets (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL,
    category TEXT NOT NULL CHECK (category IN ('Cash', 'Stable', 'Advanced')),
    sub_category TEXT,
    current_value TEXT NOT NULL,  -- 使用 TEXT 存储 Decimal
    notes TEXT,
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL
);

CREATE INDEX idx_assets_category ON assets(category);

snapshots 表

CREATE TABLE IF NOT EXISTS snapshots (
    id TEXT PRIMARY KEY,
    snapshot_date TEXT NOT NULL,
    created_at TEXT NOT NULL,
    total_value TEXT NOT NULL
);

CREATE INDEX idx_snapshots_date ON snapshots(snapshot_date);

snapshot_items 表

CREATE TABLE IF NOT EXISTS snapshot_items (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    snapshot_id TEXT NOT NULL,
    asset_id TEXT NOT NULL,
    asset_name TEXT NOT NULL,
    category TEXT NOT NULL,
    sub_category TEXT,
    value TEXT NOT NULL,
    FOREIGN KEY (snapshot_id) REFERENCES snapshots(id) ON DELETE CASCADE
);

CREATE INDEX idx_snapshot_items_snapshot ON snapshot_items(snapshot_id);

allocation_plans 表

CREATE TABLE IF NOT EXISTS allocation_plans (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL,
    description TEXT,
    is_active INTEGER NOT NULL DEFAULT 0,
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL
);

allocations 表

CREATE TABLE IF NOT EXISTS allocations (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    plan_id TEXT NOT NULL,
    category TEXT NOT NULL,
    target_percentage TEXT NOT NULL,
    min_percentage TEXT,
    max_percentage TEXT,
    FOREIGN KEY (plan_id) REFERENCES allocation_plans(id) ON DELETE CASCADE
);

CREATE INDEX idx_allocations_plan ON allocations(plan_id);

4.2 数据库连接管理

#![allow(unused)]
fn main() {
// src/db/connection.rs
use rusqlite::{Connection, Result};
use std::path::PathBuf;

pub struct Database {
    conn: Connection,
}

impl Database {
    pub fn new() -> Result<Self> {
        let path = Self::db_path();
        std::fs::create_dir_all(path.parent().unwrap()).ok();
        let conn = Connection::open(&path)?;
        conn.execute_batch("PRAGMA foreign_keys = ON;")?;
        Ok(Self { conn })
    }
    
    fn db_path() -> PathBuf {
        let mut path = dirs::data_local_dir()
            .unwrap_or_else(|| PathBuf::from("."));
        path.push("asset-light");
        path.push("data.db");
        path
    }
    
    pub fn conn(&self) -> &Connection {
        &self.conn
    }
    
    pub fn run_migrations(&self) -> Result<()> {
        // 执行迁移脚本
        self.conn.execute_batch(include_str!("../../migrations/001_create_assets.sql"))?;
        self.conn.execute_batch(include_str!("../../migrations/002_create_snapshots.sql"))?;
        self.conn.execute_batch(include_str!("../../migrations/003_create_plans.sql"))?;
        Ok(())
    }
}
}

5. 状态管理

5.1 全局状态定义

#![allow(unused)]
fn main() {
// src/state/app_state.rs
use dioxus::prelude::*;
use crate::models::{Asset, Snapshot, AllocationPlan};

#[derive(Clone)]
pub struct AppState {
    pub assets: Vec<Asset>,
    pub snapshots: Vec<Snapshot>,
    pub plans: Vec<AllocationPlan>,
    pub active_plan: Option<AllocationPlan>,
    pub is_inventory_mode: bool,
    pub loading: bool,
    pub error: Option<String>,
}

impl Default for AppState {
    fn default() -> Self {
        Self {
            assets: Vec::new(),
            snapshots: Vec::new(),
            plans: Vec::new(),
            active_plan: None,
            is_inventory_mode: false,
            loading: false,
            error: None,
        }
    }
}

// 全局状态 Context
pub fn use_app_state() -> Signal<AppState> {
    use_context::<Signal<AppState>>()
}

pub fn provide_app_state() -> Signal<AppState> {
    use_context_provider(|| Signal::new(AppState::default()))
}
}

5.2 状态操作

#![allow(unused)]
fn main() {
// src/state/actions.rs

impl AppState {
    // 资产操作
    pub fn add_asset(&mut self, asset: Asset) {
        self.assets.push(asset);
    }
    
    pub fn update_asset(&mut self, asset: Asset) {
        if let Some(idx) = self.assets.iter().position(|a| a.id == asset.id) {
            self.assets[idx] = asset;
        }
    }
    
    pub fn remove_asset(&mut self, id: Uuid) {
        self.assets.retain(|a| a.id != id);
    }
    
    // 快照操作
    pub fn add_snapshot(&mut self, snapshot: Snapshot) {
        self.snapshots.insert(0, snapshot); // 最新在前
    }
    
    // 方案操作
    pub fn set_active_plan(&mut self, plan_id: Uuid) {
        for plan in &mut self.plans {
            plan.is_active = plan.id == plan_id;
        }
        self.active_plan = self.plans.iter()
            .find(|p| p.is_active)
            .cloned();
    }
    
    // 计算属性
    pub fn total_value(&self) -> Decimal {
        self.assets.iter().map(|a| a.current_value).sum()
    }
    
    pub fn category_value(&self, category: &Category) -> Decimal {
        self.assets
            .iter()
            .filter(|a| &a.category == category)
            .map(|a| a.current_value)
            .sum()
    }
    
    pub fn category_percentage(&self, category: &Category) -> Decimal {
        let total = self.total_value();
        if total.is_zero() {
            return Decimal::ZERO;
        }
        (self.category_value(category) / total) * Decimal::new(100, 0)
    }
}
}

6. 服务层

6.1 资产服务

#![allow(unused)]
fn main() {
// src/services/asset_service.rs
use crate::db::Database;
use crate::models::Asset;

pub struct AssetService {
    db: Database,
}

impl AssetService {
    pub fn new(db: Database) -> Self {
        Self { db }
    }
    
    pub fn list_all(&self) -> Result<Vec<Asset>, Error> {
        AssetRepository::new(self.db.conn()).find_all()
    }
    
    pub fn create(&self, asset: &Asset) -> Result<(), Error> {
        AssetRepository::new(self.db.conn()).insert(asset)
    }
    
    pub fn update(&self, asset: &Asset) -> Result<(), Error> {
        AssetRepository::new(self.db.conn()).update(asset)
    }
    
    pub fn delete(&self, id: Uuid) -> Result<(), Error> {
        AssetRepository::new(self.db.conn()).delete(id)
    }
}
}

6.2 快照服务

#![allow(unused)]
fn main() {
// src/services/snapshot_service.rs

pub struct SnapshotService {
    db: Database,
}

impl SnapshotService {
    pub fn create_snapshot(&self, assets: &[Asset]) -> Result<Snapshot, Error> {
        // 检查每日盘点次数限制
        let today_count = self.count_today_snapshots()?;
        if today_count >= 5 {
            return Err(Error::LimitExceeded("每天最多盘点5次".to_string()));
        }
        
        // 创建快照
        let items: Vec<SnapshotItem> = assets
            .iter()
            .map(|a| SnapshotItem {
                asset_id: a.id,
                asset_name: a.name.clone(),
                category: a.category.clone(),
                sub_category: a.sub_category.clone(),
                value: a.current_value,
            })
            .collect();
        
        let snapshot = Snapshot::new(items);
        SnapshotRepository::new(self.db.conn()).insert(&snapshot)?;
        
        Ok(snapshot)
    }
    
    pub fn list_all(&self) -> Result<Vec<Snapshot>, Error> {
        SnapshotRepository::new(self.db.conn()).find_all_ordered()
    }
    
    pub fn get_by_id(&self, id: Uuid) -> Result<Option<Snapshot>, Error> {
        SnapshotRepository::new(self.db.conn()).find_by_id(id)
    }
}
}

6.3 分析服务

#![allow(unused)]
fn main() {
// src/services/analysis_service.rs

pub struct AnalysisService;

impl AnalysisService {
    pub fn calculate_deviation(
        assets: &[Asset],
        plan: &AllocationPlan,
    ) -> Vec<DeviationResult> {
        let total: Decimal = assets.iter().map(|a| a.current_value).sum();
        
        if total.is_zero() {
            return vec![];
        }
        
        plan.allocations
            .iter()
            .map(|alloc| {
                let category_total: Decimal = assets
                    .iter()
                    .filter(|a| a.category == alloc.category)
                    .map(|a| a.current_value)
                    .sum();
                
                let current_pct = (category_total / total) * Decimal::new(100, 0);
                let target_pct = alloc.target_percentage;
                let deviation = current_pct - target_pct;
                let abs_deviation = deviation.abs();
                
                let status = if abs_deviation <= Decimal::new(5, 0) {
                    DeviationStatus::Normal
                } else if abs_deviation <= Decimal::new(10, 0) {
                    DeviationStatus::Mild
                } else {
                    DeviationStatus::Severe
                };
                
                let direction = if deviation > Decimal::ZERO {
                    DeviationDirection::Overweight
                } else if deviation < Decimal::ZERO {
                    DeviationDirection::Underweight
                } else {
                    DeviationDirection::Balanced
                };
                
                DeviationResult {
                    category: alloc.category.clone(),
                    current_percentage: current_pct,
                    target_percentage: target_pct,
                    deviation,
                    status,
                    direction,
                }
            })
            .collect()
    }
    
    pub fn calculate_return(
        start_snapshot: &Snapshot,
        end_snapshot: &Snapshot,
    ) -> ReturnAnalysis {
        let absolute_return = end_snapshot.total_value - start_snapshot.total_value;
        
        let return_rate = if start_snapshot.total_value > Decimal::ZERO {
            (absolute_return / start_snapshot.total_value) * Decimal::new(100, 0)
        } else {
            Decimal::ZERO
        };
        
        let categories = vec![Category::Cash, Category::Stable, Category::Advanced];
        
        let category_breakdown: Vec<CategoryReturn> = categories
            .into_iter()
            .map(|cat| {
                let start_val = start_snapshot.category_total(&cat);
                let end_val = end_snapshot.category_total(&cat);
                let cat_return = end_val - start_val;
                
                let cat_rate = if start_val > Decimal::ZERO {
                    (cat_return / start_val) * Decimal::new(100, 0)
                } else {
                    Decimal::ZERO
                };
                
                let contribution = if absolute_return != Decimal::ZERO {
                    (cat_return / absolute_return) * Decimal::new(100, 0)
                } else {
                    Decimal::ZERO
                };
                
                CategoryReturn {
                    category: cat,
                    start_value: start_val,
                    end_value: end_val,
                    absolute_return: cat_return,
                    return_rate: cat_rate,
                    contribution_rate: contribution,
                }
            })
            .collect();
        
        ReturnAnalysis {
            start_date: start_snapshot.snapshot_date,
            end_date: end_snapshot.snapshot_date,
            start_value: start_snapshot.total_value,
            end_value: end_snapshot.total_value,
            absolute_return,
            return_rate,
            category_breakdown,
        }
    }
}
}

7. 组件架构

7.1 组件层次

App
├── Router
│   ├── Layout
│   │   ├── Sidebar
│   │   │   └── NavItem
│   │   └── PageContainer
│   │       ├── HomePage
│   │       │   ├── TotalCard
│   │       │   ├── PieChartPair
│   │       │   └── DeviationTable
│   │       │
│   │       ├── AssetsPage
│   │       │   ├── AssetList
│   │       │   │   └── CategoryGroup
│   │       │   │       └── AssetItem
│   │       │   ├── AssetFormModal
│   │       │   └── InventoryMode
│   │       │
│   │       ├── HistoryPage
│   │       │   ├── SnapshotTimeline
│   │       │   │   └── SnapshotCard
│   │       │   └── SnapshotDetailModal
│   │       │
│   │       ├── PlansPage
│   │       │   ├── PlanList
│   │       │   │   └── PlanCard
│   │       │   └── PlanEditorModal
│   │       │
│   │       └── AnalysisPage
│   │           ├── PeriodSelector
│   │           ├── ReturnCards
│   │           ├── AttributionTable
│   │           └── TrendChart
│   │
│   └── ModalPortal
│       └── [各类模态框]

7.2 核心组件示例

根组件

#![allow(unused)]
fn main() {
// src/app.rs
use dioxus::prelude::*;
use crate::router::Route;
use crate::state::provide_app_state;
use crate::components::layout::Layout;

pub fn App() -> Element {
    // 提供全局状态
    let _state = provide_app_state();
    
    // 初始化数据
    use_effect(|| {
        spawn(async {
            // 从数据库加载数据
            load_initial_data().await;
        });
    });
    
    rsx! {
        Router::<Route> {}
    }
}
}

布局组件

#![allow(unused)]
fn main() {
// src/components/layout/mod.rs
use dioxus::prelude::*;
use crate::components::layout::sidebar::Sidebar;

#[component]
pub fn Layout() -> Element {
    rsx! {
        div {
            class: "flex h-screen bg-gray-50",
            
            Sidebar {}
            
            main {
                class: "flex-1 overflow-auto p-8",
                Outlet::<Route> {}
            }
        }
    }
}
}

侧边栏

#![allow(unused)]
fn main() {
// src/components/layout/sidebar.rs
use dioxus::prelude::*;
use crate::router::Route;

#[component]
pub fn Sidebar() -> Element {
    rsx! {
        nav {
            class: "w-56 bg-slate-900 text-white flex flex-col",
            
            // Logo
            div {
                class: "p-6 text-xl font-bold",
                "asset-light"
            }
            
            // Navigation
            div {
                class: "flex-1 px-4 space-y-2",
                
                NavItem { to: Route::Home {}, icon: "home", label: "首页" }
                NavItem { to: Route::Assets {}, icon: "wallet", label: "资产" }
                NavItem { to: Route::History {}, icon: "clock", label: "历史" }
                NavItem { to: Route::Plans {}, icon: "target", label: "配置" }
                NavItem { to: Route::Analysis {}, icon: "chart", label: "收益" }
            }
        }
    }
}

#[component]
fn NavItem(to: Route, icon: &'static str, label: &'static str) -> Element {
    let current_route = use_route::<Route>();
    let is_active = current_route == to;
    
    rsx! {
        Link {
            to: to,
            class: if is_active { 
                "flex items-center px-4 py-3 rounded-lg bg-blue-600" 
            } else { 
                "flex items-center px-4 py-3 rounded-lg hover:bg-slate-800" 
            },
            
            span { class: "mr-3", "{icon}" }
            span { "{label}" }
        }
    }
}
}

8. 路由设计

#![allow(unused)]
fn main() {
// src/router.rs
use dioxus::prelude::*;
use crate::pages::*;
use crate::components::layout::Layout;

#[derive(Routable, Clone, PartialEq)]
pub enum Route {
    #[layout(Layout)]
        #[route("/")]
        Home {},
        
        #[route("/assets")]
        Assets {},
        
        #[route("/history")]
        History {},
        
        #[route("/plans")]
        Plans {},
        
        #[route("/analysis")]
        Analysis {},
    #[end_layout]
    
    #[route("/:..route")]
    NotFound { route: Vec<String> },
}
}

9. 错误处理

#![allow(unused)]
fn main() {
// src/utils/error.rs
use thiserror::Error;

#[derive(Error, Debug)]
pub enum AppError {
    #[error("数据库错误: {0}")]
    Database(#[from] rusqlite::Error),
    
    #[error("验证失败: {0}")]
    Validation(String),
    
    #[error("操作限制: {0}")]
    LimitExceeded(String),
    
    #[error("未找到: {0}")]
    NotFound(String),
}

pub type Result<T> = std::result::Result<T, AppError>;
}

10. 配置文件

Cargo.toml

[package]
name = "asset-light"
version = "0.1.0"
edition = "2021"

[dependencies]
dioxus = { version = "0.5", features = ["desktop", "router"] }
rusqlite = { version = "0.31", features = ["bundled"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
uuid = { version = "1.0", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
rust_decimal = { version = "1.0", features = ["serde"] }
dirs = "5.0"
thiserror = "1.0"
tokio = { version = "1.0", features = ["full"] }

[profile.release]
opt-level = "z"
lto = true

Dioxus.toml

[application]
name = "asset-light"
default_platform = "desktop"

[desktop]
title = "asset-light"
min_width = 1280
min_height = 720

[desktop.window]
title = "asset-light - 个人资产管理"
resizable = true
decorations = true

11. 性能考虑

方面策略
启动速度延迟加载非首页数据
列表渲染虚拟滚动(大数据量时)
状态更新细粒度 Signal,避免全量刷新
数据库索引优化,批量操作
内存及时释放大对象引用

12. 安全考虑

方面措施
数据存储本地 SQLite,无网络传输
输入验证服务层统一验证
SQL 注入使用参数化查询
文件访问仅访问应用专属目录

13. 开发路线图

阶段 1:基础设施 (Week 1)

  • 项目初始化
  • 数据库层实现
  • 基础组件库
  • 路由和布局

阶段 2:核心功能 (Week 2-3)

  • 资产 CRUD
  • 盘点功能
  • 快照存储

阶段 3:配置和分析 (Week 4)

  • 配置方案管理
  • 偏离度计算
  • 收益分析

阶段 4:完善优化 (Week 5)

  • UI 打磨
  • 性能优化
  • 错误处理
  • 测试

修订历史

版本日期作者变更说明
1.0.02025-12-20Architect初始版本

Test Design Document: asset-light

版本: 1.0.0
日期: 2025-12-20
测试工程师: TEA Agent


1. 测试概述

1.1 测试目标

为 asset-light MVP 建立全面的测试策略,确保:

  • 核心业务逻辑正确性
  • 数据持久化可靠性
  • UI 交互符合预期
  • 性能指标达标

1.2 测试范围

模块测试类型优先级
数据模型单元测试P0
业务服务单元测试 + 集成测试P0
数据库层集成测试P0
组件渲染组件测试P1
端到端流程E2E 测试P1
性能基准性能测试P2

1.3 测试工具

用途工具
单元测试Rust 内置 #[test]
断言增强pretty_assertions
Mockmockall
异步测试tokio::test
测试覆盖率cargo-tarpaulin
基准测试criterion

2. 测试策略

2.1 测试金字塔

        ┌───────────┐
        │   E2E     │  少量关键流程
        │  Tests    │  
        ├───────────┤
        │Integration│  服务 + 数据库
        │  Tests    │
        ├───────────┤
        │   Unit    │  大量覆盖
        │  Tests    │  模型 + 服务 + 工具
        └───────────┘

2.2 覆盖率目标

层级目标覆盖率
模型层 (models)≥ 90%
服务层 (services)≥ 85%
数据库层 (db)≥ 80%
工具函数 (utils)≥ 90%
整体≥ 80%

3. 单元测试

3.1 模型层测试

3.1.1 Asset 模型

测试文件: src/models/asset.rs

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    use rust_decimal_macros::dec;

    #[test]
    fn test_asset_new() {
        let asset = Asset::new(
            "余额宝".to_string(),
            Category::Cash,
            dec!(10000.00),
        );
        
        assert!(!asset.id.is_nil());
        assert_eq!(asset.name, "余额宝");
        assert_eq!(asset.category, Category::Cash);
        assert_eq!(asset.current_value, dec!(10000.00));
        assert!(asset.sub_category.is_none());
        assert!(asset.notes.is_none());
    }

    #[test]
    fn test_category_display_name() {
        assert_eq!(Category::Cash.display_name(), "现金类");
        assert_eq!(Category::Stable.display_name(), "稳健类");
        assert_eq!(Category::Advanced.display_name(), "进阶类");
    }

    #[test]
    fn test_asset_with_sub_category() {
        let mut asset = Asset::new(
            "沪深300ETF".to_string(),
            Category::Advanced,
            dec!(50000.00),
        );
        asset.sub_category = Some("宽基指数".to_string());
        
        assert_eq!(asset.sub_category, Some("宽基指数".to_string()));
    }
}
}

3.1.2 Snapshot 模型

测试文件: src/models/snapshot.rs

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    use rust_decimal_macros::dec;

    #[test]
    fn test_snapshot_new_calculates_total() {
        let items = vec![
            SnapshotItem {
                asset_id: Uuid::new_v4(),
                asset_name: "资产A".to_string(),
                category: Category::Cash,
                sub_category: None,
                value: dec!(10000.00),
            },
            SnapshotItem {
                asset_id: Uuid::new_v4(),
                asset_name: "资产B".to_string(),
                category: Category::Stable,
                sub_category: None,
                value: dec!(20000.00),
            },
        ];
        
        let snapshot = Snapshot::new(items);
        
        assert_eq!(snapshot.total_value, dec!(30000.00));
        assert_eq!(snapshot.items.len(), 2);
    }

    #[test]
    fn test_snapshot_category_total() {
        let items = vec![
            SnapshotItem {
                asset_id: Uuid::new_v4(),
                asset_name: "现金A".to_string(),
                category: Category::Cash,
                sub_category: None,
                value: dec!(10000.00),
            },
            SnapshotItem {
                asset_id: Uuid::new_v4(),
                asset_name: "现金B".to_string(),
                category: Category::Cash,
                sub_category: None,
                value: dec!(5000.00),
            },
            SnapshotItem {
                asset_id: Uuid::new_v4(),
                asset_name: "稳健".to_string(),
                category: Category::Stable,
                sub_category: None,
                value: dec!(20000.00),
            },
        ];
        
        let snapshot = Snapshot::new(items);
        
        assert_eq!(snapshot.category_total(&Category::Cash), dec!(15000.00));
        assert_eq!(snapshot.category_total(&Category::Stable), dec!(20000.00));
        assert_eq!(snapshot.category_total(&Category::Advanced), dec!(0.00));
    }

    #[test]
    fn test_snapshot_empty_items() {
        let snapshot = Snapshot::new(vec![]);
        
        assert_eq!(snapshot.total_value, dec!(0.00));
        assert!(snapshot.items.is_empty());
    }
}
}

3.1.3 AllocationPlan 模型

测试文件: src/models/plan.rs

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    use rust_decimal_macros::dec;

    #[test]
    fn test_default_balanced_plan() {
        let plan = AllocationPlan::default_balanced();
        
        assert_eq!(plan.name, "平衡型");
        assert!(plan.is_active);
        assert_eq!(plan.allocations.len(), 3);
        
        let total: Decimal = plan.allocations
            .iter()
            .map(|a| a.target_percentage)
            .sum();
        assert_eq!(total, dec!(100));
    }

    #[test]
    fn test_template_conservative() {
        let plan = AllocationPlan::template_conservative();
        
        let cash = plan.allocations.iter()
            .find(|a| a.category == Category::Cash)
            .unwrap();
        assert_eq!(cash.target_percentage, dec!(30));
    }

    #[test]
    fn test_template_aggressive() {
        let plan = AllocationPlan::template_aggressive();
        
        let advanced = plan.allocations.iter()
            .find(|a| a.category == Category::Advanced)
            .unwrap();
        assert_eq!(advanced.target_percentage, dec!(70));
    }
}
}

3.2 服务层测试

3.2.1 AnalysisService 测试

测试文件: src/services/analysis_service.rs

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    use rust_decimal_macros::dec;

    fn create_test_assets() -> Vec<Asset> {
        vec![
            Asset {
                id: Uuid::new_v4(),
                name: "现金".to_string(),
                category: Category::Cash,
                sub_category: None,
                current_value: dec!(25000.00), // 25%
                notes: None,
                created_at: Utc::now(),
                updated_at: Utc::now(),
            },
            Asset {
                id: Uuid::new_v4(),
                name: "稳健".to_string(),
                category: Category::Stable,
                sub_category: None,
                current_value: dec!(40000.00), // 40%
                notes: None,
                created_at: Utc::now(),
                updated_at: Utc::now(),
            },
            Asset {
                id: Uuid::new_v4(),
                name: "进阶".to_string(),
                category: Category::Advanced,
                sub_category: None,
                current_value: dec!(35000.00), // 35%
                notes: None,
                created_at: Utc::now(),
                updated_at: Utc::now(),
            },
        ]
    }

    fn create_test_plan() -> AllocationPlan {
        AllocationPlan {
            id: Uuid::new_v4(),
            name: "测试方案".to_string(),
            description: None,
            is_active: true,
            allocations: vec![
                Allocation {
                    category: Category::Cash,
                    target_percentage: dec!(20),
                    min_percentage: None,
                    max_percentage: None,
                },
                Allocation {
                    category: Category::Stable,
                    target_percentage: dec!(40),
                    min_percentage: None,
                    max_percentage: None,
                },
                Allocation {
                    category: Category::Advanced,
                    target_percentage: dec!(40),
                    min_percentage: None,
                    max_percentage: None,
                },
            ],
            created_at: Utc::now(),
            updated_at: Utc::now(),
        }
    }

    #[test]
    fn test_calculate_deviation_normal() {
        let assets = create_test_assets();
        let plan = create_test_plan();
        
        let results = AnalysisService::calculate_deviation(&assets, &plan);
        
        assert_eq!(results.len(), 3);
        
        // 现金类: 25% - 20% = +5% (超配,轻度偏离)
        let cash = results.iter().find(|r| r.category == Category::Cash).unwrap();
        assert_eq!(cash.deviation, dec!(5));
        assert_eq!(cash.status, DeviationStatus::Mild);
        assert_eq!(cash.direction, DeviationDirection::Overweight);
        
        // 稳健类: 40% - 40% = 0% (正常)
        let stable = results.iter().find(|r| r.category == Category::Stable).unwrap();
        assert_eq!(stable.deviation, dec!(0));
        assert_eq!(stable.status, DeviationStatus::Normal);
        
        // 进阶类: 35% - 40% = -5% (低配,正常边界)
        let advanced = results.iter().find(|r| r.category == Category::Advanced).unwrap();
        assert_eq!(advanced.deviation, dec!(-5));
        assert_eq!(advanced.status, DeviationStatus::Normal);
        assert_eq!(advanced.direction, DeviationDirection::Underweight);
    }

    #[test]
    fn test_calculate_deviation_severe() {
        let assets = vec![
            Asset {
                id: Uuid::new_v4(),
                name: "现金".to_string(),
                category: Category::Cash,
                sub_category: None,
                current_value: dec!(50000.00), // 50%
                notes: None,
                created_at: Utc::now(),
                updated_at: Utc::now(),
            },
            Asset {
                id: Uuid::new_v4(),
                name: "进阶".to_string(),
                category: Category::Advanced,
                sub_category: None,
                current_value: dec!(50000.00), // 50%
                notes: None,
                created_at: Utc::now(),
                updated_at: Utc::now(),
            },
        ];
        let plan = create_test_plan();
        
        let results = AnalysisService::calculate_deviation(&assets, &plan);
        
        // 现金类: 50% - 20% = +30% (严重偏离)
        let cash = results.iter().find(|r| r.category == Category::Cash).unwrap();
        assert_eq!(cash.status, DeviationStatus::Severe);
    }

    #[test]
    fn test_calculate_deviation_empty_assets() {
        let assets: Vec<Asset> = vec![];
        let plan = create_test_plan();
        
        let results = AnalysisService::calculate_deviation(&assets, &plan);
        
        assert!(results.is_empty());
    }

    #[test]
    fn test_calculate_return() {
        let start_items = vec![
            SnapshotItem {
                asset_id: Uuid::new_v4(),
                asset_name: "资产".to_string(),
                category: Category::Cash,
                sub_category: None,
                value: dec!(100000.00),
            },
        ];
        let end_items = vec![
            SnapshotItem {
                asset_id: Uuid::new_v4(),
                asset_name: "资产".to_string(),
                category: Category::Cash,
                sub_category: None,
                value: dec!(110000.00),
            },
        ];
        
        let start = Snapshot::new(start_items);
        let end = Snapshot::new(end_items);
        
        let result = AnalysisService::calculate_return(&start, &end);
        
        assert_eq!(result.absolute_return, dec!(10000.00));
        assert_eq!(result.return_rate, dec!(10)); // 10%
    }

    #[test]
    fn test_calculate_return_negative() {
        let start_items = vec![
            SnapshotItem {
                asset_id: Uuid::new_v4(),
                asset_name: "资产".to_string(),
                category: Category::Advanced,
                sub_category: None,
                value: dec!(100000.00),
            },
        ];
        let end_items = vec![
            SnapshotItem {
                asset_id: Uuid::new_v4(),
                asset_name: "资产".to_string(),
                category: Category::Advanced,
                sub_category: None,
                value: dec!(90000.00),
            },
        ];
        
        let start = Snapshot::new(start_items);
        let end = Snapshot::new(end_items);
        
        let result = AnalysisService::calculate_return(&start, &end);
        
        assert_eq!(result.absolute_return, dec!(-10000.00));
        assert_eq!(result.return_rate, dec!(-10)); // -10%
    }

    #[test]
    fn test_calculate_return_zero_start() {
        let start = Snapshot::new(vec![]);
        let end_items = vec![
            SnapshotItem {
                asset_id: Uuid::new_v4(),
                asset_name: "资产".to_string(),
                category: Category::Cash,
                sub_category: None,
                value: dec!(10000.00),
            },
        ];
        let end = Snapshot::new(end_items);
        
        let result = AnalysisService::calculate_return(&start, &end);
        
        assert_eq!(result.return_rate, dec!(0)); // 避免除零
    }

    #[test]
    fn test_category_return_contribution() {
        let start_items = vec![
            SnapshotItem {
                asset_id: Uuid::new_v4(),
                asset_name: "现金".to_string(),
                category: Category::Cash,
                sub_category: None,
                value: dec!(50000.00),
            },
            SnapshotItem {
                asset_id: Uuid::new_v4(),
                asset_name: "进阶".to_string(),
                category: Category::Advanced,
                sub_category: None,
                value: dec!(50000.00),
            },
        ];
        let end_items = vec![
            SnapshotItem {
                asset_id: Uuid::new_v4(),
                asset_name: "现金".to_string(),
                category: Category::Cash,
                sub_category: None,
                value: dec!(51000.00), // +1000
            },
            SnapshotItem {
                asset_id: Uuid::new_v4(),
                asset_name: "进阶".to_string(),
                category: Category::Advanced,
                sub_category: None,
                value: dec!(59000.00), // +9000
            },
        ];
        
        let start = Snapshot::new(start_items);
        let end = Snapshot::new(end_items);
        
        let result = AnalysisService::calculate_return(&start, &end);
        
        // 总收益 10000,现金贡献 10%,进阶贡献 90%
        let cash_contrib = result.category_breakdown
            .iter()
            .find(|c| c.category == Category::Cash)
            .unwrap();
        assert_eq!(cash_contrib.contribution_rate, dec!(10));
        
        let advanced_contrib = result.category_breakdown
            .iter()
            .find(|c| c.category == Category::Advanced)
            .unwrap();
        assert_eq!(advanced_contrib.contribution_rate, dec!(90));
    }
}
}

3.3 工具函数测试

3.3.1 金额格式化

测试文件: src/utils/format.rs

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    use rust_decimal_macros::dec;

    #[test]
    fn test_format_currency() {
        assert_eq!(format_currency(dec!(1234567.89)), "¥ 1,234,567.89");
        assert_eq!(format_currency(dec!(0)), "¥ 0.00");
        assert_eq!(format_currency(dec!(-1234.56)), "-¥ 1,234.56");
    }

    #[test]
    fn test_format_percentage() {
        assert_eq!(format_percentage(dec!(25.5)), "25.50%");
        assert_eq!(format_percentage(dec!(0)), "0.00%");
        assert_eq!(format_percentage(dec!(-5.25)), "-5.25%");
    }

    #[test]
    fn test_format_change() {
        assert_eq!(format_change(dec!(1234.56)), "+¥ 1,234.56");
        assert_eq!(format_change(dec!(-1234.56)), "-¥ 1,234.56");
        assert_eq!(format_change(dec!(0)), "¥ 0.00");
    }

    #[test]
    fn test_format_change_percentage() {
        assert_eq!(format_change_percentage(dec!(5.25)), "+5.25%");
        assert_eq!(format_change_percentage(dec!(-3.5)), "-3.50%");
    }
}
}

3.3.2 日期处理

测试文件: src/utils/date.rs

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    use chrono::NaiveDate;

    #[test]
    fn test_get_quarter() {
        assert_eq!(get_quarter(NaiveDate::from_ymd_opt(2025, 1, 15).unwrap()), 1);
        assert_eq!(get_quarter(NaiveDate::from_ymd_opt(2025, 4, 1).unwrap()), 2);
        assert_eq!(get_quarter(NaiveDate::from_ymd_opt(2025, 7, 31).unwrap()), 3);
        assert_eq!(get_quarter(NaiveDate::from_ymd_opt(2025, 12, 31).unwrap()), 4);
    }

    #[test]
    fn test_get_quarter_date_range() {
        let (start, end) = get_quarter_date_range(2025, 1);
        assert_eq!(start, NaiveDate::from_ymd_opt(2025, 1, 1).unwrap());
        assert_eq!(end, NaiveDate::from_ymd_opt(2025, 3, 31).unwrap());
        
        let (start, end) = get_quarter_date_range(2025, 4);
        assert_eq!(start, NaiveDate::from_ymd_opt(2025, 10, 1).unwrap());
        assert_eq!(end, NaiveDate::from_ymd_opt(2025, 12, 31).unwrap());
    }

    #[test]
    fn test_format_date_display() {
        let date = NaiveDate::from_ymd_opt(2025, 12, 20).unwrap();
        assert_eq!(format_date_display(date), "2025-12-20");
    }

    #[test]
    fn test_format_date_friendly() {
        let date = NaiveDate::from_ymd_opt(2025, 12, 20).unwrap();
        assert_eq!(format_date_friendly(date), "12月20日");
    }
}
}

4. 集成测试

4.1 数据库层集成测试

测试文件: tests/db_integration.rs

#![allow(unused)]
fn main() {
use asset_light::db::{Database, AssetRepository, SnapshotRepository, PlanRepository};
use asset_light::models::*;
use rust_decimal_macros::dec;
use tempfile::TempDir;

fn setup_test_db() -> (Database, TempDir) {
    let temp_dir = TempDir::new().unwrap();
    let db_path = temp_dir.path().join("test.db");
    std::env::set_var("ASSET_LIGHT_DB_PATH", db_path.to_str().unwrap());
    
    let db = Database::new().unwrap();
    db.run_migrations().unwrap();
    
    (db, temp_dir)
}

#[test]
fn test_asset_crud() {
    let (db, _temp) = setup_test_db();
    let repo = AssetRepository::new(db.conn());
    
    // Create
    let asset = Asset::new("测试资产".to_string(), Category::Cash, dec!(10000));
    repo.insert(&asset).unwrap();
    
    // Read
    let found = repo.find_by_id(asset.id).unwrap().unwrap();
    assert_eq!(found.name, "测试资产");
    
    // Update
    let mut updated = found.clone();
    updated.current_value = dec!(20000);
    repo.update(&updated).unwrap();
    
    let found = repo.find_by_id(asset.id).unwrap().unwrap();
    assert_eq!(found.current_value, dec!(20000));
    
    // Delete
    repo.delete(asset.id).unwrap();
    let found = repo.find_by_id(asset.id).unwrap();
    assert!(found.is_none());
}

#[test]
fn test_asset_find_by_category() {
    let (db, _temp) = setup_test_db();
    let repo = AssetRepository::new(db.conn());
    
    repo.insert(&Asset::new("现金1".to_string(), Category::Cash, dec!(1000))).unwrap();
    repo.insert(&Asset::new("现金2".to_string(), Category::Cash, dec!(2000))).unwrap();
    repo.insert(&Asset::new("稳健".to_string(), Category::Stable, dec!(3000))).unwrap();
    
    let cash_assets = repo.find_by_category(Category::Cash).unwrap();
    assert_eq!(cash_assets.len(), 2);
    
    let stable_assets = repo.find_by_category(Category::Stable).unwrap();
    assert_eq!(stable_assets.len(), 1);
}

#[test]
fn test_snapshot_with_items() {
    let (db, _temp) = setup_test_db();
    let asset_repo = AssetRepository::new(db.conn());
    let snapshot_repo = SnapshotRepository::new(db.conn());
    
    // 创建资产
    let asset = Asset::new("资产".to_string(), Category::Cash, dec!(10000));
    asset_repo.insert(&asset).unwrap();
    
    // 创建快照
    let items = vec![SnapshotItem {
        asset_id: asset.id,
        asset_name: asset.name.clone(),
        category: asset.category.clone(),
        sub_category: None,
        value: asset.current_value,
    }];
    let snapshot = Snapshot::new(items);
    snapshot_repo.insert(&snapshot).unwrap();
    
    // 查询快照
    let found = snapshot_repo.find_by_id(snapshot.id).unwrap().unwrap();
    assert_eq!(found.items.len(), 1);
    assert_eq!(found.total_value, dec!(10000));
}

#[test]
fn test_plan_activation() {
    let (db, _temp) = setup_test_db();
    let repo = PlanRepository::new(db.conn());
    
    // 创建两个方案
    let plan1 = AllocationPlan::default_balanced();
    let mut plan2 = AllocationPlan::template_conservative();
    plan2.is_active = false;
    
    repo.insert(&plan1).unwrap();
    repo.insert(&plan2).unwrap();
    
    // 激活 plan2
    repo.set_active(plan2.id).unwrap();
    
    // 验证
    let found1 = repo.find_by_id(plan1.id).unwrap().unwrap();
    let found2 = repo.find_by_id(plan2.id).unwrap().unwrap();
    
    assert!(!found1.is_active);
    assert!(found2.is_active);
    
    // 查询激活方案
    let active = repo.find_active().unwrap().unwrap();
    assert_eq!(active.id, plan2.id);
}

#[test]
fn test_snapshot_daily_limit() {
    let (db, _temp) = setup_test_db();
    let repo = SnapshotRepository::new(db.conn());
    
    // 创建 5 个快照
    for i in 0..5 {
        let snapshot = Snapshot::new(vec![]);
        repo.insert(&snapshot).unwrap();
    }
    
    // 第 6 个应该检查失败
    let count = repo.count_today().unwrap();
    assert_eq!(count, 5);
}
}

5. 业务流程测试

5.1 盘点流程测试

#![allow(unused)]
fn main() {
#[cfg(test)]
mod inventory_flow_tests {
    use super::*;

    #[test]
    fn test_complete_inventory_flow() {
        let (db, _temp) = setup_test_db();
        let asset_service = AssetService::new(db.clone());
        let snapshot_service = SnapshotService::new(db.clone());
        
        // 1. 创建资产
        let asset1 = Asset::new("资产1".to_string(), Category::Cash, dec!(10000));
        let asset2 = Asset::new("资产2".to_string(), Category::Stable, dec!(20000));
        asset_service.create(&asset1).unwrap();
        asset_service.create(&asset2).unwrap();
        
        // 2. 执行盘点
        let assets = asset_service.list_all().unwrap();
        let snapshot = snapshot_service.create_snapshot(&assets).unwrap();
        
        assert_eq!(snapshot.total_value, dec!(30000));
        assert_eq!(snapshot.items.len(), 2);
        
        // 3. 更新资产
        let mut updated = asset1.clone();
        updated.current_value = dec!(15000);
        asset_service.update(&updated).unwrap();
        
        // 4. 再次盘点
        let assets = asset_service.list_all().unwrap();
        let snapshot2 = snapshot_service.create_snapshot(&assets).unwrap();
        
        assert_eq!(snapshot2.total_value, dec!(35000));
        
        // 5. 验证历史
        let history = snapshot_service.list_all().unwrap();
        assert_eq!(history.len(), 2);
    }

    #[test]
    fn test_inventory_daily_limit() {
        let (db, _temp) = setup_test_db();
        let asset_service = AssetService::new(db.clone());
        let snapshot_service = SnapshotService::new(db.clone());
        
        // 创建资产
        let asset = Asset::new("资产".to_string(), Category::Cash, dec!(10000));
        asset_service.create(&asset).unwrap();
        
        // 盘点 5 次
        for _ in 0..5 {
            let assets = asset_service.list_all().unwrap();
            snapshot_service.create_snapshot(&assets).unwrap();
        }
        
        // 第 6 次应该失败
        let assets = asset_service.list_all().unwrap();
        let result = snapshot_service.create_snapshot(&assets);
        
        assert!(result.is_err());
        match result {
            Err(AppError::LimitExceeded(msg)) => {
                assert!(msg.contains("5次"));
            }
            _ => panic!("Expected LimitExceeded error"),
        }
    }
}
}

5.2 收益分析流程测试

#![allow(unused)]
fn main() {
#[cfg(test)]
mod analysis_flow_tests {
    use super::*;

    #[test]
    fn test_quarterly_return_analysis() {
        let (db, _temp) = setup_test_db();
        let snapshot_repo = SnapshotRepository::new(db.conn());
        
        // 创建 Q4 的两个快照
        let start_items = vec![
            SnapshotItem {
                asset_id: Uuid::new_v4(),
                asset_name: "资产".to_string(),
                category: Category::Cash,
                sub_category: None,
                value: dec!(100000),
            },
        ];
        let mut start = Snapshot::new(start_items);
        start.snapshot_date = NaiveDate::from_ymd_opt(2025, 10, 1).unwrap();
        snapshot_repo.insert(&start).unwrap();
        
        let end_items = vec![
            SnapshotItem {
                asset_id: Uuid::new_v4(),
                asset_name: "资产".to_string(),
                category: Category::Cash,
                sub_category: None,
                value: dec!(105000),
            },
        ];
        let mut end = Snapshot::new(end_items);
        end.snapshot_date = NaiveDate::from_ymd_opt(2025, 12, 20).unwrap();
        snapshot_repo.insert(&end).unwrap();
        
        // 查询 Q4 收益
        let (q_start, q_end) = get_quarter_date_range(2025, 4);
        let snapshots = snapshot_repo.find_in_range(q_start, q_end).unwrap();
        
        assert_eq!(snapshots.len(), 2);
        
        let result = AnalysisService::calculate_return(
            snapshots.last().unwrap(),  // 最早
            snapshots.first().unwrap(), // 最新
        );
        
        assert_eq!(result.absolute_return, dec!(5000));
        assert_eq!(result.return_rate, dec!(5));
    }
}
}

6. 边界条件测试

6.1 数据边界

#![allow(unused)]
fn main() {
#[cfg(test)]
mod boundary_tests {
    use super::*;

    #[test]
    fn test_asset_name_max_length() {
        let name = "a".repeat(100);
        let asset = Asset::new(name.clone(), Category::Cash, dec!(1000));
        assert_eq!(asset.name.len(), 100);
        
        // 超过 100 字符应该验证失败
        let long_name = "a".repeat(101);
        let result = validate_asset_name(&long_name);
        assert!(result.is_err());
    }

    #[test]
    fn test_asset_value_zero() {
        let asset = Asset::new("零值资产".to_string(), Category::Cash, dec!(0));
        assert_eq!(asset.current_value, dec!(0));
    }

    #[test]
    fn test_asset_value_negative_rejected() {
        let result = validate_asset_value(dec!(-100));
        assert!(result.is_err());
    }

    #[test]
    fn test_asset_value_precision() {
        // 保留 2 位小数
        let asset = Asset::new("资产".to_string(), Category::Cash, dec!(1234.567));
        // 应该被截断或四舍五入到 1234.57
        assert_eq!(asset.current_value.scale(), 2);
    }

    #[test]
    fn test_allocation_total_not_100() {
        let plan = AllocationPlan {
            id: Uuid::new_v4(),
            name: "错误方案".to_string(),
            description: None,
            is_active: false,
            allocations: vec![
                Allocation {
                    category: Category::Cash,
                    target_percentage: dec!(30),
                    min_percentage: None,
                    max_percentage: None,
                },
                Allocation {
                    category: Category::Stable,
                    target_percentage: dec!(30),
                    min_percentage: None,
                    max_percentage: None,
                },
                // 缺少 Advanced,总和只有 60%
            ],
            created_at: Utc::now(),
            updated_at: Utc::now(),
        };
        
        let result = validate_allocation_plan(&plan);
        assert!(result.is_err());
    }

    #[test]
    fn test_empty_assets_deviation() {
        let assets: Vec<Asset> = vec![];
        let plan = AllocationPlan::default_balanced();
        
        let results = AnalysisService::calculate_deviation(&assets, &plan);
        assert!(results.is_empty());
    }

    #[test]
    fn test_single_snapshot_return() {
        // 只有一个快照时无法计算收益
        let snapshot = Snapshot::new(vec![]);
        
        // 应该返回特殊值或错误
        // 具体取决于 API 设计
    }
}
}

7. 性能测试

7.1 基准测试

文件: benches/performance.rs

#![allow(unused)]
fn main() {
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use asset_light::services::AnalysisService;
use asset_light::models::*;
use rust_decimal_macros::dec;
use uuid::Uuid;

fn create_large_asset_list(count: usize) -> Vec<Asset> {
    (0..count)
        .map(|i| Asset {
            id: Uuid::new_v4(),
            name: format!("资产{}", i),
            category: match i % 3 {
                0 => Category::Cash,
                1 => Category::Stable,
                _ => Category::Advanced,
            },
            sub_category: None,
            current_value: dec!(10000),
            notes: None,
            created_at: Utc::now(),
            updated_at: Utc::now(),
        })
        .collect()
}

fn bench_deviation_calculation(c: &mut Criterion) {
    let assets = create_large_asset_list(100);
    let plan = AllocationPlan::default_balanced();
    
    c.bench_function("calculate_deviation_100_assets", |b| {
        b.iter(|| {
            AnalysisService::calculate_deviation(
                black_box(&assets),
                black_box(&plan),
            )
        })
    });
}

fn bench_return_calculation(c: &mut Criterion) {
    let items: Vec<SnapshotItem> = (0..100)
        .map(|i| SnapshotItem {
            asset_id: Uuid::new_v4(),
            asset_name: format!("资产{}", i),
            category: Category::Cash,
            sub_category: None,
            value: dec!(10000),
        })
        .collect();
    
    let start = Snapshot::new(items.clone());
    let end = Snapshot::new(items);
    
    c.bench_function("calculate_return_100_items", |b| {
        b.iter(|| {
            AnalysisService::calculate_return(
                black_box(&start),
                black_box(&end),
            )
        })
    });
}

criterion_group!(benches, bench_deviation_calculation, bench_return_calculation);
criterion_main!(benches);
}

7.2 性能指标

场景目标验收标准
应用启动< 2s冷启动到可交互
页面切换< 200ms点击到渲染完成
盘点保存< 500ms100 个资产
偏离计算< 50ms100 个资产
收益计算< 100ms1000 条快照

8. 测试执行

8.1 测试命令

# 运行所有测试
cargo test

# 运行单元测试
cargo test --lib

# 运行集成测试
cargo test --test '*'

# 运行特定模块测试
cargo test models::
cargo test services::

# 显示详细输出
cargo test -- --nocapture

# 运行性能基准
cargo bench

# 生成覆盖率报告
cargo tarpaulin --out Html

8.2 CI 集成

# .github/workflows/test.yml
name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - name: Run tests
        run: cargo test --all
      - name: Run clippy
        run: cargo clippy -- -D warnings

9. 测试清单

9.1 模型层测试清单

测试状态
Asset::new() 创建
Category 枚举显示名
Snapshot 总值计算
Snapshot 类别汇总
AllocationPlan 模板

9.2 服务层测试清单

测试状态
偏离度正常计算
偏离度严重偏离
偏离度空资产
收益正向计算
收益负向计算
收益贡献度
每日盘点限制

9.3 集成测试清单

测试状态
资产 CRUD
快照存储
方案激活切换
完整盘点流程
季度收益分析

9.4 边界测试清单

测试状态
名称最大长度
零值资产
负值拒绝
占比非 100%
空资产偏离

修订历史

版本日期作者变更说明
1.0.02025-12-20TEA Agent初始版本

Epics and Stories: asset-light MVP

版本: 1.0.0
日期: 2025-12-20
关联 PRD: PRD


概览

本文档将 PRD 中的功能需求拆分为可执行的 Epics 和 Stories,用于指导开发迭代。

Epic 总览

Epic ID名称Stories 数量优先级
E0项目基础设施4P0
E1资产盘点模块8P0
E2配置方案模块6P0
E3资产视图模块5P0
E4收益分析模块5P1

优先级定义

  • P0: MVP 必须完成,阻塞发布
  • P1: MVP 应该完成,可降级
  • P2: MVP 可选,后续迭代

E0: 项目基础设施

目标: 搭建项目骨架,建立开发基础

E0-S1: 项目初始化

优先级: P0

描述:
作为开发者,我需要初始化 Dioxus 桌面项目,配置基础开发环境,以便开始功能开发。

验收标准:

  • 创建 Cargo 项目,配置 Dioxus Desktop 依赖
  • 配置 macOS 应用 bundle 设置(应用名称、图标占位)
  • 应用能够成功编译并启动空白窗口
  • 建立基础目录结构(src/components, src/models, src/services)
  • 配置 rustfmt 和 clippy

技术备注:

依赖: dioxus = { version = "0.5", features = ["desktop"] }

E0-S2: 数据持久化层

优先级: P0

描述:
作为开发者,我需要建立本地数据存储机制,以便应用能够保存和读取用户数据。

验收标准:

  • 选定并集成 SQLite 作为本地数据库
  • 实现数据库初始化和迁移机制
  • 创建基础的 CRUD 操作 trait
  • 数据文件存储在用户目录下的应用专属位置
  • 错误处理:数据库损坏时给出友好提示

技术备注:

建议使用 rusqlite 或 sqlx (with sqlite feature)
数据路径: ~/Library/Application Support/asset-light/

E0-S3: 应用布局框架

优先级: P0

描述:
作为用户,我希望应用有清晰的导航结构,以便快速切换不同功能模块。

验收标准:

  • 实现侧边栏导航组件
  • 导航项包含:首页、资产管理、盘点历史、配置方案、收益分析
  • 当前激活项有视觉高亮
  • 路由系统支持页面切换
  • 页面切换响应时间 < 200ms

界面参考:

┌─────────┬────────────────────────────────┐
│ 侧边栏   │                                │
│ ──────  │         主内容区域              │
│ 首页    │                                │
│ 资产    │                                │
│ 历史    │                                │
│ 配置    │                                │
│ 收益    │                                │
└─────────┴────────────────────────────────┘

E0-S4: 全局状态管理

优先级: P0

描述:
作为开发者,我需要建立全局状态管理机制,以便跨组件共享数据(如当前资产列表、激活配置方案)。

验收标准:

  • 使用 Dioxus Signal 或 Context 实现全局状态
  • 定义核心状态结构:AppState(资产列表、快照列表、配置方案列表、当前激活方案)
  • 状态变更能触发相关组件重渲染
  • 状态持久化:应用启动时从数据库加载,变更时自动保存

E1: 资产盘点模块

目标: 实现资产条目的增删改查和快照式盘点功能

E1-S1: 资产条目数据模型

优先级: P0
PRD 引用: FR-F1-01, FR-F1-02

描述:
作为开发者,我需要定义资产条目的数据模型和数据库表结构。

验收标准:

  • 定义 Asset 结构体,包含:id, name, category, sub_category, current_value, notes, created_at, updated_at
  • 定义 Category 枚举:Cash, Stable, Advanced
  • 创建 assets 表并实现 CRUD 操作
  • 支持按类别查询资产列表
  • 金额精度:小数点后 2 位

数据模型:

#![allow(unused)]
fn main() {
pub enum Category {
    Cash,      // 现金类
    Stable,    // 稳健类
    Advanced,  // 进阶类
}

pub struct Asset {
    pub id: Uuid,
    pub name: String,
    pub category: Category,
    pub sub_category: Option<String>,
    pub current_value: Decimal,
    pub notes: Option<String>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}
}

E1-S2: 新增资产条目

优先级: P0
PRD 引用: US-F1-01

描述:
作为用户,我希望能够新增一个资产条目(包含名称、类别、当前市值),以便记录我持有的资产。

验收标准:

  • 提供"新增资产"按钮,点击弹出模态框/侧边栏表单
  • 表单字段:名称(必填)、类别(必填,下拉选择)、子类别(可选)、市值(必填,数字输入)、备注(可选)
  • 名称验证:非空,最大 100 字符
  • 市值验证:非负数,自动格式化为 2 位小数
  • 保存成功后关闭表单,资产列表自动刷新
  • 保存失败时显示错误提示

E1-S3: 编辑资产条目

优先级: P0
PRD 引用: US-F1-02

描述:
作为用户,我希望能够编辑已有资产条目的信息,以便修正录入错误或更新资产状态。

验收标准:

  • 资产列表中每个条目有"编辑"操作入口
  • 点击编辑打开预填充的表单
  • 表单验证规则与新增一致
  • 保存时自动更新 updated_at 时间戳
  • 支持取消编辑,不保存变更

E1-S4: 删除资产条目

优先级: P0
PRD 引用: US-F1-03, BR-F1-03

描述:
作为用户,我希望能够删除不再持有的资产条目,以便保持资产列表的准确性。

验收标准:

  • 资产列表中每个条目有"删除"操作入口
  • 点击删除弹出确认对话框:"确定删除资产「XXX」吗?此操作不可撤销。"
  • 确认后从数据库删除记录
  • 历史快照中的该资产数据保留(快照不可变原则)
  • 删除后列表自动刷新

E1-S5: 资产列表展示

优先级: P0
PRD 引用: US-F1-04, UI-F1-01

描述:
作为用户,我希望资产条目能够按类别(现金类/稳健类/进阶类)组织展示,以便快速定位和管理。

验收标准:

  • 资产列表按三个类别分组展示
  • 每个类别区块显示:类别名称、资产数量、类别总市值
  • 每个资产条目显示:名称、子类别(如有)、当前市值
  • 类别区块支持折叠/展开
  • 空类别显示"暂无资产"占位
  • 页面顶部显示资产总数和总市值

界面参考:

资产管理                          总计: 10 项 ¥ 500,000.00
──────────────────────────────────────────────────────────
▼ 现金类 (3 项)                              ¥ 100,000.00
  ├─ 余额宝                                   ¥ 50,000.00
  ├─ 活期存款          [银行活期]             ¥ 30,000.00
  └─ 定期存单          [大额存单]             ¥ 20,000.00

▼ 稳健类 (2 项)                              ¥ 150,000.00
  ├─ XX债券基金        [纯债]                 ¥ 80,000.00
  └─ YY理财产品        [银行理财]             ¥ 70,000.00

▼ 进阶类 (5 项)                              ¥ 250,000.00
  ├─ 沪深300ETF        [宽基指数]            ¥ 100,000.00
  └─ ...

E1-S6: 执行盘点操作

优先级: P0
PRD 引用: US-F1-05, UI-F1-03, FR-F1-03, FR-F1-04

描述:
作为用户,我希望能够执行一次"盘点"操作,批量更新所有资产的当前市值,并自动记录盘点时间戳。

验收标准:

  • 资产管理页有"开始盘点"按钮
  • 点击后进入盘点模式,页面有明显的视觉区分(如顶部提示条)
  • 盘点模式下,每个资产的市值变为可编辑输入框
  • 每个输入框旁显示"上次市值"作为参考
  • 提供"完成盘点"按钮:
    • 创建盘点快照,记录当前所有资产的状态
    • 更新各资产的 current_value
    • 退出盘点模式,显示成功提示
  • 提供"取消盘点"按钮:放弃所有修改,退出盘点模式
  • 业务规则:至少有 1 个资产才能盘点
  • 业务规则:每天最多 5 次盘点(超出提示并阻止)

盘点快照数据:

#![allow(unused)]
fn main() {
pub struct Snapshot {
    pub id: Uuid,
    pub snapshot_date: NaiveDate,
    pub created_at: DateTime<Utc>,
    pub total_value: Decimal,
    pub items: Vec<SnapshotItem>,
}

pub struct SnapshotItem {
    pub asset_id: Uuid,
    pub asset_name: String,
    pub category: Category,
    pub sub_category: Option<String>,
    pub value: Decimal,
}
}

E1-S7: 查看盘点历史列表

优先级: P0
PRD 引用: US-F1-06, UI-F1-04

描述:
作为用户,我希望能够查看历史盘点记录列表,以便回顾资产变化轨迹。

验收标准:

  • 盘点历史页以时间线形式展示所有历史快照
  • 每条记录显示:盘点日期、资产总额
  • 每条记录显示与上次盘点的变化:金额差(+¥1,234 或 -¥567)、百分比(+2.5%)
  • 记录按时间倒序排列(最新在上)
  • 空状态显示"暂无盘点记录,请先进行一次盘点"
  • 支持点击记录查看详情(见 E1-S8)

E1-S8: 查看盘点快照详情

优先级: P1
PRD 引用: US-F1-07

描述:
作为用户,我希望能够查看某次历史盘点的详细快照,以便了解当时的资产状态。

验收标准:

  • 点击历史记录可展开/跳转至详情视图
  • 详情视图显示:盘点日期、总市值、各类别市值
  • 显示该快照中所有资产条目:名称、类别、子类别、当时市值
  • 提供返回按钮回到历史列表

E2: 配置方案模块

目标: 实现目标配置方案的创建、编辑和管理

E2-S1: 配置方案数据模型

优先级: P0
PRD 引用: FR-F2-01, FR-F2-02

描述:
作为开发者,我需要定义配置方案的数据模型和数据库表结构。

验收标准:

  • 定义 AllocationPlan 结构体
  • 定义 Allocation 结构体(配置项)
  • 创建数据库表并实现 CRUD 操作
  • 支持查询当前激活方案
  • 应用首次启动时自动创建默认"平衡型"方案并激活

数据模型:

#![allow(unused)]
fn main() {
pub struct AllocationPlan {
    pub id: Uuid,
    pub name: String,
    pub description: Option<String>,
    pub is_active: bool,
    pub allocations: Vec<Allocation>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

pub struct Allocation {
    pub category: Category,
    pub target_percentage: Decimal,  // 0-100
    pub min_percentage: Option<Decimal>,
    pub max_percentage: Option<Decimal>,
}
}

E2-S2: 创建配置方案

优先级: P0
PRD 引用: US-F2-01, UI-F2-02

描述:
作为用户,我希望能够创建一个配置方案,定义各资产类别的目标占比,以便作为资产配置的参照标准。

验收标准:

  • 提供"新建方案"按钮,打开方案编辑器
  • 编辑器包含:方案名称(必填)、描述(可选)
  • 提供三个类别的占比设置(滑块或数字输入)
  • 实时显示占比总和,必须 = 100%
  • 总和不等于 100% 时禁用保存按钮并提示
  • 可选:饼图预览目标配置
  • 保存后方案出现在方案列表中

E2-S3: 编辑配置方案

优先级: P0
PRD 引用: US-F2-02

描述:
作为用户,我希望能够编辑已有的配置方案,以便根据投资策略变化调整目标配置。

验收标准:

  • 方案列表中每个方案有"编辑"操作入口
  • 点击编辑打开预填充的方案编辑器
  • 编辑规则与创建一致
  • 保存时更新 updated_at 时间戳

E2-S4: 删除配置方案

优先级: P1
PRD 引用: US-F2-03, BR-F2-04, BR-F2-05

描述:
作为用户,我希望能够删除不再使用的配置方案,以便保持方案列表简洁。

验收标准:

  • 方案列表中每个方案有"删除"操作入口
  • 当前激活方案不可删除,删除按钮置灰并提示"请先切换激活方案"
  • 系统仅剩 1 个方案时不可删除,提示"至少保留一个方案"
  • 点击删除弹出确认对话框
  • 确认后删除方案

E2-S5: 激活配置方案

优先级: P0
PRD 引用: US-F2-04, BR-F2-03

描述:
作为用户,我希望能够将某个方案设为"当前激活",用于计算配置偏离度。

验收标准:

  • 方案列表中每个非激活方案有"设为当前"操作入口
  • 当前激活方案有明显视觉标识(如徽章、高亮背景)
  • 点击"设为当前"后:
    • 该方案变为激活状态
    • 原激活方案变为非激活
    • 资产视图页的偏离度重新计算
  • 系统始终有且仅有一个激活方案

E2-S6: 预设方案模板

优先级: P1
PRD 引用: US-F2-06, UI-F2-03

描述:
作为用户,我希望系统提供预设的配置方案模板作为起点,以便快速开始使用。

验收标准:

  • 新建方案时提供"从模板创建"选项
  • 提供 3 套预设模板:
    • 保守型:Cash 30%, Stable 50%, Advanced 20%
    • 平衡型:Cash 20%, Stable 40%, Advanced 40%
    • 进取型:Cash 10%, Stable 20%, Advanced 70%
  • 选择模板后自动填充占比,用户可进一步调整
  • 应用首次启动自动创建"平衡型"方案

E3: 资产视图模块

目标: 展示资产全貌,可视化配置对比和偏离情况

E3-S1: 资产总览卡片

优先级: P0
PRD 引用: US-F3-01, UI-F3-01

描述:
作为用户,我希望在首页看到资产总额和各类别的分布情况,以便快速了解资产全貌。

验收标准:

  • 首页顶部显示资产总额卡片,突出显示总市值
  • 显示上次盘点日期
  • 显示各类别的市值和占比
  • 无资产时显示引导文案"暂无资产,点击添加您的第一个资产"

E3-S2: 配置对比图表

优先级: P0
PRD 引用: US-F3-02, UI-F3-03

描述:
作为用户,我希望能够直观对比当前配置与目标配置的差异,以便判断是否需要调整。

验收标准:

  • 首页展示双饼图:左侧"当前配置",右侧"目标配置"
  • 同类别使用相同颜色
  • 鼠标悬停显示具体金额和百分比
  • 目标配置来自当前激活方案
  • 无激活方案时提示"请先创建配置方案"

E3-S3: 偏离状态列表

优先级: P0
PRD 引用: US-F3-03, US-F3-04, FR-F3-01, FR-F3-02, UI-F3-02

描述:
作为用户,我希望当某类别偏离目标超过阈值时,系统能够高亮提示,并看到各类别的偏离百分比。

验收标准:

  • 首页显示类别配置对比表格
  • 表格列:类别、当前占比、目标占比、偏离度、状态
  • 偏离度计算:当前占比 - 目标占比
  • 状态判定(默认容忍度 5%):
    • |偏离度| ≤ 5%:正常(绿色 ✓)
    • |偏离度| > 5%:轻度偏离(黄色 ⚠️)
    • |偏离度| > 10%:严重偏离(红色 ❗)
  • 偏离度显示正负符号:+5%(超配)、-3%(低配)

偏离计算逻辑:

#![allow(unused)]
fn main() {
fn calculate_deviation(current: Decimal, target: Decimal) -> DeviationResult {
    let deviation = current - target;
    let abs_deviation = deviation.abs();
    
    let status = if abs_deviation <= Decimal::new(5, 0) {
        DeviationStatus::Normal
    } else if abs_deviation <= Decimal::new(10, 0) {
        DeviationStatus::Mild
    } else {
        DeviationStatus::Severe
    };
    
    let direction = if deviation > Decimal::ZERO {
        DeviationDirection::Overweight
    } else {
        DeviationDirection::Underweight
    };
    
    DeviationResult { deviation, status, direction }
}
}

E3-S4: 无数据状态处理

优先级: P1

描述:
作为用户,当我没有资产或没有配置方案时,我希望看到友好的引导提示。

验收标准:

  • 无资产时:显示空状态插图 + "开始添加资产"按钮
  • 无配置方案时:显示"创建配置方案"按钮
  • 无盘点记录时:偏离度显示"--",提示"请先完成一次盘点"

E3-S5: 再平衡方向提示

优先级: P2
PRD 引用: US-F3-05

描述:
作为用户,我希望能够一键查看再平衡所需的资金调整方向,以便规划调仓。

验收标准:

  • 偏离状态列表增加"调整方向"列
  • 超配类别显示"↓ 减少"
  • 低配类别显示"↑ 增加"
  • 正常类别显示"- 维持"
  • 可选:显示建议调整金额(粗略估算)

E4: 收益分析模块

目标: 基于盘点快照计算和展示收益情况

E4-S1: 周期收益计算

优先级: P0
PRD 引用: US-F4-01, US-F4-02, FR-F4-01, FR-F4-02

描述:
作为用户,我希望看到选定时间段内的资产总收益(金额和百分比),并能按季度/年度查看。

验收标准:

  • 收益分析页顶部提供周期选择器
  • 支持选择:季度(Q1-Q4)、年度
  • 支持选择年份
  • 显示本期收益金额(绝对值)
  • 显示本期收益率(百分比)
  • 显示期初/期末总市值
  • 至少需要 2 个快照才能计算,否则显示"数据不足"

收益计算:

#![allow(unused)]
fn main() {
fn calculate_period_return(start_snapshot: &Snapshot, end_snapshot: &Snapshot) -> ReturnResult {
    let absolute_return = end_snapshot.total_value - start_snapshot.total_value;
    
    let return_rate = if start_snapshot.total_value > Decimal::ZERO {
        (absolute_return / start_snapshot.total_value) * Decimal::new(100, 0)
    } else {
        Decimal::ZERO  // 或返回 N/A
    };
    
    ReturnResult {
        period: (start_snapshot.snapshot_date, end_snapshot.snapshot_date),
        start_value: start_snapshot.total_value,
        end_value: end_snapshot.total_value,
        absolute_return,
        return_rate,
    }
}
}

E4-S2: 收益归因分析

优先级: P0
PRD 引用: US-F4-03, FR-F4-04, UI-F4-01, UI-F4-02

描述:
作为用户,我希望看到各资产类别的收益贡献,以便了解哪些类别在产生收益。

验收标准:

  • 收益分析页显示收益归因表格
  • 表格列:类别、期初市值、期末市值、收益金额、收益率、贡献度
  • 贡献度 = 该类别收益 / 总收益 × 100%
  • 正收益显示绿色,负收益显示红色
  • 可选:横向条形图可视化各类别收益贡献

E4-S3: 资产趋势图

优先级: P1
PRD 引用: US-F4-04, UI-F4-03

描述:
作为用户,我希望看到资产总额的历史变化趋势图,以便直观感受资产增长轨迹。

验收标准:

  • 收益分析页底部显示折线图
  • X 轴:盘点日期
  • Y 轴:资产总额
  • 数据点来自历史盘点快照
  • 鼠标悬停显示具体日期和金额
  • 至少 2 个数据点才显示趋势线
  • 可选:支持时间范围筛选

E4-S4: 自定义周期对比

优先级: P2
PRD 引用: US-F4-05

描述:
作为用户,我希望能够选择任意两个盘点时间点进行收益对比,以便灵活分析。

验收标准:

  • 提供"自定义对比"入口
  • 可选择起始快照和结束快照
  • 计算并显示两个时间点之间的收益
  • 复用周期收益的展示组件

E4-S5: 无数据状态处理

优先级: P1

描述:
作为用户,当盘点数据不足时,我希望看到友好的提示。

验收标准:

  • 无快照时:显示"暂无盘点数据,请先进行资产盘点"
  • 仅 1 个快照时:显示"需要至少 2 次盘点才能计算收益"
  • 选定周期内无快照时:显示"该周期内暂无盘点记录"

开发建议顺序

迭代 1:基础骨架(预计 3-5 天)

Story说明
E0-S1项目初始化
E0-S2数据持久化层
E0-S3应用布局框架
E0-S4全局状态管理

迭代 2:资产管理核心(预计 5-7 天)

Story说明
E1-S1资产数据模型
E1-S2新增资产
E1-S3编辑资产
E1-S4删除资产
E1-S5资产列表展示

迭代 3:盘点功能(预计 3-5 天)

Story说明
E1-S6执行盘点操作
E1-S7盘点历史列表
E1-S8盘点快照详情

迭代 4:配置方案(预计 3-5 天)

Story说明
E2-S1配置方案数据模型
E2-S2创建配置方案
E2-S3编辑配置方案
E2-S5激活配置方案

迭代 5:资产视图(预计 3-5 天)

Story说明
E3-S1资产总览卡片
E3-S2配置对比图表
E3-S3偏离状态列表

迭代 6:收益分析(预计 3-5 天)

Story说明
E4-S1周期收益计算
E4-S2收益归因分析
E4-S3资产趋势图

迭代 7:完善优化(预计 2-3 天)

Story说明
E2-S4删除配置方案
E2-S6预设方案模板
E3-S4无数据状态处理
E4-S5无数据状态处理

修订历史

版本日期作者变更说明
1.0.02025-12-20PM Agent初始版本

This document breaks down the PRD into implementable Epics and Stories.

Next: Architecture design will define the technical implementation approach.

Epics and Stories (Final): asset-light MVP

版本: 2.0.0 (Final)
日期: 2025-12-20
关联文档:


概览

本文档是基于技术架构设计的最终版 Epics 和 Stories,包含具体的技术实现任务。

Epic 总览

Epic ID名称Stories任务数预计工时
E0项目基础设施4163-4 天
E1资产盘点模块8285-7 天
E2配置方案模块6203-5 天
E3资产视图模块5183-4 天
E4收益分析模块5163-4 天
合计-289817-24 天

E0: 项目基础设施

E0-S1: 项目初始化

预计工时: 0.5 天

技术任务

任务 ID任务文件/路径
T0-1-1创建 Cargo 项目Cargo.toml
T0-1-2配置 Dioxus Desktop 依赖Cargo.toml
T0-1-3创建 Dioxus.toml 配置Dioxus.toml
T0-1-4创建目录结构src/*
T0-1-5创建 main.rs 入口src/main.rs
T0-1-6配置 rustfmt.tomlrustfmt.toml

代码模板

Cargo.toml:

[package]
name = "asset-light"
version = "0.1.0"
edition = "2021"

[dependencies]
dioxus = { version = "0.5", features = ["desktop", "router"] }
rusqlite = { version = "0.31", features = ["bundled"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
uuid = { version = "1.0", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
rust_decimal = { version = "1.0", features = ["serde"] }
dirs = "5.0"
thiserror = "1.0"

验收标准:

  • cargo build 编译成功
  • cargo run 启动空白窗口
  • 目录结构符合架构设计

E0-S2: 数据持久化层

预计工时: 1 天

技术任务

任务 ID任务文件/路径
T0-2-1创建 Database 连接管理src/db/connection.rs
T0-2-2实现数据库路径管理src/db/connection.rs
T0-2-3创建迁移脚本 001_create_assetsmigrations/001_create_assets.sql
T0-2-4创建迁移脚本 002_create_snapshotsmigrations/002_create_snapshots.sql
T0-2-5创建迁移脚本 003_create_plansmigrations/003_create_plans.sql
T0-2-6实现迁移执行逻辑src/db/migrations.rs

SQL 迁移脚本

001_create_assets.sql:

CREATE TABLE IF NOT EXISTS assets (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL,
    category TEXT NOT NULL CHECK (category IN ('Cash', 'Stable', 'Advanced')),
    sub_category TEXT,
    current_value TEXT NOT NULL,
    notes TEXT,
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_assets_category ON assets(category);

002_create_snapshots.sql:

CREATE TABLE IF NOT EXISTS snapshots (
    id TEXT PRIMARY KEY,
    snapshot_date TEXT NOT NULL,
    created_at TEXT NOT NULL,
    total_value TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_snapshots_date ON snapshots(snapshot_date);

CREATE TABLE IF NOT EXISTS snapshot_items (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    snapshot_id TEXT NOT NULL,
    asset_id TEXT NOT NULL,
    asset_name TEXT NOT NULL,
    category TEXT NOT NULL,
    sub_category TEXT,
    value TEXT NOT NULL,
    FOREIGN KEY (snapshot_id) REFERENCES snapshots(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_snapshot_items_snapshot ON snapshot_items(snapshot_id);

003_create_plans.sql:

CREATE TABLE IF NOT EXISTS allocation_plans (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL,
    description TEXT,
    is_active INTEGER NOT NULL DEFAULT 0,
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS allocations (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    plan_id TEXT NOT NULL,
    category TEXT NOT NULL,
    target_percentage TEXT NOT NULL,
    min_percentage TEXT,
    max_percentage TEXT,
    FOREIGN KEY (plan_id) REFERENCES allocation_plans(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_allocations_plan ON allocations(plan_id);

验收标准:

  • 数据库文件创建在 ~/Library/Application Support/asset-light/data.db
  • 迁移执行成功
  • 表结构正确

E0-S3: 应用布局框架

预计工时: 1 天

技术任务

任务 ID任务文件/路径
T0-3-1定义路由枚举src/router.rs
T0-3-2创建 Layout 组件src/components/layout/mod.rs
T0-3-3创建 Sidebar 组件src/components/layout/sidebar.rs
T0-3-4创建 NavItem 组件src/components/layout/sidebar.rs
T0-3-5创建 PageContainer 组件src/components/layout/page_container.rs
T0-3-6创建 5 个页面占位组件src/pages/*.rs

组件结构

src/components/layout/
├── mod.rs          // pub mod sidebar; pub mod page_container;
├── sidebar.rs      // Sidebar, NavItem
└── page_container.rs

src/pages/
├── mod.rs
├── home.rs         // HomePage
├── assets.rs       // AssetsPage
├── history.rs      // HistoryPage
├── plans.rs        // PlansPage
└── analysis.rs     // AnalysisPage

验收标准:

  • 侧边栏显示 5 个导航项
  • 点击导航项切换页面
  • 当前页面导航项高亮

E0-S4: 全局状态管理

预计工时: 0.5 天

技术任务

任务 ID任务文件/路径
T0-4-1定义 AppState 结构体src/state/app_state.rs
T0-4-2实现 provide_app_statesrc/state/app_state.rs
T0-4-3实现 use_app_state Hooksrc/state/app_state.rs
T0-4-4在 App 组件中提供状态src/app.rs

状态结构

#![allow(unused)]
fn main() {
// src/state/app_state.rs
#[derive(Clone, Default)]
pub struct AppState {
    pub assets: Vec<Asset>,
    pub snapshots: Vec<Snapshot>,
    pub plans: Vec<AllocationPlan>,
    pub active_plan: Option<AllocationPlan>,
    pub is_inventory_mode: bool,
    pub loading: bool,
    pub error: Option<String>,
}
}

验收标准:

  • 全局状态在所有页面可访问
  • 状态变更触发 UI 更新

E1: 资产盘点模块

E1-S1: 资产条目数据模型

预计工时: 0.5 天

技术任务

任务 ID任务文件/路径
T1-1-1定义 Category 枚举src/models/category.rs
T1-1-2定义 Asset 结构体src/models/asset.rs
T1-1-3实现 Asset::new()src/models/asset.rs
T1-1-4创建 AssetRepositorysrc/db/asset_repo.rs
T1-1-5实现 CRUD 方法src/db/asset_repo.rs

Repository 接口

#![allow(unused)]
fn main() {
// src/db/asset_repo.rs
impl AssetRepository {
    pub fn insert(&self, asset: &Asset) -> Result<()>;
    pub fn update(&self, asset: &Asset) -> Result<()>;
    pub fn delete(&self, id: Uuid) -> Result<()>;
    pub fn find_by_id(&self, id: Uuid) -> Result<Option<Asset>>;
    pub fn find_all(&self) -> Result<Vec<Asset>>;
    pub fn find_by_category(&self, category: Category) -> Result<Vec<Asset>>;
}
}

验收标准:

  • 资产可增删改查
  • 按类别查询正常

E1-S2: 新增资产条目

预计工时: 1 天

技术任务

任务 ID任务文件/路径
T1-2-1创建 Modal 通用组件src/components/common/modal.rs
T1-2-2创建 Input 通用组件src/components/common/input.rs
T1-2-3创建 Select 通用组件src/components/common/select.rs
T1-2-4创建 Button 通用组件src/components/common/button.rs
T1-2-5创建 AssetForm 组件src/components/asset/asset_form.rs
T1-2-6创建 AssetServicesrc/services/asset_service.rs
T1-2-7实现表单验证逻辑src/components/asset/asset_form.rs

组件 Props

#![allow(unused)]
fn main() {
#[component]
pub fn AssetForm(
    asset: Option<Asset>,           // None = 新增, Some = 编辑
    on_save: EventHandler<Asset>,
    on_cancel: EventHandler<()>,
) -> Element
}

验收标准:

  • 点击新增按钮弹出表单
  • 表单验证生效
  • 保存成功关闭表单

E1-S3: 编辑资产条目

预计工时: 0.5 天

技术任务

任务 ID任务文件/路径
T1-3-1AssetItem 添加编辑按钮src/components/asset/asset_item.rs
T1-3-2复用 AssetForm 组件src/pages/assets.rs
T1-3-3实现编辑状态管理src/pages/assets.rs

验收标准:

  • 点击编辑打开预填充表单
  • 保存更新资产信息

E1-S4: 删除资产条目

预计工时: 0.5 天

技术任务

任务 ID任务文件/路径
T1-4-1创建 ConfirmDialog 组件src/components/common/confirm_dialog.rs
T1-4-2AssetItem 添加删除按钮src/components/asset/asset_item.rs
T1-4-3实现删除确认流程src/pages/assets.rs

验收标准:

  • 点击删除弹出确认
  • 确认后删除成功

E1-S5: 资产列表展示

预计工时: 1 天

技术任务

任务 ID任务文件/路径
T1-5-1创建 AssetList 组件src/components/asset/asset_list.rs
T1-5-2创建 CategoryGroup 组件src/components/asset/category_group.rs
T1-5-3创建 AssetItem 组件src/components/asset/asset_item.rs
T1-5-4实现折叠/展开功能src/components/asset/category_group.rs
T1-5-5实现汇总计算src/pages/assets.rs

组件层次

AssetList
├── CategoryGroup (Cash)
│   ├── AssetItem
│   └── AssetItem
├── CategoryGroup (Stable)
│   └── AssetItem
└── CategoryGroup (Advanced)
    └── AssetItem

验收标准:

  • 资产按类别分组显示
  • 显示类别汇总
  • 折叠展开正常

E1-S6: 执行盘点操作

预计工时: 1.5 天

技术任务

任务 ID任务文件/路径
T1-6-1定义 Snapshot 模型src/models/snapshot.rs
T1-6-2创建 SnapshotRepositorysrc/db/snapshot_repo.rs
T1-6-3创建 SnapshotServicesrc/services/snapshot_service.rs
T1-6-4创建 InventoryMode 组件src/components/snapshot/inventory_mode.rs
T1-6-5创建 InventoryItem 组件src/components/snapshot/inventory_item.rs
T1-6-6实现盘点次数限制检查src/services/snapshot_service.rs
T1-6-7实现盘点保存逻辑src/services/snapshot_service.rs

盘点流程

开始盘点 → 进入盘点模式 → 编辑各资产市值 → 完成盘点 → 创建快照 → 更新资产

验收标准:

  • 盘点模式 UI 区分明显
  • 可编辑各资产市值
  • 快照正确保存
  • 每日限制 5 次

E1-S7: 查看盘点历史列表

预计工时: 0.5 天

技术任务

任务 ID任务文件/路径
T1-7-1创建 SnapshotTimeline 组件src/components/snapshot/snapshot_timeline.rs
T1-7-2创建 SnapshotCard 组件src/components/snapshot/snapshot_card.rs
T1-7-3实现变化计算src/components/snapshot/snapshot_card.rs

验收标准:

  • 时间线展示历史
  • 显示变化金额和百分比

E1-S8: 查看盘点快照详情

预计工时: 0.5 天

技术任务

任务 ID任务文件/路径
T1-8-1创建 SnapshotDetail 组件src/components/snapshot/snapshot_detail.rs
T1-8-2实现详情弹窗或展开src/pages/history.rs

验收标准:

  • 可查看快照详细资产列表

E2: 配置方案模块

E2-S1: 配置方案数据模型

预计工时: 0.5 天

技术任务

任务 ID任务文件/路径
T2-1-1定义 AllocationPlan 结构体src/models/plan.rs
T2-1-2定义 Allocation 结构体src/models/plan.rs
T2-1-3创建 PlanRepositorysrc/db/plan_repo.rs
T2-1-4实现默认方案创建src/db/plan_repo.rs

验收标准:

  • 首次启动创建默认方案
  • 方案 CRUD 正常

E2-S2: 创建配置方案

预计工时: 1 天

技术任务

任务 ID任务文件/路径
T2-2-1创建 PlanEditor 组件src/components/plan/plan_editor.rs
T2-2-2创建 AllocationSlider 组件src/components/plan/allocation_slider.rs
T2-2-3实现 100% 验证逻辑src/components/plan/plan_editor.rs
T2-2-4创建 PlanServicesrc/services/plan_service.rs

验收标准:

  • 可创建新方案
  • 占比总和必须 100%

E2-S3: 编辑配置方案

预计工时: 0.5 天

技术任务

任务 ID任务文件/路径
T2-3-1PlanCard 添加编辑按钮src/components/plan/plan_card.rs
T2-3-2复用 PlanEditorsrc/pages/plans.rs

验收标准:

  • 可编辑现有方案

E2-S4: 删除配置方案

预计工时: 0.5 天

技术任务

任务 ID任务文件/路径
T2-4-1PlanCard 添加删除按钮src/components/plan/plan_card.rs
T2-4-2实现删除限制检查src/services/plan_service.rs

验收标准:

  • 激活方案不可删除
  • 最后一个方案不可删除

E2-S5: 激活配置方案

预计工时: 0.5 天

技术任务

任务 ID任务文件/路径
T2-5-1PlanCard 添加激活按钮src/components/plan/plan_card.rs
T2-5-2实现激活切换逻辑src/services/plan_service.rs
T2-5-3更新全局状态 active_plansrc/state/app_state.rs

验收标准:

  • 可切换激活方案
  • 始终只有一个激活

E2-S6: 预设方案模板

预计工时: 0.5 天

技术任务

任务 ID任务文件/路径
T2-6-1定义模板常量src/models/plan.rs
T2-6-2PlanEditor 添加模板选择src/components/plan/plan_editor.rs

模板定义:

#![allow(unused)]
fn main() {
impl AllocationPlan {
    pub fn template_conservative() -> Self { /* Cash 30, Stable 50, Advanced 20 */ }
    pub fn template_balanced() -> Self { /* Cash 20, Stable 40, Advanced 40 */ }
    pub fn template_aggressive() -> Self { /* Cash 10, Stable 20, Advanced 70 */ }
}
}

验收标准:

  • 提供 3 个模板
  • 选择模板自动填充

E3: 资产视图模块

E3-S1: 资产总览卡片

预计工时: 0.5 天

技术任务

任务 ID任务文件/路径
T3-1-1创建 TotalCard 组件src/components/dashboard/total_card.rs
T3-1-2创建 StatCard 通用组件src/components/common/stat_card.rs
T3-1-3实现汇总计算src/pages/home.rs

验收标准:

  • 显示总资产
  • 显示上次盘点日期

E3-S2: 配置对比图表

预计工时: 1 天

技术任务

任务 ID任务文件/路径
T3-2-1创建 PieChart 组件src/components/dashboard/pie_chart.rs
T3-2-2创建 PieChartPair 组件src/components/dashboard/pie_chart_pair.rs
T3-2-3实现 SVG 饼图渲染src/components/dashboard/pie_chart.rs
T3-2-4实现悬停提示src/components/dashboard/pie_chart.rs

验收标准:

  • 双饼图并列显示
  • 类别颜色一致

E3-S3: 偏离状态列表

预计工时: 1 天

技术任务

任务 ID任务文件/路径
T3-3-1创建 DeviationTable 组件src/components/dashboard/deviation_table.rs
T3-3-2创建 StatusBadge 组件src/components/common/status_badge.rs
T3-3-3创建 AnalysisServicesrc/services/analysis_service.rs
T3-3-4实现 calculate_deviationsrc/services/analysis_service.rs

验收标准:

  • 显示偏离度表格
  • 状态颜色正确

E3-S4: 无数据状态处理

预计工时: 0.5 天

技术任务

任务 ID任务文件/路径
T3-4-1创建 EmptyState 组件src/components/common/empty_state.rs
T3-4-2首页添加空状态处理src/pages/home.rs

验收标准:

  • 无数据时显示引导

E3-S5: 再平衡方向提示

预计工时: 0.5 天

技术任务

任务 ID任务文件/路径
T3-5-1DeviationTable 添加方向列src/components/dashboard/deviation_table.rs
T3-5-2实现方向箭头显示src/components/dashboard/deviation_table.rs

验收标准:

  • 显示调整方向

E4: 收益分析模块

E4-S1: 周期收益计算

预计工时: 1 天

技术任务

任务 ID任务文件/路径
T4-1-1创建 PeriodSelector 组件src/components/analysis/period_selector.rs
T4-1-2创建 ReturnCard 组件src/components/analysis/return_card.rs
T4-1-3实现 calculate_returnsrc/services/analysis_service.rs
T4-1-4实现周期筛选逻辑src/pages/analysis.rs

验收标准:

  • 可选择季度/年度
  • 正确计算收益

E4-S2: 收益归因分析

预计工时: 1 天

技术任务

任务 ID任务文件/路径
T4-2-1创建 AttributionTable 组件src/components/analysis/attribution_table.rs
T4-2-2实现类别收益计算src/services/analysis_service.rs
T4-2-3实现贡献度计算src/services/analysis_service.rs

验收标准:

  • 显示各类别收益
  • 正负收益颜色区分

E4-S3: 资产趋势图

预计工时: 1 天

技术任务

任务 ID任务文件/路径
T4-3-1创建 TrendChart 组件src/components/analysis/trend_chart.rs
T4-3-2实现 SVG 折线图src/components/analysis/trend_chart.rs
T4-3-3实现悬停数据点提示src/components/analysis/trend_chart.rs

验收标准:

  • 折线图正确渲染
  • 悬停显示详情

E4-S4: 自定义周期对比

预计工时: 0.5 天

技术任务

任务 ID任务文件/路径
T4-4-1PeriodSelector 添加自定义入口src/components/analysis/period_selector.rs
T4-4-2创建快照选择器src/components/analysis/snapshot_picker.rs

验收标准:

  • 可选择任意两个快照对比

E4-S5: 无数据状态处理

预计工时: 0.5 天

技术任务

任务 ID任务文件/路径
T4-5-1分析页添加空状态处理src/pages/analysis.rs

验收标准:

  • 数据不足时友好提示

Sprint 规划建议

Sprint 1: 基础设施 (3-4 天)

  • E0-S1: 项目初始化
  • E0-S2: 数据持久化层
  • E0-S3: 应用布局框架
  • E0-S4: 全局状态管理

Sprint 2: 资产管理 (5-7 天)

  • E1-S1: 资产数据模型
  • E1-S2: 新增资产
  • E1-S3: 编辑资产
  • E1-S4: 删除资产
  • E1-S5: 资产列表展示

Sprint 3: 盘点功能 (3-4 天)

  • E1-S6: 执行盘点
  • E1-S7: 盘点历史
  • E1-S8: 快照详情

Sprint 4: 配置方案 (3-4 天)

  • E2-S1 ~ E2-S6

Sprint 5: 仪表盘 (3-4 天)

  • E3-S1 ~ E3-S5

Sprint 6: 收益分析 (3-4 天)

  • E4-S1 ~ E4-S5

文件清单

需创建的文件 (共约 45 个)

src/models/ (5 个)

  • mod.rs, asset.rs, snapshot.rs, plan.rs, category.rs

src/db/ (5 个)

  • mod.rs, connection.rs, migrations.rs, asset_repo.rs, snapshot_repo.rs, plan_repo.rs

src/services/ (5 个)

  • mod.rs, asset_service.rs, snapshot_service.rs, plan_service.rs, analysis_service.rs

src/state/ (2 个)

  • mod.rs, app_state.rs

src/components/common/ (8 个)

  • mod.rs, button.rs, input.rs, select.rs, modal.rs, card.rs, confirm_dialog.rs, empty_state.rs, status_badge.rs, stat_card.rs

src/components/layout/ (3 个)

  • mod.rs, sidebar.rs, page_container.rs

src/components/asset/ (5 个)

  • mod.rs, asset_list.rs, asset_form.rs, asset_item.rs, category_group.rs

src/components/snapshot/ (5 个)

  • mod.rs, inventory_mode.rs, inventory_item.rs, snapshot_timeline.rs, snapshot_card.rs, snapshot_detail.rs

src/components/plan/ (4 个)

  • mod.rs, plan_list.rs, plan_card.rs, plan_editor.rs, allocation_slider.rs

src/components/dashboard/ (4 个)

  • mod.rs, total_card.rs, pie_chart.rs, pie_chart_pair.rs, deviation_table.rs

src/components/analysis/ (5 个)

  • mod.rs, period_selector.rs, return_card.rs, attribution_table.rs, trend_chart.rs, snapshot_picker.rs

src/pages/ (6 个)

  • mod.rs, home.rs, assets.rs, history.rs, plans.rs, analysis.rs

migrations/ (3 个)

  • 001_create_assets.sql, 002_create_snapshots.sql, 003_create_plans.sql

修订历史

版本日期作者变更说明
1.0.02025-12-20PM Agent初始版本
2.0.02025-12-20PM Agent基于架构添加技术任务

Implementation Readiness Report: asset-light

日期: 2025-12-20
状态: ✅ READY FOR IMPLEMENTATION


1. 执行摘要

准备状态总览

维度完成度状态
文档完整性7/7
技术准备6/6
任务就绪6/6
质量保障4/4
总体23/23✅ READY

结论

asset-light 项目已完成所有规划和设计工作,具备开始实施的条件。


2. 已完成的准备工作

2.1 产品规划文档

文档文件内容
Product Briefproduct-brief-asset-light-2025-12-20.md产品愿景、用户痛点、MVP 范围
PRDprd-asset-light-2025-12-20.md详细功能需求、数据模型、业务规则
UI Designui-design-asset-light-2025-12-20.md视觉风格、页面设计、组件库

2.2 技术设计文档

文档文件内容
Architecturearchitecture-asset-light-2025-12-20.md技术栈、项目结构、数据模型、服务层
Test Designtest-design-asset-light-2025-12-20.md测试策略、测试用例、性能指标

2.3 开发计划文档

文档文件内容
Epics & Storiesepics-and-stories-asset-light-2025-12-20.md功能拆分、用户故事
Epics & Stories (Final)epics-and-stories-final-2025-12-20.md技术任务分解、Sprint 规划

3. 技术栈确认

3.1 核心依赖

[dependencies]
dioxus = { version = "0.5", features = ["desktop", "router"] }
rusqlite = { version = "0.31", features = ["bundled"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
uuid = { version = "1.0", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
rust_decimal = { version = "1.0", features = ["serde"] }
dirs = "5.0"
thiserror = "1.0"

3.2 开发环境要求

项目要求
Rust1.75+
macOS12.0+
IDE推荐 VS Code + rust-analyzer

4. Sprint 规划

4.1 Sprint 概览

Sprint内容预计时间Story 数
Sprint 1基础设施3-4 天4
Sprint 2资产管理5-7 天5
Sprint 3盘点功能3-4 天3
Sprint 4配置方案3-4 天6
Sprint 5仪表盘3-4 天5
Sprint 6收益分析3-4 天5

总计: 17-24 天

4.2 Sprint 1 详细任务

目标: 搭建项目骨架,建立开发基础

Story任务预计时间
E0-S1项目初始化0.5 天
E0-S2数据持久化层1 天
E0-S3应用布局框架1 天
E0-S4全局状态管理0.5 天

Sprint 1 产出:

  • 可编译运行的 Dioxus 桌面应用
  • SQLite 数据库初始化和迁移
  • 侧边栏导航 + 5 个页面占位
  • 全局状态 Context

5. 风险评估

5.1 技术风险

风险可能性影响缓解措施
Dioxus 学习曲线参考官方文档和示例
图表渲染复杂度使用 SVG 手动绘制或简化
数据迁移兼容版本化迁移脚本

5.2 进度风险

风险可能性影响缓解措施
功能蔓延严格遵守 MVP 范围
估算偏差迭代调整,P2 可延后

6. 开始实施清单

6.1 环境准备

# 1. 确认 Rust 版本
rustc --version  # 需要 1.75+

# 2. 安装 Dioxus CLI
cargo install dioxus-cli

# 3. 创建项目
cargo new asset-light
cd asset-light

# 4. 初始化 Dioxus
dx init

6.2 首个 Commit 内容

  • Cargo.toml - 依赖配置
  • Dioxus.toml - Dioxus 配置
  • src/main.rs - 应用入口
  • src/app.rs - 根组件
  • .gitignore - Git 忽略配置
  • README.md - 项目说明

6.3 目录结构创建

mkdir -p src/{components/{layout,common,asset,snapshot,plan,dashboard,analysis},pages,models,services,state,db,utils}
mkdir -p migrations
mkdir -p assets/{icons,styles}

7. 验收标准

7.1 MVP 完成标准

标准验收条件
资产盘点3 分钟内完成 10 个资产盘点
配置管理创建、编辑、切换 2+ 套方案
偏离监控清晰显示配置偏离情况
收益分析查看季度/年度收益率
性能启动 < 2s,切换 < 200ms
稳定性无崩溃、无数据丢失

7.2 代码质量标准

标准要求
Clippy无警告
测试覆盖率≥ 80%
代码风格rustfmt 格式化
文档公共 API 有注释

8. 下一步行动

立即执行

  1. 创建 Git 仓库
  2. 执行 Sprint 1 - Story E0-S1: 项目初始化
  3. 配置 CI/CD(可选)

Sprint 1 目标

完成基础设施搭建,应用能够:

  • ✅ 编译并启动窗口
  • ✅ 显示侧边栏和页面占位
  • ✅ 连接 SQLite 数据库
  • ✅ 全局状态可用

修订历史

版本日期作者变更说明
1.0.02025-12-20Architect初始版本

🎉 Congratulations! asset-light is READY FOR IMPLEMENTATION!

BMM Workflow Status(渲染版)

mdBook 只能直接收录 Markdown。
本页用于把 docs/bmm-workflow-status.yaml 以代码块形式渲染到文档站点中,方便浏览与检索。

原始文件

  • docs/bmm-workflow-status.yaml

内容

# BMM Workflow Status - asset-light
# Generated by workflow-init
# Track progress through BMM methodology phases

# STATUS DEFINITIONS:
# ==================
# Initial Status (before completion):
#   - required: Must be completed to progress
#   - optional: Can be completed but not required
#   - recommended: Strongly suggested but not required
#   - conditional: Required only if certain conditions met
#
# Completion Status:
#   - {file-path}: File created/found (e.g., "docs/product-brief.md")
#   - skipped: Optional/conditional workflow that was skipped

generated: "2025-12-20"
project: "asset-light"
project_type: "desktop-app"
selected_track: "bmad-method"
field_type: "greenfield"
workflow_path: "method-greenfield.yaml"

# User selections from workflow-init
discovery_selections:
  brainstorm: false
  research: false
  product_brief: true

# Project context captured during init
project_context:
  description: "基于 Dioxus 技术栈的个人资产管理桌面应用"
  tech_stack: "Dioxus (Rust)"
  target_user: "个人用户"
  core_features:
    - "资产盘点(新增/更新资产条目)"
    - "资产视图"
    - "资产评估及建议"
  asset_categories:
    - name: "现金类"
      examples: ["余额宝", "活期存款", "定期存单", "借出款项"]
    - name: "稳健类"
      examples: ["债券基金"]
    - name: "进阶类"
      examples: ["股票基金", "混合基金", "ETF"]

workflow_status:
  # Phase 0: Discovery (Optional)
  phase_0_discovery:
    - id: "brainstorm-project"
      status: "skipped"
      agent: "analyst"
      command: "brainstorm-project"
      
    - id: "research"
      status: "skipped"
      agent: "analyst"
      command: "research"
      
    - id: "product-brief"
      status: "docs/product-brief-asset-light-2025-12-20.md"
      agent: "analyst"
      command: "product-brief"
      output: "docs/product-brief-asset-light-2025-12-20.md"

  # Phase 1: Planning
  phase_1_planning:
    - id: "prd"
      status: "docs/prd-asset-light-2025-12-20.md"
      agent: "pm"
      command: "prd"
      output: "docs/prd-asset-light-2025-12-20.md"
      
    - id: "validate-prd"
      status: "validated"
      agent: "pm"
      command: "validate-prd"
      output: "PRD validated against Product Brief - all checks passed"
      
    - id: "create-epics-and-stories"
      status: "docs/epics-and-stories-asset-light-2025-12-20.md"
      agent: "pm"
      command: "create-epics-and-stories"
      output: "docs/epics-and-stories-asset-light-2025-12-20.md"
      
    - id: "create-design"
      status: "docs/ui-design-asset-light-2025-12-20.md"
      condition: "if_has_ui"
      condition_met: true
      agent: "ux-designer"
      command: "create-design"
      output: "docs/ui-design-asset-light-2025-12-20.md"

  # Phase 2: Solutioning
  phase_2_solutioning:
    - id: "create-architecture"
      status: "docs/architecture-asset-light-2025-12-20.md"
      agent: "architect"
      command: "create-architecture"
      output: "docs/architecture-asset-light-2025-12-20.md"
      
    - id: "create-epics-and-stories-final"
      status: "docs/epics-and-stories-final-2025-12-20.md"
      agent: "pm"
      command: "create-epics-and-stories"
      output: "docs/epics-and-stories-final-2025-12-20.md"
      
    - id: "test-design"
      status: "docs/test-design-asset-light-2025-12-20.md"
      agent: "tea"
      command: "test-design"
      output: "docs/test-design-asset-light-2025-12-20.md"
      
    - id: "validate-architecture"
      status: "validated"
      agent: "architect"
      command: "validate-architecture"
      output: "Architecture validated against PRD - all checks passed"
      
    - id: "implementation-readiness"
      status: "docs/implementation-readiness-2025-12-20.md"
      agent: "architect"
      command: "implementation-readiness"
      output: "docs/implementation-readiness-2025-12-20.md"

  # Phase 3: Implementation
  phase_3_implementation:
    - id: "sprint-planning"
      status: "docs/sprint-artifacts/001-sprint-planning-2025-12-21.md"
      agent: "sm"
      command: "sprint-planning"
      output: "docs/sprint-artifacts/001-sprint-planning-2025-12-21.md"
      notes: "MVP 已全部完成,所有 6 个 Sprint 功能验证通过"

Sprint Planning Report: asset-light

日期: 2025-12-21
执行人: SM Agent
状态: ✅ MVP 已完成


1. 执行摘要

🎉 项目状态:MVP 已完成

经过完整的代码审查,asset-light 项目已完成所有 MVP 功能的实现

Sprint内容计划工时状态
Sprint 1基础设施3-4 天✅ 完成
Sprint 2资产管理5-7 天✅ 完成
Sprint 3盘点功能3-4 天✅ 完成
Sprint 4配置方案3-4 天✅ 完成
Sprint 5仪表盘3-4 天✅ 完成
Sprint 6收益分析3-4 天✅ 完成

2. 功能完成度检查

2.1 E0: 项目基础设施 ✅ 100%

Story ID功能状态实现文件
E0-S1项目初始化Cargo.toml, Dioxus.toml, main.rs
E0-S2数据持久化层src/db/connection.rs, src/db/migrations.rs
E0-S3应用布局框架src/router.rs, src/components/layout/
E0-S4全局状态管理src/state/app_state.rs

验证:

  • ✅ Cargo 项目完整配置
  • ✅ Dioxus Desktop 依赖配置正确
  • ✅ SQLite 数据库初始化
  • ✅ 侧边栏导航 + 5 个页面
  • ✅ 全局状态 Context

2.2 E1: 资产盘点模块 ✅ 100%

Story ID功能状态实现文件
E1-S1资产条目数据模型src/models/asset.rs, src/models/category.rs
E1-S2新增资产条目src/components/asset/asset_form.rs
E1-S3编辑资产条目src/components/asset/asset_form.rs
E1-S4删除资产条目src/components/common/confirm_dialog.rs
E1-S5资产列表展示src/components/asset/asset_list.rs, category_group.rs
E1-S6执行盘点操作src/components/snapshot/inventory_mode.rs
E1-S7查看盘点历史src/components/snapshot/snapshot_timeline.rs
E1-S8查看快照详情src/components/snapshot/snapshot_detail.rs

验证:

  • ✅ 资产 CRUD 功能完整
  • ✅ 按类别分组显示
  • ✅ 盘点模式独立 UI
  • ✅ 快照保存和查看

2.3 E2: 配置方案模块 ✅ 100%

Story ID功能状态实现文件
E2-S1配置方案数据模型src/models/plan.rs
E2-S2创建配置方案src/components/plan/plan_editor.rs
E2-S3编辑配置方案src/components/plan/plan_editor.rs
E2-S4删除配置方案src/pages/plans.rs
E2-S5激活配置方案src/db/plan_repo.rs
E2-S6预设方案模板src/models/plan.rs (模板常量)

验证:

  • ✅ 方案 CRUD 完整
  • ✅ 占比总和验证
  • ✅ 激活方案切换
  • ✅ 预设模板支持

2.4 E3: 资产视图模块 ✅ 100%

Story ID功能状态实现文件
E3-S1资产总览卡片src/pages/home.rs (主统计卡片)
E3-S2配置对比图表src/pages/home.rs (CategoryBar 组件)
E3-S3偏离状态列表src/pages/home.rs (偏离度计算)
E3-S4无数据状态处理src/components/common/empty_state.rs
E3-S5再平衡方向提示src/pages/home.rs (ActionGuidance 组件)

验证:

  • ✅ 总资产展示
  • ✅ 双层进度条 (当前 vs 目标)
  • ✅ 偏离状态颜色标识
  • ✅ 投资建议 (增配/减配)

2.5 E4: 收益分析模块 ✅ 100%

Story ID功能状态实现文件
E4-S1周期收益计算src/pages/analysis.rs
E4-S2收益归因分析src/components/analysis/attribution_table.rs
E4-S3资产趋势图src/components/analysis/trend_chart.rs
E4-S4自定义周期对比src/components/analysis/period_selector.rs
E4-S5无数据状态处理src/pages/analysis.rs (空状态)

验证:

  • ✅ 周期选择 (本季/上季/本年/上年/全部)
  • ✅ 收益率计算正确
  • ✅ 类别归因分析
  • ✅ SVG 趋势图

3. 项目文件结构验证

src/
├── main.rs                    ✅ 应用入口
├── app.rs                     ✅ 根组件
├── router.rs                  ✅ 路由定义
│
├── models/                    ✅ 6 个模型文件
│   ├── asset.rs
│   ├── asset_scope.rs
│   ├── asset_sub_category.rs
│   ├── category.rs
│   ├── plan.rs
│   ├── snapshot.rs
│   └── vehicle_type.rs
│
├── db/                        ✅ 数据库层
│   ├── connection.rs
│   ├── migrations.rs
│   ├── asset_repo.rs
│   ├── plan_repo.rs
│   └── snapshot_repo.rs
│
├── state/                     ✅ 状态管理
│   └── app_state.rs
│
├── services/                  ✅ 业务服务
│   └── analysis_service.rs
│
├── components/
│   ├── layout/               ✅ 布局组件
│   │   ├── sidebar.rs
│   │   └── mod.rs
│   ├── common/               ✅ 8 个通用组件
│   │   ├── button.rs
│   │   ├── card.rs
│   │   ├── confirm_dialog.rs
│   │   ├── empty_state.rs
│   │   ├── input.rs
│   │   ├── modal.rs
│   │   ├── select.rs
│   │   └── mod.rs
│   ├── asset/                ✅ 资产组件
│   │   ├── asset_form.rs
│   │   ├── asset_item.rs
│   │   ├── asset_list.rs
│   │   ├── category_group.rs
│   │   └── mod.rs
│   ├── snapshot/             ✅ 快照组件
│   │   ├── inventory_mode.rs
│   │   ├── snapshot_card.rs
│   │   ├── snapshot_detail.rs
│   │   ├── snapshot_timeline.rs
│   │   └── mod.rs
│   ├── plan/                 ✅ 配置方案组件
│   │   ├── plan_card.rs
│   │   ├── plan_editor.rs
│   │   ├── plan_list.rs
│   │   └── mod.rs
│   ├── dashboard/            ✅ 仪表盘组件
│   │   ├── deviation_table.rs
│   │   ├── pie_chart.rs
│   │   ├── quick_actions.rs
│   │   ├── stat_card.rs
│   │   └── mod.rs
│   └── analysis/             ✅ 分析组件
│       ├── attribution_table.rs
│       ├── period_selector.rs
│       ├── return_card.rs
│       ├── trend_chart.rs
│       └── mod.rs
│
├── pages/                    ✅ 5 个页面
│   ├── home.rs
│   ├── assets.rs
│   ├── history.rs
│   ├── plans.rs
│   ├── analysis.rs
│   └── mod.rs
│
└── utils/                    ✅ 工具函数
    ├── format.rs
    └── mod.rs

4. 增强功能说明

在实际开发中,项目还增加了以下 MVP 之外的增强功能:

4.1 资产模型增强

  • AssetScope - 资产口径区分(可再平衡/非可再平衡)
  • AssetSubCategory - 资产细分类别
  • VehicleType - 投资工具类型

4.2 UI/UX 优化

  • Catppuccin Mocha 主题风格
  • 双层进度条配置对比
  • 智能投资建议组件
  • 完整的空状态处理

5. 后续建议

5.1 验收测试

建议执行以下验收测试:

测试项验收条件
资产盘点3 分钟内完成 10 个资产盘点
配置管理创建、编辑、切换 2+ 套方案
偏离监控清晰显示配置偏离情况
收益分析查看季度/年度收益率
性能启动 < 2s,切换 < 200ms

5.2 可选优化项

优先级优化项说明
P2数据导出导出 CSV/JSON
P2主题切换亮色/暗色主题
P3数据备份自动备份功能
P3图表增强更多图表类型

6. 总结

🎉 asset-light MVP 已全部完成!

项目已实现所有规划的核心功能:

  • ✅ 资产盘点与管理
  • ✅ 快照历史记录
  • ✅ 配置方案管理
  • ✅ 偏离度监控与建议
  • ✅ 收益分析与归因

建议下一步:

  1. 执行功能验收测试
  2. 进行用户体验测试
  3. 根据反馈迭代优化

修订历史

版本日期作者变更说明
1.0.02025-12-21SM Agent初始版本 - 确认 MVP 完成

用户故事:Dioxus 0.7 框架升级

Epic: E0 - 项目基础设施
Story ID: E0-S5
版本: 1.0.0
日期: 2025-01-09
优先级:
预计工时: 0.5 天


用户故事

作为项目维护者,
我希望将 Dioxus 框架从 0.5/0.6 升级到 0.7 版本,
以便获得更好的性能、更新的依赖项支持,以及更灵活的表单处理能力。


Dioxus 0.7 新特性概览

1. 依赖项结构变更

dioxus-lib crate 已被移除,现在直接使用 dioxus 作为依赖:

# Cargo.toml
[dependencies]
dioxus = { version = "0.7", features = ["desktop", "router"] }

影响范围:

  • ✅ 已完成 - Cargo.toml 已更新

2. 表单提交行为变更(重要)

在 0.7 之前,Dioxus 默认阻止所有表单提交以防止桌面端页面破坏。0.7 版本中这一行为已改变:

  • 表单提交不再被默认阻止
  • 需要手动调用 prevent_default() 来阻止默认行为

当前代码影响分析:

组件文件路径状态
AssetFormsrc/components/asset/asset_form.rs✅ 使用按钮点击事件,无需修改
InventoryPanelsrc/components/snapshot/inventory_panel.rs✅ 使用按钮点击事件,无需修改
PlanEditorsrc/components/plan/plan_editor.rs✅ 使用按钮点击事件,无需修改

建议: 当前项目使用按钮 on_click 事件处理表单提交,而非原生 <form onsubmit>,因此不受此变更影响。如后续使用原生表单,需注意:

#![allow(unused)]
fn main() {
// 如果使用原生表单,需要显式阻止默认行为
form {
    onsubmit: move |event| {
        event.prevent_default();
        // 处理表单数据...
    },
    // 表单内容...
}
}

3. 依赖项版本升级

Dioxus 0.7 升级了多个核心依赖:

依赖旧版本新版本影响
Wry~0.470.52WebView 渲染引擎,桌面端性能改进
Axum0.70.8服务器端(本项目暂未使用)
Server fn0.60.7服务器函数(本项目暂未使用)

影响范围:

  • 桌面端渲染可能有性能改进
  • 暂无需额外代码调整

4. 事件监听器类型变更

事件处理程序的类型签名有所变化:

#![allow(unused)]
fn main() {
// 0.6 及之前
impl SuperInto<EventHandler<Event<$data>>, __Marker>

// 0.7
impl SuperInto<ListenerCallback<$data>, __Marker>
}

影响范围:

  • 仅影响自定义渲染器
  • 本项目使用标准 Desktop 渲染器,无需修改

5. 新增特性建议

基于 Dioxus 0.7 的改进,建议后续考虑以下优化:

5.1 使用原生表单验证(可选增强)

#![allow(unused)]
fn main() {
// 可以利用浏览器原生表单验证
form {
    onsubmit: move |event| {
        event.prevent_default();
        let form_data = event.data();
        // 使用 FormData 获取表单值
    },
    input {
        r#type: "text",
        required: true,  // HTML5 原生验证
        pattern: "[A-Za-z0-9]+",  // 正则验证
    }
}
}

5.2 改进的错误边界处理

Dioxus 0.7 提供了更好的错误边界支持,可以考虑为关键组件添加:

#![allow(unused)]
fn main() {
use dioxus::prelude::*;

#[component]
fn SafeComponent() -> Element {
    // 使用 throw 和 ErrorBoundary 进行优雅降级
    rsx! {
        ErrorBoundary {
            fallback: |error| rsx! {
                div { class: "error-state",
                    "发生错误: {error}"
                }
            },
            RiskyComponent {}
        }
    }
}
}

技术任务

任务 ID任务描述文件/路径状态
T0-5-1更新 Cargo.toml 中 dioxus 版本到 0.7Cargo.toml✅ 已完成
T0-5-2检查表单提交逻辑兼容性src/components/**/✅ 无需修改
T0-5-3验证编译和运行-✅ 已通过
T0-5-4更新文档记录升级变更docs/✅ 本文档

验收标准

  • cargo build --release 编译成功
  • cargo run 应用正常启动
  • 所有表单功能正常工作(新增、编辑、删除资产)
  • 路由导航正常
  • 盘点功能正常
  • 文档已更新

迁移检查清单

已验证项目

  • 移除 dioxus-lib 引用(如有)
  • 更新 Cargo.toml 中的版本号
  • 检查所有表单提交处理
  • 验证事件处理器签名
  • 运行完整功能测试

未来可选优化

  • 评估使用原生 HTML5 表单验证
  • 添加错误边界组件
  • 探索 Dioxus 0.7 的性能改进特性

参考资料


修订历史

版本日期作者变更说明
1.0.02025-01-09Leon创建 Dioxus 0.7 升级用户故事

用户故事:响应式设计 - 支持桌面与移动端

Epic: E0 - 项目基础设施
Story ID: E0-S6
版本: 1.0.0
日期: 2025-01-11
优先级:
预计工时: 5-7 天


用户故事

作为个人投资者,
我希望在手机上也能查看和管理我的资产配置,
以便随时随地了解资产状况,不受设备限制。


背景分析

当前状态

方面现状问题
窗口尺寸固定 1440×900,最小 1280×720不支持移动端
侧边栏固定 220px 宽度小屏幕占用过多空间
布局方式内联样式,固定像素值无响应式断点
导航模式左侧固定侧边栏移动端需要底部导航或抽屉

目标状态

设备类型屏幕宽度导航模式布局特点
桌面端≥1024px左侧固定侧边栏多列布局,保持现有体验
平板端768-1023px可折叠侧边栏自适应列数
移动端<768px底部导航栏单列布局,卡片堆叠

技术方案

1. 响应式断点定义

#![allow(unused)]
fn main() {
// src/utils/responsive.rs

/// 响应式断点
pub enum Breakpoint {
    Mobile,   // < 768px
    Tablet,   // 768-1023px
    Desktop,  // ≥ 1024px
}

/// 根据窗口宽度获取当前断点
pub fn get_breakpoint(width: u32) -> Breakpoint {
    match width {
        w if w < 768 => Breakpoint::Mobile,
        w if w < 1024 => Breakpoint::Tablet,
        _ => Breakpoint::Desktop,
    }
}
}

2. 布局架构改造

2.1 主布局组件

#![allow(unused)]
fn main() {
// src/components/layout/app_layout.rs

#[component]
pub fn AppLayout() -> Element {
    let window_size = use_window_size(); // 需要实现
    let breakpoint = get_breakpoint(window_size.width);
    
    rsx! {
        div {
            class: "app-container",
            style: "display: flex; flex-direction: column; height: 100vh;",
            
            match breakpoint {
                Breakpoint::Desktop | Breakpoint::Tablet => rsx! {
                    div {
                        style: "display: flex; flex: 1;",
                        Sidebar { collapsible: breakpoint == Breakpoint::Tablet }
                        main {
                            style: "flex: 1; overflow: auto;",
                            Outlet::<Route> {}
                        }
                    }
                },
                Breakpoint::Mobile => rsx! {
                    main {
                        style: "flex: 1; overflow: auto; padding-bottom: 60px;",
                        Outlet::<Route> {}
                    }
                    BottomNavBar {}
                }
            }
        }
    }
}
}

2.2 移动端底部导航栏(新增)

#![allow(unused)]
fn main() {
// src/components/layout/bottom_nav.rs

#[component]
pub fn BottomNavBar() -> Element {
    rsx! {
        nav {
            style: r#"
                position: fixed;
                bottom: 0;
                left: 0;
                right: 0;
                height: 60px;
                background: #1e1e2e;
                display: flex;
                justify-content: space-around;
                align-items: center;
                border-top: 1px solid #313244;
                z-index: 1000;
            "#,
            
            BottomNavItem { to: Route::HomePage {}, icon: "📊", label: "首页" }
            BottomNavItem { to: Route::AssetsPage {}, icon: "💰", label: "资产" }
            BottomNavItem { to: Route::HistoryPage {}, icon: "📅", label: "历史" }
            BottomNavItem { to: Route::PlansPage {}, icon: "🎯", label: "配置" }
            BottomNavItem { to: Route::AnalysisPage {}, icon: "📈", label: "收益" }
        }
    }
}
}

3. 组件响应式改造清单

3.1 布局组件(必须改造)

组件文件路径改造内容优先级
Sidebarlayout/sidebar.rs支持折叠、移动端隐藏P0
AppLayoutlayout/mod.rs添加断点检测、切换布局P0
BottomNavBarlayout/bottom_nav.rs新建 移动端导航P0

3.2 首页组件

组件文件路径改造内容优先级
StatCarddashboard/stat_card.rs移动端全宽显示P1
PieChartdashboard/pie_chart.rs调整图表尺寸P1
DeviationTabledashboard/deviation_table.rs移动端改为卡片列表P1
QuickActionsdashboard/quick_actions.rs移动端网格布局P2

3.3 资产管理组件

组件文件路径改造内容优先级
AssetListasset/asset_list.rs移动端单列P1
AssetItemasset/asset_item.rs紧凑布局P1
AssetFormasset/asset_form.rs全屏弹窗/页面P1
CategoryGroupasset/category_group.rs折叠卡片P2

3.4 盘点组件

组件文件路径改造内容优先级
SnapshotTimelinesnapshot/snapshot_timeline.rs垂直时间线P1
SnapshotCardsnapshot/snapshot_card.rs全宽卡片P1
SnapshotDetailsnapshot/snapshot_detail.rs堆叠布局P1
InventoryModesnapshot/inventory_mode.rs移动端优化P2

3.5 配置方案组件

组件文件路径改造内容优先级
PlanListplan/plan_list.rs卡片列表P1
PlanCardplan/plan_card.rs紧凑布局P1
PlanEditorplan/plan_editor.rs全屏编辑P1

3.6 收益分析组件

组件文件路径改造内容优先级
PeriodSelectoranalysis/period_selector.rs下拉选择P1
ReturnCardanalysis/return_card.rs全宽显示P1
TrendChartanalysis/trend_chart.rs自适应宽度P1
AttributionTableanalysis/attribution_table.rs卡片化P2

3.7 通用组件

组件文件路径改造内容优先级
Modalcommon/modal.rs移动端全屏P0
Cardcommon/card.rs响应式内边距P1
Buttoncommon/button.rs移动端更大触控区P2
Inputcommon/input.rs移动端更大输入框P2

技术任务

Phase 1: 基础设施(P0)

任务 ID任务描述文件/路径预计工时
T6-1-1创建响应式工具模块src/utils/responsive.rs0.25d
T6-1-2实现窗口尺寸监听 Hooksrc/utils/use_window_size.rs0.5d
T6-1-3更新 Dioxus.toml 移除最小窗口限制Dioxus.toml0.1d
T6-1-4创建 AppLayout 组件src/components/layout/app_layout.rs0.5d
T6-1-5创建 BottomNavBar 组件src/components/layout/bottom_nav.rs0.5d
T6-1-6改造 Sidebar 支持折叠src/components/layout/sidebar.rs0.5d
T6-1-7改造 Modal 移动端全屏src/components/common/modal.rs0.25d

Phase 2: 核心页面适配(P1)

任务 ID任务描述文件/路径预计工时
T6-2-1首页 Dashboard 响应式src/pages/home.rs + dashboard/*0.5d
T6-2-2资产页响应式src/pages/assets.rs + asset/*0.5d
T6-2-3历史页响应式src/pages/history.rs + snapshot/*0.5d
T6-2-4配置页响应式src/pages/plans.rs + plan/*0.5d
T6-2-5收益页响应式src/pages/analysis.rs + analysis/*0.5d

Phase 3: 优化与测试(P2)

任务 ID任务描述文件/路径预计工时
T6-3-1通用组件触控优化src/components/common/*0.5d
T6-3-2表格移动端卡片化各表格组件0.5d
T6-3-3多设备测试与调整-0.5d

配置变更

Dioxus.toml

[application]
name = "asset-light"
default_platform = "desktop"

[desktop]
title = "asset-light - 个人资产管理"

[desktop.window]
title = "asset-light"
width = 1440
height = 900
# 移除最小尺寸限制以支持窗口缩放测试
# min_width = 1280  
# min_height = 720
resizable = true
decorations = true

验收标准

桌面端(≥1024px)

  • 侧边栏正常显示,布局与现有一致
  • 所有页面功能正常

平板端(768-1023px)

  • 侧边栏可折叠(点击汉堡按钮展开/收起)
  • 内容区自适应宽度
  • 表格/卡片自动调整列数

移动端(<768px)

  • 侧边栏隐藏,显示底部导航栏
  • 导航栏 5 个入口清晰可点击
  • 所有页面单列布局
  • 弹窗改为全屏页面
  • 触控区域足够大(最小 44×44px)
  • 无水平滚动条

通用

  • 窗口缩放时布局平滑过渡
  • 无样式错乱或重叠
  • 编译成功,无 warning

移动端 UI 示意

底部导航栏

┌────────────────────────────────────┐
│  📊     💰     📅     🎯     📈   │
│ 首页   资产   历史   配置   收益  │
└────────────────────────────────────┘

首页布局(移动端)

┌──────────────────────┐
│   总资产: ¥1,234,567  │
├──────────────────────┤
│  ┌────┐ ┌────┐      │
│  │现金│ │权益│ ...   │ ← 统计卡片横向滚动
│  └────┘ └────┘      │
├──────────────────────┤
│    [饼图 - 资产分布]   │
├──────────────────────┤
│  配置偏离 ▼           │
│  ├ 现金类 +5.2%       │
│  ├ 权益类 -3.1%       │
│  └ 固收类 -2.1%       │
├──────────────────────┤
│ 📊   💰   📅   🎯   📈 │ ← 底部导航
└──────────────────────┘

后续扩展

完成响应式改造后,可进一步:

  1. iOS/Android 打包:使用 Dioxus Mobile 或 Tauri 2.0
  2. PWA 支持:编译为 Web 版本,支持离线使用
  3. 手势交互:左滑删除、下拉刷新等移动端交互

参考资料


修订历史

版本日期作者变更说明
1.0.02025-01-11Leon创建响应式设计用户故事

用户故事:真实收益追踪

创建日期: 2025-01-11
状态: ✅ 已实现
优先级: P0


1. 背景与问题

当前问题

现有的"快照式盘点"模式无法区分"资产增值收益"和"资金流入流出"。

示例场景

  • 期初:沪深300ETF 10,000元
  • 期末:沪深300ETF 15,000元
  • 账面变化:+5,000元

但这5,000元可能是:

  • 用户加仓投入:+3,000元
  • 实际投资收益:+2,000元

用户需求

"我想知道我的每一个具体资产(比如'沪深300ETF')到底赚了多少钱,而不是被新投入的资金'污染'。"


2. 解决方案

核心思路

在盘点时,除了记录资产当前市值,还可以选填"本期资金变动"(净流入/流出)。

真实收益计算公式

真实收益 = 期末市值 - 期初市值 - 净流入
真实收益率 = 真实收益 / (期初市值 + 净流入/2)

设计原则

  1. 输入负担最小化:只有资金变动的资产需要额外填写
  2. 向后兼容:历史数据默认 net_inflow = 0
  3. 精确到单个资产:每个资产独立追踪
  4. 不需要交易流水:只在盘点时汇总

3. 数据模型改动

3.1 SnapshotItem 增加字段

#![allow(unused)]
fn main() {
// src/models/snapshot.rs
pub struct SnapshotItem {
    pub asset_id: Uuid,
    pub asset_name: String,
    pub scope: AssetScope,
    pub category: Category,
    pub sub_asset_class: String,
    pub vehicle_type: VehicleType,
    pub value: Decimal,
    
    // 新增:本期净流入(正=投入,负=取出)
    pub net_inflow: Decimal,  // 默认 Decimal::ZERO
}
}

3.2 数据库表结构

-- snapshot_items 表新增列
ALTER TABLE snapshot_items ADD COLUMN net_inflow TEXT NOT NULL DEFAULT '0';

3.3 Snapshot 新增辅助方法

#![allow(unused)]
fn main() {
impl Snapshot {
    /// 计算单个资产的真实收益(相对于上一个快照)
    pub fn asset_true_return(&self, asset_id: Uuid, prev_snapshot: &Snapshot) -> Option<TrueReturn> {
        let curr_item = self.items.iter().find(|i| i.asset_id == asset_id)?;
        let prev_item = prev_snapshot.items.iter().find(|i| i.asset_id == asset_id)?;
        
        let book_return = curr_item.value - prev_item.value;
        let true_return = book_return - curr_item.net_inflow;
        let base = prev_item.value + curr_item.net_inflow / dec!(2);
        let true_rate = if base > Decimal::ZERO {
            true_return / base * dec!(100)
        } else {
            Decimal::ZERO
        };
        
        Some(TrueReturn {
            asset_id,
            book_return,
            net_inflow: curr_item.net_inflow,
            true_return,
            true_rate,
        })
    }
}

pub struct TrueReturn {
    pub asset_id: Uuid,
    pub book_return: Decimal,
    pub net_inflow: Decimal,
    pub true_return: Decimal,
    pub true_rate: Decimal,
}
}

4. UI 改动

4.1 盘点模式

在每个资产条目下方增加"本期投入"输入框(可选,默认收起)。

布局设计

┌────────────────────────────────────────────────────────────────┐
│ 沪深300ETF                              原值: ¥10,000          │
│ ┌──────────────────────────────────────────────────────────┐   │
│ │ 当前市值  [  ¥15,000  ]              变化: +¥5,000       │   │
│ └──────────────────────────────────────────────────────────┘   │
│                                                                │
│ 📥 本期投入/取出 [  ¥3,000  ]  (可选,取出填负数)            │
│                                                                │
│ 💡 真实收益: +¥2,000 (+17.4%)                                  │
└────────────────────────────────────────────────────────────────┘

交互设计

  • 默认隐藏"本期投入"行
  • 点击某个资产的"+"按钮展开填写
  • 填写后实时计算并显示"真实收益"
  • 空值或0表示没有资金变动

4.2 收益分析页

增加"真实收益"视角,显示剔除资金进出后的收益。

概览卡片

┌─────────────────────────────┬─────────────────────────────┐
│ 账面变动                     │ 真实收益                     │
│ +¥ 100,000                  │ +¥ 45,000                   │
│ +10.5%                      │ +4.85%                      │
│ 含资金流入 ¥55,000          │ 剔除资金进出后               │
└─────────────────────────────┴─────────────────────────────┘

归因表格扩展(按单个资产):

┌──────────────┬────────────┬────────────┬────────────┬──────────┐
│ 资产         │ 账面变化    │ 资金进出    │ 真实收益   │ 收益率   │
├──────────────┼────────────┼────────────┼────────────┼──────────┤
│ 沪深300ETF   │ +¥5,000    │ +¥3,000    │ +¥2,000   │ +17.4%  │
│ 中证500ETF   │ +¥3,000    │ +¥5,000    │ -¥2,000   │ -6.7%   │
│ 余额宝       │ +¥100      │ ¥0         │ +¥100     │ +0.2%   │
│ 朝朝宝       │ +¥25,700   │ +¥25,700   │ ¥0        │ 0%      │
├──────────────┼────────────┼────────────┼────────────┼──────────┤
│ 合计         │ +¥33,800   │ +¥33,700   │ +¥100     │ +0.1%   │
└──────────────┴────────────┴────────────┴────────────┴──────────┘

5. 实现计划

Phase 1:数据模型与数据库(预估 0.5 天)

  1. SnapshotItem 增加 net_inflow 字段
  2. 数据库迁移脚本
  3. snapshot_repo.rs 读写支持

Phase 2:盘点模式 UI(预估 1 天)

  1. InventoryMode 组件增加"本期投入"输入
  2. 实时计算真实收益预览
  3. 保存时存储 net_inflow

Phase 3:收益分析页(预估 1 天)

  1. 收益概览卡片增加"真实收益"
  2. 归因表格支持按资产展开
  3. 计算逻辑更新

Phase 4:测试与优化(预估 0.5 天)

  1. 单元测试
  2. 边界情况处理
  3. UI 细节优化

总预估:3 天


6. 验收标准

  • 盘点时可以为每个资产填写"本期投入/取出"金额
  • 收益分析页显示"账面收益"和"真实收益"双指标
  • 支持按单个资产查看真实收益归因
  • 历史数据向后兼容(net_inflow 默认为 0)
  • 输入体验流畅,大部分资产不需要额外输入

7. 术语定义

术语定义
账面收益期末市值 - 期初市值
净流入本期投入资金 - 本期取出资金
真实收益账面收益 - 净流入
真实收益率真实收益 / (期初市值 + 净流入/2) × 100%

8. 附录:用户操作示例

场景:用户月初工资入账,定投了两只基金

操作流程

  1. 打开资产管理 → 发起盘点
  2. 更新各资产当前市值
  3. 对于"朝朝宝",填写本期投入 +25,700(工资入账)
  4. 对于"沪深300ETF",填写本期投入 +3,000(定投)
  5. 对于"中证500ETF",填写本期投入 +5,000(定投)
  6. 完成盘点

结果:收益分析页显示

  • 朝朝宝真实收益 ≈ 0(纯粹是工资入账)
  • 沪深300ETF真实收益 = 实际涨跌
  • 中证500ETF真实收益 = 实际涨跌(可能是负的)

这样用户就能清楚看到:哪些资产在真正赚钱,哪些在亏钱

用户故事:单资产级别收益归因表格

创建日期: 2025-01-11
更新日期: 2025-01-11
状态: 待开发
优先级: P1
依赖: 真实收益追踪功能(已完成)


1. 用户故事

作为个人投资者,
我希望在收益分析页面看到每个资产的真实收益明细,包括每期收益变化,
以便我能清楚地知道哪些资产在真正赚钱,哪些在亏钱,以及收益是如何随时间变化的。


2. 背景与问题

用户场景

  • 用户每月盘点一次资产
  • 每次盘点时既有新资金投入,也有市场涨跌导致的收益变化
  • 用户想知道:
    1. 单期收益:这个月我的某个资产赚了还是亏了?
    2. 累计收益:我买的这个资产从开始到现在一共赚了多少?
    3. 对比分析:哪个资产表现好,哪个差?

当前状态

目前收益分析页只支持"两点比较"模式:

  • 选择起始快照和终止快照
  • 计算累计收益

缺失的能力

  • 无法看到每期(每月)的收益变化
  • 无法追踪单个资产的收益历史

用户痛点

问题影响
只有累计收益,没有分期明细不知道某个月是赚是亏
类别级别的收益会相互抵消无法识别表现差的个别资产
没有收益趋势无法判断资产表现是在改善还是恶化

3. 功能需求

3.1 功能一:资产收益对比表(横向对比)

展示选定时间段内,所有资产的收益对比

核心字段

字段说明
资产名称资产标识
期初市值起始快照时的市值
期末市值终止快照时的市值
账面变动期末 - 期初
累计流入期间所有快照的 net_inflow 之和
真实收益账面变动 - 累计流入
真实收益率真实收益 / 平均资本基础

⚠️ 注意:当选择跨多个快照的时间段时,需要累加中间所有快照的 net_inflow

UI 设计(桌面端)

┌─────────────────────────────────────────────────────────────────────────┐
│ 📊 资产收益对比                                        [按类别筛选 ▾]   │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│ ┌───────────────┬────────┬────────┬────────┬────────┬────────┬───────┐ │
│ │ 资产          │ 期初   │ 期末   │ 账面   │ 流入   │ 真实收益│ 收益率│ │
│ ├───────────────┼────────┼────────┼────────┼────────┼────────┼───────┤ │
│ │ 🟢 沪深300ETF │ ¥10,000│ ¥15,000│ +¥5,000│ ¥3,000 │ +¥2,000│ +17.4%│ │
│ │ 🟢 余额宝     │ ¥50,000│ ¥50,100│ +¥100  │ ¥0     │ +¥100  │ +0.2% │ │
│ │ ⚪ 朝朝宝     │ ¥20,000│ ¥45,700│+¥25,700│ ¥25,700│ ¥0     │ 0%    │ │
│ │ 🔴 中证500ETF │ ¥10,000│ ¥13,000│ +¥3,000│ ¥5,000 │ -¥2,000│ -16.0%│ │
│ └───────────────┴────────┴────────┴────────┴────────┴────────┴───────┘ │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

3.2 功能二:收益时间序列表(纵向追踪)⭐ 核心需求

展示单个资产在每个盘点周期的收益变化

用户场景:用户想知道"我的沪深300ETF每个月赚了多少"。

核心字段

字段说明
盘点日期快照日期
期末市值该快照时的市值
本期投入该快照的 net_inflow
本期收益(本期市值 - 上期市值) - 本期投入
累计投入历史 net_inflow 之和
累计收益从第一期到当前的真实收益累计

UI 设计

┌─────────────────────────────────────────────────────────────────────────┐
│ 📈 沪深300ETF 收益历史                                      [← 返回]    │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│ ┌──────────┬────────┬────────┬────────┬────────┬────────┐              │
│ │ 盘点日期  │ 期末市值│ 本期投入│ 本期收益│ 累计投入│ 累计收益│              │
│ ├──────────┼────────┼────────┼────────┼────────┼────────┤              │
│ │ 2025-01  │ ¥10,000│ ¥10,000│   -    │ ¥10,000│   -    │ ← 首次建仓   │
│ │ 2025-02  │ ¥14,000│ ¥3,000 │ +¥1,000│ ¥13,000│ +¥1,000│              │
│ │ 2025-03  │ ¥17,500│ ¥2,000 │ +¥1,500│ ¥15,000│ +¥2,500│              │
│ │ 2025-04  │ ¥16,000│ ¥0     │ -¥1,500│ ¥15,000│ +¥1,000│ ← 本月亏损   │
│ │ 2025-05  │ ¥20,000│ ¥1,000 │ +¥3,000│ ¥16,000│ +¥4,000│              │
│ └──────────┴────────┴────────┴────────┴────────┴────────┘              │
│                                                                         │
│ 📊 统计摘要                                                              │
│ ├─ 累计投入: ¥16,000                                                    │
│ ├─ 当前市值: ¥20,000                                                    │
│ ├─ 累计收益: +¥4,000 (+25.0%)                                           │
│ └─ 盈利月份: 3/4 (75%)                                                  │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

移动端适配:使用可滚动的卡片时间线

┌─────────────────────────────┐
│ 📈 沪深300ETF               │
│ 累计收益: +¥4,000 (+25.0%)  │
├─────────────────────────────┤
│                             │
│ ○ 2025-05                   │
│ │ 市值 ¥20,000              │
│ │ 投入 ¥1,000               │
│ │ 本期 🟢 +¥3,000           │
│ │                           │
│ ○ 2025-04                   │
│ │ 市值 ¥16,000              │
│ │ 投入 ¥0                   │
│ │ 本期 🔴 -¥1,500           │
│ │                           │
│ ○ 2025-03                   │
│ │ ...                       │
│                             │
└─────────────────────────────┘

3.3 交互流程

收益分析页
    │
    ├── 总览卡片(账面变动 / 真实收益)
    │
    ├── 类别归因表格(已有)
    │
    └── 资产收益对比表(新增 - 3.1)
            │
            └── 点击某个资产行
                    │
                    └── 展开/跳转到 → 收益时间序列表(新增 - 3.2)

4. 数据模型

4.1 复用已有结构

#![allow(unused)]
fn main() {
/// 单个资产的真实收益(两点比较)
pub struct AssetTrueReturn {
    pub asset_id: Uuid,
    pub asset_name: String,
    pub category: Category,
    pub start_value: Decimal,
    pub end_value: Decimal,
    pub book_return: Decimal,
    pub net_inflow: Decimal,      // 注意:跨多期时需要累加
    pub true_return: Decimal,
    pub true_rate: Decimal,
}
}

4.2 新增:收益时间序列结构

#![allow(unused)]
fn main() {
/// 单个资产的收益时间序列
pub struct AssetReturnSeries {
    pub asset_id: Uuid,
    pub asset_name: String,
    pub category: Category,
    pub periods: Vec<PeriodReturn>,
    pub total_inflow: Decimal,      // 累计投入
    pub total_return: Decimal,      // 累计收益
    pub total_rate: Decimal,        // 累计收益率
    pub winning_periods: usize,     // 盈利期数
    pub total_periods: usize,       // 总期数
}

/// 单期收益
pub struct PeriodReturn {
    pub snapshot_date: NaiveDate,
    pub end_value: Decimal,         // 期末市值
    pub period_inflow: Decimal,     // 本期投入
    pub period_return: Decimal,     // 本期收益
    pub cumulative_inflow: Decimal, // 累计投入
    pub cumulative_return: Decimal, // 累计收益
}
}

5. 计算逻辑

5.1 资产收益对比(跨多期)

#![allow(unused)]
fn main() {
fn calculate_asset_returns_multi_period(
    snapshots: &[Snapshot],  // 按时间排序,包含起始和终止之间的所有快照
    asset_id: Uuid,
) -> AssetTrueReturn {
    let start = snapshots.first();
    let end = snapshots.last();
    
    // 关键:累加中间所有快照的 net_inflow
    let total_inflow: Decimal = snapshots.iter()
        .skip(1)  // 跳过起始快照
        .filter_map(|s| s.items.iter().find(|i| i.asset_id == asset_id))
        .map(|i| i.net_inflow)
        .sum();
    
    // ... 计算真实收益
}
}

5.2 收益时间序列

#![allow(unused)]
fn main() {
fn calculate_asset_return_series(
    snapshots: &[Snapshot],  // 按时间正序
    asset_id: Uuid,
) -> AssetReturnSeries {
    let mut periods = Vec::new();
    let mut cumulative_inflow = Decimal::ZERO;
    let mut cumulative_return = Decimal::ZERO;
    let mut prev_value = Decimal::ZERO;
    
    for (i, snapshot) in snapshots.iter().enumerate() {
        if let Some(item) = snapshot.items.iter().find(|i| i.asset_id == asset_id) {
            let period_inflow = item.net_inflow;
            let period_return = if i == 0 {
                Decimal::ZERO  // 首期无收益计算
            } else {
                item.value - prev_value - period_inflow
            };
            
            cumulative_inflow += period_inflow;
            cumulative_return += period_return;
            
            periods.push(PeriodReturn {
                snapshot_date: snapshot.snapshot_date,
                end_value: item.value,
                period_inflow,
                period_return,
                cumulative_inflow,
                cumulative_return,
            });
            
            prev_value = item.value;
        }
    }
    
    // ... 构建 AssetReturnSeries
}
}

6. 验收标准

6.1 资产收益对比表

  • 展示所有资产的期初、期末、账面变动、累计流入、真实收益、收益率
  • 正确累加跨多期的 net_inflow
  • 支持按真实收益排序
  • 支持按类别筛选
  • 正确处理新增/删除资产

6.2 收益时间序列表

  • 点击资产可查看该资产的收益历史
  • 展示每期的本期收益和累计收益
  • 显示统计摘要(累计收益、盈利月份比例)
  • 移动端有良好的时间线展示体验
  • 首期(首次建仓)正确标记,不计算收益

7. 工作量估算

任务估算
数据模型新增(AssetReturnSeries, PeriodReturn)0.25 天
计算逻辑(多期累加、时间序列)0.5 天
资产收益对比表组件0.5 天
收益时间序列表组件0.75 天
移动端适配0.5 天
集成与交互0.25 天
测试与优化0.25 天
总计3 天

8. 技术风险

风险缓解措施
资产 ID 跨快照不一致严格按 asset_id 匹配,不依赖 asset_name
首期资产没有期初值首期特殊处理,不计算收益
资产中途删除又重新添加按 asset_id 追踪,中间缺失期视为断档

9. 后续扩展

  • 收益趋势折线图(可视化每期收益变化)
  • 与大盘指数对比(相对收益)
  • 导出收益报表(Excel/PDF)

用户故事:收益归因图表可视化

创建日期: 2025-01-11
更新日期: 2025-01-11
状态: 待开发
优先级: P2
依赖:

  • 真实收益追踪功能(已完成)
  • 单资产级别收益归因表格(建议先完成)

1. 用户故事

作为个人投资者,
我希望通过可视化图表直观地看到收益趋势和归因分布,
以便我能快速理解收益如何随时间变化,以及哪些资产/类别贡献最大。


2. 背景与问题

用户场景

  • 用户每月盘点一次
  • 每次盘点都有新投入和收益变化
  • 想要直观地看到:
    1. 收益趋势:这几个月收益是上涨还是下跌?
    2. 收益归因:哪个类别/资产贡献了主要收益?
    3. 真实 vs 账面:资金流入对收益数字的影响有多大?

当前状态

目前收益归因只有表格形式,用户需要阅读数字来理解收益情况。

用户痛点

痛点影响
纯数字表格不直观难以快速把握整体趋势
没有时间维度的可视化看不出收益是在改善还是恶化
正负收益混合时信息过载整体情况不清晰

3. 功能需求

3.1 图表类型总览

优先级图表用途Phase
⭐ P0收益趋势折线图展示每期累计收益变化1
P1收益对比条形图对比各类别/资产的收益1
P2账面vs真实对比图展示资金流入的影响2
P3收益贡献环形图展示贡献占比2
P4收益瀑布图展示收益分解路径3

3.2 图表 1:收益趋势折线图 ⭐ 核心

用途:展示累计收益随时间的变化,回答"我的投资整体表现如何"。

数据维度

  • X 轴:盘点日期(月份)
  • Y 轴:累计真实收益(¥)
  • 可选叠加:累计账面收益

UI 设计

累计收益
    │
 +4k├────────────────────────────────●  真实收益
    │                              ╱
 +3k├────────────────────────────╱
    │                          ╱
 +2k├────────────────────────●╱
    │                      ╱
 +1k├────────────────────●
    │                  ╱ 
   0├────────────────●─────────────────────────
    │              ╱                    ○ 账面收益
 -1k├────────────○─────○─────○─────○─────○
    │
    └──────┬──────┬──────┬──────┬──────┬──────
         1月    2月    3月    4月    5月

    ● 真实收益: +¥4,000 (+25.0%)
    ○ 账面收益: +¥8,000 (+50.0%)
    差额由资金流入解释: ¥4,000

交互

  • 悬停显示具体数值
  • 可切换:总收益 / 按类别分组 / 单资产
  • 可选时间范围

3.3 图表 2:收益对比条形图

用途:横向对比各类别/资产的收益表现,识别表现好/差的部分。

UI 设计(按真实收益排序)

┌─────────────────────────────────────────────────────────────┐
│ 📊 收益贡献排行                                              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│ 沪深300ETF  ████████████████████████  +¥2,000 (+17.4%)     │
│ 余额宝      ███                        +¥100  (+0.2%)      │
│ 朝朝宝      │                          ¥0     (0%)         │
│ 中证500ETF  ▓▓▓▓▓▓▓▓                   -¥2,000 (-16.0%)    │
│                                                             │
│             ─────────────────┼─────────────────             │
│            -¥3k   -¥1.5k     0    +¥1.5k   +¥3k             │
│                                                             │
│ █ 正收益  ▓ 负收益                                          │
└─────────────────────────────────────────────────────────────┘

特点

  • 正收益向右(绿色),负收益向左(红色)
  • 按收益额排序,表现好的在上
  • 支持按类别/资产切换

3.4 图表 3:账面 vs 真实收益对比

用途:直观展示资金流入对收益数字的影响。

UI 设计

┌─────────────────────────────────────────────────────────────┐
│ 📊 账面收益 vs 真实收益                                       │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│ 现金类   账面 █████████████████████████ +¥25,700            │
│          真实 ░                          ¥0                 │
│                                                             │
│ 权益类   账面 ████████                   +¥8,000            │
│          真实 ███████                    +¥3,000            │
│                                                             │
│ 固收类   账面 ▓▓                         -¥2,000            │
│          真实 ▓▓                         -¥2,000            │
│                                                             │
│ █ 账面收益  ░ 被资金流入抵消部分  ▓ 负收益                    │
└─────────────────────────────────────────────────────────────┘

解读

  • 现金类账面 +¥25,700,但真实 ¥0 → 说明全是资金流入
  • 权益类账面 +¥8,000,真实 +¥3,000 → 有 ¥5,000 是资金流入

3.5 图表 4:收益贡献环形图

用途:展示各类别对总收益的贡献占比。

⚠️ 负值处理:环形图不适合展示负值,采用以下策略:

  1. 仅展示正收益贡献:负收益类别单独标注
  2. 或使用分离扇区:负收益部分用虚线边框

UI 设计

┌─────────────────────────────────────────┐
│ 📊 正收益贡献分布                        │
├─────────────────────────────────────────┤
│                                         │
│          ╭───────────────╮              │
│        ╱                   ╲            │
│       │    权益类 60%       │           │
│       │   ╭─────────╮      │           │
│       │   │ 现金类  │      │           │
│       │   │  40%   │      │           │
│       │   ╰─────────╯      │           │
│        ╲                   ╱            │
│          ╰───────────────╯              │
│                                         │
│ 📍 负收益类别:固收类 -¥2,000           │
│                                         │
└─────────────────────────────────────────┘

3.6 图表 5:单资产收益趋势图

用途:配合"资产级别收益归因表格",点击资产后展示该资产的历史收益趋势。

UI 设计

┌─────────────────────────────────────────────────────────────┐
│ 📈 沪深300ETF 收益趋势                             [← 返回]  │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│ 累计收益                                                     │
│     │                                                       │
│  +4k├────────────────────────────────●                      │
│     │                              ╱                        │
│  +2k├────────────────────────────●                          │
│     │                          ╱                            │
│  +1k├────────────────────────●                              │
│     │                      ╱                                │
│    0├────────────────────●──────────────────────            │
│     │                                                       │
│     └──────┬──────┬──────┬──────┬──────                     │
│          1月    2月    3月    4月                            │
│                                                             │
│  ┌──────────────────────────────────────────┐               │
│  │ 本期收益柱状图                            │               │
│  │    +3k│        ████                      │               │
│  │    +2k│  ████        ████                │               │
│  │    +1k│        ▓▓▓▓                      │               │
│  │       └──┬──────┬──────┬──────┬──        │               │
│  │         2月    3月    4月    5月          │               │
│  └──────────────────────────────────────────┘               │
│                                                             │
└─────────────────────────────────────────────────────────────┘

4. 技术方案

4.1 图表实现方式

图表实现方式理由
折线图纯 SVG路径简单,易于实现
条形图CSS + div最简单,无需 SVG
环形图SVG需要 arc 路径
瀑布图SVG结构复杂,Phase 3

4.2 组件设计

src/components/analysis/
├── charts/
│   ├── mod.rs
│   ├── line_chart.rs       # 折线图(趋势)
│   ├── bar_chart.rs        # 条形图(对比)
│   ├── comparison_bar.rs   # 双条对比图(账面vs真实)
│   ├── donut_chart.rs      # 环形图(占比)
│   └── waterfall_chart.rs  # 瀑布图(Phase 3)
└── ...

4.3 数据接口

#![allow(unused)]
fn main() {
/// 趋势图数据点
pub struct TrendPoint {
    pub date: NaiveDate,
    pub cumulative_return: Decimal,
    pub period_return: Decimal,
}

/// 收益趋势数据
pub struct ReturnTrend {
    pub points: Vec<TrendPoint>,
    pub min_value: Decimal,
    pub max_value: Decimal,
}

/// 生成趋势数据
fn calculate_return_trend(
    snapshots: &[Snapshot],
    scope: Option<AssetScope>,
    category: Option<Category>,
    asset_id: Option<Uuid>,
) -> ReturnTrend;
}

5. 移动端适配

5.1 布局调整

  • 图表全宽显示,垂直堆叠
  • 折线图支持左右滑动查看历史
  • 条形图改为垂直方向

5.2 交互调整

桌面端移动端
鼠标悬停长按显示
点击跳转点击跳转
缩放双指缩放

6. 验收标准

Phase 1(基础图表)

  • 收益趋势折线图展示累计收益变化
  • 支持切换:全部 / 按类别 / 单资产
  • 收益对比条形图展示各资产排行
  • 正负收益有明确的颜色区分
  • 移动端适配良好

Phase 2(进阶图表)

  • 账面 vs 真实收益对比图
  • 收益贡献环形图(正确处理负值)
  • 图表交互(悬停显示数值)

Phase 3(高级图表)

  • 收益瀑布图
  • 动画效果

7. 工作量估算

Phase 1(核心图表)- 2 天

任务估算
折线图组件(SVG)0.75 天
条形图组件(CSS)0.5 天
数据接口(ReturnTrend)0.25 天
移动端适配0.25 天
集成到收益分析页0.25 天

Phase 2(对比图表)- 1.5 天

任务估算
双条对比图组件0.5 天
环形图组件(SVG)0.5 天
交互优化0.5 天

Phase 3(高级图表)- 1 天

任务估算
瀑布图组件0.75 天
动画效果0.25 天

总计4.5 天


8. 设计规范

8.1 配色方案(Catppuccin Mocha)

元素颜色色值
正收益Green#a6e3a1
负收益Red#f38ba8
中性/零Overlay0#6c7086
现金类Green#a6e3a1
固收类Blue#89b4fa
权益类Mauve#cba6f7
另类Yellow#f9e2af
折线(真实收益)Green#a6e3a1
折线(账面收益)Overlay1#7f849c

8.2 动画

场景动画
图表加载从左到右渐入(折线),从下到上生长(条形)
数值变化300ms ease-out 过渡
悬停反馈放大 1.05x + 阴影

9. 后续扩展

  • 支持自定义时间范围
  • 支持导出图表为图片
  • 支持历史收益归因对比(本期 vs 上期 vs 去年同期)
  • 与大盘指数叠加对比(相对收益)

业务概念:标准化资产分类体系(行业最佳实践版)

1. 目标与适用范围

建立一套可计算、可治理、可扩展的资产分类体系,用于:

  • 资产盘点(净资产/投资组合)
  • 资产分布(配置对比、偏离监控、再平衡)
  • 收益归因(按风险暴露维度汇总)
  • 数据治理(避免自由输入导致的分类漂移)

行业最佳实践的关键点:以“风险暴露(Exposure)”作为主分类轴,并把“工具类型/账户载体/策略分层/用途”作为正交维度,避免混用。


2. 设计原则(Best Practices)

  • 主轴以风险暴露为准:分类反映主要风险因子(利率/信用/权益/商品/地产/另类/加密),而不是“买的是什么产品名字”。
  • 主分类 MECE:互斥且覆盖(必须有“其他”桶),避免同一资产在多个主类间摇摆。
  • 多维正交
    • 资产大类/子资产类别(Exposure)≠ 工具类型(ETF/基金/股票/存款)
    • Exposure ≠ 核心/卫星(策略分层)
    • Exposure ≠ 用途/流动性(应急金/短期/长期)
  • 字典化、版本化:分类项应有稳定编码与版本号;新增/调整走字典版本升级,保证历史可追溯。
  • 输入强约束:录入侧使用下拉选择;必要时用“其他”+备注,不允许随意创建新类别进入核心计算。

3. 推荐分类维度(四维 + 可选扩展)

3.1 维度A:资产范围(Scope,建议)

用于区分“是否纳入再平衡”。

  • Investable(可再平衡资产):可通过买卖/赎回/调仓在合理周期内改变权重的金融资产
  • Non‑investable(非可再平衡资产):短期难以调整或不建议作为再平衡对象(如自住房、长期锁定资产、应收款等)

规则建议:

  • 配置偏离/再平衡默认只统计 Investable
  • 净资产总览可包含 Investable + Non‑investable

3.2 维度B:资产大类(Asset Class,主轴,必填)

按主要风险暴露划分主类(推荐编码仅作业务字典标识,不是代码实现):

Code资产大类主要风险因子典型资产(示例)
CASH现金及现金等价物流动性/短期利率活期、货币基金、现金管理、应急金
FI固定收益(债券/信用)利率/信用国债/政策债/信用债、债券基金、固收类理财
EQ权益(股票)权益风险溢价宽基指数、行业/主题ETF、主动权益基金、个股
RE不动产/REITs地产/利率/现金流公募REITs、投资性房产(可选)
CMD商品通胀/商品周期黄金、商品指数(可选)
ALT另类(非传统公开市场)期限/流动性/策略私募股权/对冲/结构性产品(可选)
CRYPTO加密资产加密市场风险BTC/ETH/其他(可选)
OTHER其他资产/权利取决于条目保险现金价值、养老金权益、应收款等

实操建议:如果产品聚焦“投资组合”,可先启用 CASH/FI/EQ 三大类;RE/CMD/ALT/CRYPTO/OTHER 作为扩展项或可选开关。

3.3 维度C:子资产类别(Sub‑Asset Class,强烈建议必填)

子类用于进一步反映更具体的风险暴露/用途(仍然是 Exposure 维度,而不是工具类型)。

CASH(现金及现金等价物)

  • CASH_EMERGENCY:应急准备金(用途维度的标准化落位)
  • CASH_DEMAND:活期存款
  • CASH_MMF:货币基金/现金管理
  • CASH_TIME_DEPOSIT:定期存款/存单
  • CASH_RECEIVABLE:应收款/个人借贷(建议标记为 Non‑investable 或单独口径)
  • CASH_OTHER:其他现金类

FI(固定收益)

  • FI_GOV:政府债/国债
  • FI_POLICY:政策性金融债/准政府
  • FI_CREDIT_IG:投资级信用(中高等级)
  • FI_CREDIT_HY:高收益/信用下沉
  • FI_CONVERTIBLE:可转债(介于固收与权益之间,可按主要暴露归类)
  • FI_FUND:债券型基金/固收基金(面向个人用户的便捷子类入口)
  • FI_WMP:固收类银行理财(若纳入)
  • FI_OTHER:其他固定收益

EQ(权益)

  • EQ_BROAD:宽基指数(你提到的“宽基指数”在此落位)
  • EQ_FACTOR:因子/Smart Beta
  • EQ_SECTOR_THEME:行业/主题
  • EQ_ACTIVE_FUND:主动权益基金
  • EQ_SINGLE_STOCK:个股
  • EQ_OTHER:其他权益

RE(不动产/REITs,可选)

  • RE_PUBLIC_REIT:公募REITs
  • RE_DIRECT_INVEST:投资性房产
  • RE_PRIMARY:自住房(通常 Non‑investable)
  • RE_OTHER:其他不动产

CMD / ALT / CRYPTO / OTHER(可选扩展)

建议在产品确实需要时再细分;原则是仍以“主要风险暴露”来定义子类。

3.4 维度D:工具类型(Vehicle Type,推荐)

工具类型回答“你通过什么载体持有这份暴露”,不参与主轴归类。

建议枚举(示例):

  • 存款/现金(Deposit/Cash)
  • 公募基金(Mutual Fund)
  • ETF
  • 股票(Stock)
  • 债券(Bond)
  • 银行理财(WMP)
  • 公募REIT(REIT)
  • 私募产品(Private)
  • 保险(Insurance)
  • 其他(Other)

3.5 核心/卫星(Core/Satellite,策略分层,配置层面必填)

核心/卫星是策略分层,不是资产大类。

  • 核心(Core):长期持有、分散、低成本、规则更稳定(通常占比 70%–80%)
  • 卫星(Satellite):小仓位增强收益或表达主题观点,波动更高、集中度更高(通常占比 20%–30%)

落地建议:

  • Core 常见承载:EQ_BROAD、高质量 FI(利率/投资级)
  • Satellite 常见承载:EQ_SECTOR_THEMEEQ_SINGLE_STOCK、更激进的 ALT/CRYPTO

4. 常见概念如何“正确落位”(对齐示例)

你提到的条目在最佳实践体系中建议这样表达:

  • 应急准备金CASHCASH_EMERGENCY(Scope 可为 Investable,但通常不参与再平衡)
  • 固定收益类:资产大类 FI(子类按持仓实际暴露细分,如 FI_FUND / FI_GOV 等)
  • 应收账款:建议归入 CASH_RECEIVABLEOTHER(并标记为 Non‑investable,避免干扰配置偏离)
  • 债券型基金:Exposure 归入 FI(子类可用 FI_FUND),工具类型为 “公募基金”
  • 宽基指数:Exposure 归入 EQ_BROAD,工具类型可能是 ETF/指数基金

5. 数据录入与治理规则(强约束)

  • 资产大类:必填,下拉选择(来自业务字典)
  • 子资产类别:强烈建议必填,下拉选择(与资产大类联动过滤)
  • 工具类型:推荐填写,下拉选择
  • Scope:建议填写(Investable/Non‑investable)
  • 允许扩展:仅通过“字典版本升级”增加新类;不允许自由输入新分类进入核心计算

6. 旧叫法映射(用于迁移/沟通)

以下映射仅用于把历史认知对齐到行业最佳实践主轴:

旧叫法最佳实践落位
现金类CASH
稳健类FI
进阶类EQ
核心资产Strategy Layer:Core
卫星资产Strategy Layer:Satellite