feat: initialize Xiaobai Bookkeeping 0.9

This commit is contained in:
leefer
2026-08-01 11:01:33 +08:00
commit 3ef13b5674
100 changed files with 17503 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
node_modules/
dist/
target/
.pnpm-store/
*.tsbuildinfo
vite.config.js
vite.config.d.ts
.idea/
.vscode/
*.log
*.sqlite
*.sqlite-shm
*.sqlite-wal
.env
.env.*
!.env.example
+46
View File
@@ -0,0 +1,46 @@
# 小白记账
小白记账是面向零会计基础个体工商户的本地优先 Windows AI 记账与申报协作软件。
当前版本为 `0.9.0` 本地内测版,包含:
- 产品章程、架构、设计系统和质量维护规范
- 可交互的经营首页、待确认、票据、流水、账簿和报税日历界面
- 整数金额领域模型与第一组自动化测试
- Tauri 桌面容器与 SQLite 本地账本
- UTF-8 CSV 流水导入、整批校验、文件去重和交易去重
- 经营/个人收支确认、追加式审计历史和幂等保存
- 个人收支自动排除在经营收入、经营支出与利润汇总之外
- 经营主体名称、主体类型和地区的本地建档、修改与重启恢复
- 建档字段的前后端双重校验,以及单一主体约束
- SQLite 在线备份、结构校验与 SHA-256 完整性校验
- 恢复前自动安全快照、损坏备份拒绝和失败回退
- SQLCipher 账本与备份加密,密钥由当前 Windows 用户的 DPAPI 保护
- 旧版明文账本与可用备份在首次启动时自动迁移为加密格式
- 手工记账、流水搜索与筛选,以及保留原始记录的追加式纠错
- 基于当前有效流水的确定性账簿汇总,自动排除个人收支
- Excel 可直接打开的 UTF-8 CSV 账簿导出与独立 SHA-256 校验文件
## 本地运行
```powershell
npm install
npm run dev
```
## 质量检查
```powershell
npm test
npm run build
```
Windows 发布构建需要 Rust 和 Visual C++ Build Tools。当前开发环境的 Build Tools 位于 `C:\VSBuildTools2022`,可运行 `scripts\verify.ps1` 做完整检查。
## 当前边界
首次启动时界面展示明确标记的演示数据。Windows 客户端可完成经营主体建档、手工记账、CSV 导入、确认与纠错、账簿导出、加密备份和恢复。票据识别、AI 与真实申报尚未接入,相应页面仅说明开放条件,产品不会计算或提交任何真实税务数据。
可使用 [流水导入示例.csv](samples/流水导入示例.csv) 验收导入与重复导入,使用 [待确认流水示例.csv](samples/待确认流水示例.csv) 验收确认保存。
详细约定见 `docs/`
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" rx="112" fill="#164d45"/>
<rect x="80" y="80" width="352" height="352" rx="82" fill="none" stroke="#d9c48d" stroke-width="12"/>
<path d="M150 178h212M150 256h212M150 334h212" stroke="#d9c48d" stroke-width="18" stroke-linecap="round"/>
<circle cx="256" cy="256" r="34" fill="#164d45" stroke="#f5f0e5" stroke-width="14"/>
</svg>

After

Width:  |  Height:  |  Size: 455 B

+65
View File
@@ -0,0 +1,65 @@
# 小白记账架构约定
## 架构选择
采用“模块化单体 + 本地优先 + 按需云服务”。早期保持一个可安装程序和一个清晰代码库,避免微服务带来的部署与排障成本,同时为未来拆分保留稳定接口。
## 分层
### 界面层
只负责展示、输入和用户反馈,不直接实现税务计算或修改数据库。
### 应用层
组织用户操作,例如“导入流水”“确认分类”“生成申报草稿”。应用层通过模块公开接口协作。
### 领域层
保存交易、账簿、规则、申报等核心业务规则。领域逻辑不依赖页面,也不依赖具体数据库。
### 基础设施层
负责 SQLite、文件系统、加密、网络、日志、更新和 Windows 系统能力。
## 业务模块
- `identity`:用户、经营主体、授权
- `documents`:票据和原始文件
- `transactions`:流水、去重、业务分类
- `ledger`:凭证、账簿和更正记录
- `tax-rules`:有版本的确定性税务规则
- `filing`:申报草稿、确认、提交和回执
- `review`:人工复核队列和意见
- `reporting`:经营概览与报表
- `audit`:不可静默修改的操作与依据记录
- `platform`:本地存储、加密、更新、备份和恢复
## 依赖规则
- 界面只能调用应用层。
- 应用层通过模块公开接口访问领域能力。
- 一个模块不得直接修改另一个模块的数据库表。
- AI 输出必须先经过结构校验和确定性规则校验。
- 税务计算不得调用生成式模型。
- 外部 OCR、AI 和申报通道必须经过适配层,以便更换供应商。
## 数据原则
- 金额以“分”为单位保存为整数。
- 原始文件使用内容哈希去重。
- 原始交易不可覆盖,更正通过追加版本实现。
- 计算结果记录输入版本、规则版本和程序版本。
- 数据库升级必须有迁移、校验与回退方案。
- 客户端日志默认不得记录身份证号、账号、完整票据内容或访问令牌。
## 技术边界
- 桌面容器:Tauri 2
- 界面与主要业务代码:React + TypeScript
- 本地存储:SQLite;生产版本启用加密与 Windows 安全密钥保护
- 云端接口:版本化 JSON API
- 云端数据:PostgreSQL
- 桌面底层桥接:保持少量 Rust 代码,不在其中堆积业务规则
当前仓库首先实现可运行的界面与纯领域逻辑;本地数据库、Tauri 容器和云端服务按阶段接入,不用模拟实现冒充生产能力。
+29
View File
@@ -0,0 +1,29 @@
# 本地备份与恢复模块
## 目的
在数据库加密、版本升级和更复杂功能接入前,先保证账本具备可验证、可回退的恢复能力。
## 备份流程
1. 使用 SQLite Online Backup API 获取一致性快照,不直接复制正在使用的数据库文件。
2. 对新备份执行 `PRAGMA integrity_check`
3. 检查迁移表、流水表和导入表等必要结构。
4. 计算 SHA-256,并在同目录写入独立校验文件。
5. 将数据库文件同步到磁盘后才向界面报告成功。
备份位于应用数据目录下的 `backups` 子目录。当前版本暂不提供云端上传或外部介质复制。
## 恢复保护
- 只允许恢复备份目录内、文件名合法的 `.sqlite3` 文件。
- 恢复前重新核对 SHA-256 和数据库结构;不一致立即拒绝。
- 覆盖当前账本前,自动创建 `pre-restore` 安全快照。
- 使用 SQLite Online Backup API 写回当前账本。
- 恢复后自动执行数据库迁移;若恢复或迁移失败,尝试用安全快照回退。
## 当前边界
- 本地备份与主数据库在同一块磁盘,能防误操作,不能替代异地备份。
- 备份目前仍是未加密 SQLite 文件,不应复制到不可信位置。
- 下一阶段的数据加密迁移将依赖本模块提供回退保障。
+19
View File
@@ -0,0 +1,19 @@
# 确认与审计模块
## 目的
让用户把不确定的流水判断为“经营收支”或“个人收支”,同时保证原始流水始终可查、确认过程可追溯。
## 数据原则
- `transactions` 保存导入时的原始归类与置信度,确认操作不更新该表。
- `transaction_reviews` 只追加确认记录,不覆盖历史记录。
- 同一笔流水连续提交相同判断时不新增记录,避免重复点击制造脏数据。
- 如果用户以后改变判断,则追加一条新记录;读取时以最新记录为准,旧记录仍保留。
- 个人收支仍显示在流水列表,但不进入经营收入、经营支出与利润汇总。
## 当前边界
- 确认人暂固定为本机用户 `user`,后续接入账户体系时再扩展。
- “都不是”仍是占位功能,等待更细的分类规则模块。
- 0.6 起主账本与备份已使用 SQLCipher 加密,密钥由当前 Windows 用户保护;仍应配合可靠的 Windows 登录密码和设备访问控制。
+7
View File
@@ -0,0 +1,7 @@
# 本地账本加密约定
小白记账 0.6 起使用 SQLCipher 加密主账本和新建备份。首次启动会生成 32 字节随机密钥,经过 Windows DPAPI 绑定当前登录用户后保存;应用不会保存可直接使用的明文密钥。
升级旧版本时,应用先完整导出并校验加密副本,再原子替换旧账本;失败会保留原文件。旧的有效明文备份也会自动加密并重写 SHA-256 校验文件。恢复前始终创建加密安全快照,恢复文件通过哈希、数据库完整性和必要表结构三重检查。
边界:这是静态数据保护,主要降低磁盘文件被直接复制后读取的风险。已经登录相同 Windows 用户且能够运行本应用的人,仍可能访问账本,因此不能替代 Windows 登录密码、磁盘加密和设备访问控制。
+94
View File
@@ -0,0 +1,94 @@
# 小白记账设计系统
## 设计判断
小白记账是面向 Windows 小微经营者的本地财务产品。界面首先要让用户信任数据、看懂结果并完成当前任务,不追求营销网页式的视觉表演。
- 页面类型:数据密集型桌面产品
- 目标用户:不熟悉专业会计术语的小微经营者
- 视觉语言:冷静、可信、清晰、有适度亲和力
- 设计基础:Fluent UI React 与 Fluent System Icons
- 设计参数:`DESIGN_VARIANCE 4 / MOTION_INTENSITY 3 / VISUAL_DENSITY 7`
## 不可破坏的视觉锁定
### 颜色锁定
全产品只使用青绿色作为强调色。警告色和危险色只能表达真实状态,不能作为装饰。
- 品牌深绿:`#084C45`
- 主要强调:`#0D5C52`
- 交互强调:`#0F6F61`
- 浅强调背景:`#EDF7F4`
- 页面背景:`#F4F6F8`
- 内容表面:`#FFFFFF`
- 正文:`#17212B`
- 次要文字:`#5E6A75`
- 边框:`#DFE3E8`
- 警告:`#8A6100`
- 危险:`#B42318`
禁止重新加入金色装饰、紫蓝渐变、发光描边或与品牌无关的第二强调色。
### 形状锁定
- 面板、数据组和业务卡片统一使用 `12px` 圆角。
- 按钮、输入框、图标按钮和状态标签统一使用 `8px` 或更小圆角。
- 只有数字徽标可使用完全圆形。
- 阴影只用于浮层和需要表达层级的控件,普通内容面板依靠边框与留白分组。
### 主题锁定
当前版本统一使用浅色主题。任何页面不得单独切换成深色或暖色主题。后续如果加入深色模式,必须通过全局语义变量一次性实现和测试。
## 页面骨架
- 左侧固定主导航负责产品一级入口,标签不得随意改名。
- 顶部栏负责全局搜索、演示数据提示、通知和账户入口。
- 主内容区先展示页面结果,再展示依据和可执行操作。
- 右侧辅助信息只在确有上下文价值时出现。
- 数据概览优先使用连续分区,不把每个数字都包装成独立悬浮卡片。
## 排版与数据
- 中文界面使用 `Segoe UI Variable``Segoe UI``Microsoft YaHei UI` 回退链。
- 页面标题保持 27px 左右,不使用营销页面式超大标题。
- 金额、数量和日期必须启用等宽数字特性 `tabular-nums`
- 收入和支出不能只靠颜色区分,必须同时显示正负号或文字状态。
- 表格列宽、金额右对齐和状态位置应保持稳定。
## 控件与图标
- 基础组件使用 `@fluentui/react-components`
- 图标只使用 `@fluentui/react-icons`,不得在业务组件内手绘 SVG 路径。
- 每页只突出一个主要操作,其余操作降低视觉权重。
- 按钮文字在桌面端必须保持单行。
- 所有按钮必须有清晰的键盘焦点和按下反馈。
## 动效
- 动效只用于页面切换、操作反馈和状态变化。
- 只动画化 `transform``opacity`
- 禁止滚动劫持、持续漂浮和无业务含义的循环动画。
- 必须尊重 `prefers-reduced-motion`
## 文案
- 使用“收入、进货、房租、需要确认”,不用“贷方、借方、会计科目”。
- 先说结果,再解释依据。
- 错误提示必须说明发生了什么、数据是否安全、用户下一步做什么。
- 禁止用版本号、装饰性状态点和生硬编号制造科技感。
- 日期范围使用“至”,不使用长破折号。
## 状态完整性
每个新增功能必须同时考虑:
- 正常状态
- 加载状态
- 空数据状态
- 错误状态
- 离线状态
- 无权限状态
新增页面合并前至少检查首页、当前页面、键盘焦点、窄桌面宽度和 Windows 安装包构建。
+32
View File
@@ -0,0 +1,32 @@
# 0.5 本地备份与恢复验收
请继续只使用虚构资料和样例流水。
## 1. 创建备份
1. 打开“设置与数据”。
2. 在“本地备份与恢复”区域点击“立即创建备份”。
3. 预期:提示备份已创建并通过校验;列表出现“手动账本备份”,状态为“完整性正常”。
## 2. 制造可观察变化
1. 记住当前经营主体名称。
2. 把名称改为“恢复测试后的名称”并保存。
3. 确认左上角已经显示新名称。
## 3. 恢复
1. 回到备份列表,点击刚才备份旁的“恢复这份备份”。
2. 预期:先出现二次确认,并明确说明当前账本会自动备份。
3. 点击“确认恢复”。
4. 预期:提示恢复成功;经营主体名称回到第 1 步备份时的名称。
5. 备份列表中新增一份“恢复前安全快照”。
## 4. 重启验证
1. 完全退出并重新打开软件。
2. 预期:恢复后的主体、流水和确认记录保持不变,备份列表仍然存在。
## 验收反馈
请记录:通过/不通过、问题步骤、提示文字和截图。不要手工修改或删除应用数据目录中的文件。
+48
View File
@@ -0,0 +1,48 @@
# 小白记账 0.1 首轮验收清单
本轮只验收产品方向、信息结构和视觉语言。页面数据均为演示数据,不验收真实记账与税务计算。
## 启动后先看
- 软件是否像一个可信的财务工具,而不是宣传网页。
- 文字、金额和状态是否清晰,不需要放大才能阅读。
- 左侧导航是否容易理解。
- 页面信息是否充实但不显得拥挤。
- “演示数据”和“演示规则”是否足够明显,避免误认为是真实结果。
## 经营首页
- 能否立即看懂本月收入、支出、利润和税额状态。
- 是否能发现“需要你确认”的事项。
- 是否理解免税额度进度只是当前演示。
- 最近收支表是否容易扫读。
## 待我确认
- 问题是否使用业务语言,而不是会计术语。
- 是否能理解“经营支出”“个人支出”的区别。
- 是否理解确认不会覆盖原始流水。
## 票据与流水
- 是否能区分原始票据、识别结果和待确认状态。
- 是否清楚数据来自微信、支付宝、银行还是发票。
- 金额的收入和支出方向是否容易辨认。
## 账簿与报税日历
- 是否能看懂账簿分类,而不需要理解会计科目。
- 是否清楚当前报税页面不会提交真实数据。
- 是否能理解申报草稿、人工复核、用户确认和回执归档的顺序。
## 反馈格式
不需要描述技术原因,可以直接记录:
```text
页面:经营首页
操作:打开软件后查看本月数据
问题:我第一眼不知道哪个数字最重要
期望:利润应该更明显
严重程度:无法继续 / 很困惑 / 有点别扭 / 只是建议
```
+37
View File
@@ -0,0 +1,37 @@
# 0.4 经营主体建档验收
请只填写虚构的测试资料,不要填写真实税号、身份证号或银行卡信息。
## 1. 创建经营主体
1. 安装并打开小白记账 0.4。
2. 预期:安装向导从欢迎、安装位置到完成均为简体中文,不出现英文步骤。
3. 点击左侧顶部“尚未创建经营主体”,或左下角“设置与数据”。
4. 填写名称“验收小食店”,选择“个体工商户”,地区填写“浙江省杭州市”。
5. 点击“完成建档”。
6. 预期:出现“经营主体已保存到本机”;左侧顶部显示“验收小食店 / 个体工商户”,名称前没有“禾”字图标且不会换行。
## 2. 输入校验
1. 把名称改成单个字“店”,尝试保存。
2. 预期:页面提示名称应为 2 到 60 个字符,原资料没有被覆盖。
3. 再填写一个 12 字以上的测试公司名并保存。
4. 预期:左上角名称仍保持单行,字号会自动缩小;特别长时以省略号结束,鼠标停留可查看全名。
## 3. 修改与重启恢复
1. 名称改为“验收早餐店”,主体类型改为“小微企业”,保存。
2. 完全退出软件并重新打开。
3. 预期:左侧仍显示“验收早餐店 / 小微企业”;进入设置后,地区仍为“浙江省杭州市”。
## 4. 旧数据兼容
如果电脑上已经安装过 0.3 并导入过样例流水:
1. 升级到 0.4 后完成建档。
2. 打开“收支流水”和“待我确认”。
3. 预期:原有流水和确认结果仍然存在,建档不会清空旧数据。
## 验收反馈
请记录:通过/不通过、问题出现在哪一步、界面提示文字和截图。
+23
View File
@@ -0,0 +1,23 @@
# 经营主体模块
## 目的
为后续流水、账簿、税务规则和申报草稿提供明确的数据归属,消除界面中的硬编码店铺资料。
## 当前模型
首版只允许一个经营主体,固定使用稳定标识 `primary`
- 经营主体名称:2 到 60 个字符。
- 主体类型:个体工商户或小微企业。
- 所在地区:2 到 40 个字符,后续用于匹配试点规则。
- 创建时间和更新时间:由数据库维护。
保存使用 SQLite 单事务和单行更新。重复保存不会创建第二个主体,非法字段不会部分写入。
## 数据边界
- 当前不收集身份证号、完整税号、银行卡号、登录密码。
- 修改主体资料不会删除或改写既有流水。
- 当前数据库尚未加密,只建议使用测试资料。
- 多经营主体和主体切换不属于当前 MVP,未来如需加入,必须先完成流水归属迁移设计。
+24
View File
@@ -0,0 +1,24 @@
# 人工维护指南
## 日常修改从哪里开始
- 界面与交互:`src/App.tsx`,视觉令牌与布局:`src/styles.css`
- 金额汇总规则:`src/domain/transactions.ts`,修改时先补对应测试。
- 本地数据库、命令、迁移、备份和导出:`src-tauri/src/lib.rs`
- 产品边界和架构决定:`docs/`,发布前同步更新版本说明。
## 不破坏旧数据的规则
1. 不直接改写已经发布的迁移;新增表或字段使用新的迁移版本。
2. 原始流水、确认和纠错分开保存;展示层读取最新有效值。
3. 涉及数据库格式时,必须测试旧库升级、失败回退、备份与恢复。
4. 金额始终用整数分保存和计算,禁止浮点数入库。
5. 外部 OCR、AI、税务规则和申报通道必须放在适配层,核心账本不依赖供应商格式。
## 每次发布的固定检查
运行 `scripts/verify.ps1`,确认前端测试、生产构建和 Rust 测试通过;随后人工检查建档、记一笔、CSV 重复导入、确认、修改、搜索、账簿导出、备份和恢复。安装升级测试不得删除应用数据目录。
## 故障定位顺序
先记录用户看到的中文错误和触发步骤,再判断属于界面、命令、数据库迁移还是文件系统。修复后必须增加能复现问题的自动测试。不要让用户手工编辑数据库或密钥文件。
+54
View File
@@ -0,0 +1,54 @@
# 小白记账产品章程
## 产品使命
让没有会计基础的个体工商户,用听得懂的语言完成经营数据归集、记账和申报准备。
## 第一阶段产品形态
小白记账首版是本地优先的 Windows 桌面软件。经营数据默认保存在用户电脑;只有用户明确触发 AI 识别、云端备份或申报协作时,才上传完成任务所必需的数据。
## MVP 用户
- 没有专职会计的个体工商户
- 经营数据以收款流水、发票和少量现金记录为主
- 首期先服务一个试点地区,不承诺全国规则立即通用
- 首期生成申报草稿并进入人工复核,不承诺无人值守自动申报
## MVP 闭环
1. 创建经营主体
2. 导入票据和流水
3. 自动去重、识别和业务分类
4. 用户只处理低置信度待确认项
5. 生成可追溯账簿与经营概览
6. 使用有版本的确定性规则进行税额试算
7. 生成申报草稿并进入人工复核
8. 保存确认记录、计算依据和最终回执
## 首版不做
- 全国所有地区的自动申报
- 发票开具
- 多门店、进销存和工资管理
- AI 自动决定税率或直接计算税额
- 未经用户确认上传完整账簿
- 用免责声明代替持牌机构合作与合规审查
## 产品原则
- 自动化是默认路径,人工是例外路径。
- 只向用户询问业务问题,不询问会计科目。
- 原始数据不可被静默覆盖,所有更正均保留版本。
- 任何金额都必须能够追溯到数据来源、规则版本和确认记录。
- 新功能必须属于明确模块,并可以被单独关闭。
- 正确性、可恢复性和可解释性优先于功能数量。
## 第一阶段成功标准
- 用户可在 10 分钟内完成主体创建和首次数据导入。
- 重复导入不会产生重复账目。
- 关键金额计算使用整数最小货币单位,避免浮点误差。
- 程序异常或更新失败不会损坏已有账本。
- 主要页面无需会计知识即可理解。
- 从原始票据到账簿记录形成完整依据链。
+43
View File
@@ -0,0 +1,43 @@
# 质量与维护规则
## 发布门禁
任何正式版本必须满足:
- 类型检查通过
- 自动化测试通过
- 核心流程人工验收通过
- 数据库迁移和回退演练通过
- 安装、升级、卸载不损坏用户数据
- 没有未处理的严重级别错误
- 版本说明列出新增、修复、已知限制和恢复方式
## 测试层级
- 单元测试:金额、日期、去重、分类、规则边界
- 集成测试:文件导入、数据库迁移、模块接口
- 流程测试:建档到申报草稿的完整闭环
- 视觉测试:核心页面固定尺寸截图对比
- 恢复测试:断网、崩溃、升级失败、备份损坏
- 安全测试:权限、敏感日志、恶意文件、依赖漏洞
## 功能进入流程
需求说明 → 模块归属 → 影响分析 → 验收案例 → 开发 → 自动测试 → 功能开关 → 小范围启用 → 正式发布。
没有模块归属、没有验收案例或不能单独关闭的重要功能,不进入开发。
## 可维护性
- 每个模块包含用途、公开接口、数据结构和故障处理说明。
- 关键架构决定记录原因,而不只记录结论。
- 税务政策、提示文案和非核心分类规则优先配置化。
- 所有后台任务有状态、失败原因和安全重试入口。
- 客服通过用户主动生成的脱敏诊断包排查问题。
- 规则变更实行版本管理和双人审核。
- 每季度执行一次备份恢复演练和依赖升级评估。
## 用户验收方式
每次交付提供安装包、演示数据、测试清单、变化说明和已知限制。用户只需按业务语言反馈现象,技术侧负责定位原因、修复并补充防复发测试。
+29
View File
@@ -0,0 +1,29 @@
# 小白记账 0.9 内测说明
## 已完成
- SQLCipher 加密主账本、加密备份、旧明文数据迁移和失败回退。
- 手工记账、CSV 导入去重、经营/个人确认、搜索筛选和追加式纠错。
- 经营账簿按当前有效流水确定性汇总,个人收支自动排除。
- CSV 明细与合计导出、Excel 兼容 BOM、SHA-256 校验文件和公式注入防护。
- 1080×700 最小窗口到 1440×900 默认窗口的界面验收。
## 明确未启用
- 票据文件上传与 OCR 识别。
- AI 自动分类或税务判断。
- 税额计算、申报草稿和真实提交。
- 云端同步、多设备协作和忘记密钥后的云恢复。
这些入口在 0.9 中只展示开放条件,不伪造真实业务结果。
账本导出是用户主动生成、供 Excel 打开的明文 CSV,不受账本密钥保护;界面会显示保存位置和校验码,应按敏感财务文件妥善保管。
## 恢复方式
升级前建议在“设置与数据”创建备份。恢复操作会先自动创建当前账本快照;校验码不一致、结构损坏或无法解密的备份会被拒绝。
## 安装包
- 文件:`小白记账_0.9.0_x64-setup.exe`
- SHA-256`93CB4507F90E94E2EC08FE26AECFC3D3E6A1FBD19FEB67938FA2BC9DDA06F23F`
+64
View File
@@ -0,0 +1,64 @@
# 小白记账 0.2 流水导入验收
本轮只验收 CSV 流水导入、本地保存和重复导入。票据识别、确认结果保存与税务计算仍然是后续功能。
## 准备
1. 安装或直接运行小白记账 0.2。
2. 打开“收支流水”。
3. 准备仓库 `samples` 文件夹中的 `流水导入示例.csv`
## 第一次导入
1. 点击“导入 CSV”。
2. 选择 `流水导入示例.csv`
3. 应看到“成功新增 4 笔流水”。
4. 页面应显示 2 笔收入和 2 笔支出。
5. 经营首页应显示“本地流水”,收入、支出和利润改为样例文件汇总结果。
预期金额:
- 收入:¥2,140.50
- 支出:¥763.60
- 利润:¥1,376.90
## 重复导入
1. 再次导入同一个文件。
2. 应看到“这份文件已经导入过”。
3. 流水总数仍为 4,不得变成 8。
## 重启验证
1. 关闭小白记账。
2. 重新打开。
3. 顶部仍应显示“本地流水”。
4. 收支流水仍为 4 笔,金额不变。
## 错误文件验证
可以复制样例文件,把其中一个金额改成 `12.345` 后导入。
预期:
- 明确提示金额最多两位小数。
- 文件中的其他正确行也不得写入。
- 原来已经保存的 4 笔流水不受影响。
## 已知限制
- 只支持 UTF-8 CSV。
- CSV 表头仍需要使用规定名称,没有字段映射向导。
- 导入前还没有预览页面。
- 待确认按钮暂不写入数据库。
- 本文是 0.2 阶段的历史验收记录;其中“尚未加密”的限制已由 0.6 的 SQLCipher 与 Windows 密钥保护取代。
## 反馈格式
```text
页面:收支流水
操作:第二次导入同一个 CSV
问题:流水从 4 笔变成了 8 笔
期望:重复文件不增加流水
严重程度:无法继续 / 很困惑 / 有点别扭 / 只是建议
```
+7
View File
@@ -0,0 +1,7 @@
# 0.6 验收记录
- 主账本文件不再出现 SQLite 明文文件头,错误密钥无法读取。
- 密钥使用安全随机数生成,并由当前 Windows 用户的 DPAPI 保护。
- 加密备份可创建、校验、恢复,恢复失败具备回退路径。
- 有效的旧明文账本与备份可自动迁移,迁移后校验码同步更新。
- 前端单元测试、生产构建及 16 项 Rust 回归测试通过。
+31
View File
@@ -0,0 +1,31 @@
# 0.3 待确认与审计验收
请在 Windows 安装版中按顺序测试。测试时只使用仓库 `samples` 目录中的样例数据。
## 1. 导入待确认数据
1. 打开“收支流水”。
2. 导入 `samples/待确认流水示例.csv`
3. 预期:提示新增 2 笔;左侧“待我确认”显示 2。
## 2. 保存经营判断
1. 打开“待我确认”。
2. 找到“测试顾客”200 元收入,点击“是,经营收入”。
3. 预期:该卡片消失,待确认数量减 1;经营收入包含这 200 元。
## 3. 保存个人判断
1. 将“测试供应商”100 元支出标记为“不是,个人收支”。
2. 预期:待确认数量变为 0;经营支出和利润都不包含这 100 元。
3. 打开“收支流水”,预期该记录仍存在,归类为“个人收支”,状态为“个人”。
## 4. 重启与重复操作
1. 完全退出并重新打开小白记账。
2. 预期:两项确认仍然存在,待确认数量仍为 0,经营汇总未变化。
3. 当前界面不会再次展示已确认卡片;数据库自动化测试会额外验证重复提交同一判断不会新增重复审计记录。
## 验收反馈
请记录:通过/不通过、出现问题的步骤、界面提示文字和截图。不要导入真实账单。
+63
View File
@@ -0,0 +1,63 @@
# CSV 流水导入模块
## 模块归属
- 业务模块:`transactions`
- 应用入口:收支流水页面
- 基础设施:Tauri 命令、SQLite、Windows 用户应用数据目录
## 当前公开接口
### `import_transactions_csv`
输入文件名与 UTF-8 CSV 文本,在单个 SQLite 事务中完成校验、文件登记和交易写入。
返回:
- 文件总行数
- 新增交易数
- 重复交易数
- 文件 SHA-256 指纹
### `list_local_transactions`
读取本机已经保存的交易,按交易时间倒序返回界面层需要的只读结构。
## CSV 约定
必填列:
- `交易时间`
- `对方`
- `收支类型`,值为“收入”或“支出”
- `金额`,最多两位小数
可选列:
- `来源`
- `分类`
- `交易单号`
同时接受文档中定义的英文列名。首版只支持 UTF-8 编码,不自动猜测 GBK。
## 去重规则
1. 文件内容 SHA-256 相同,整份文件视为已导入。
2. 有交易单号时,以“来源 + 交易单号”生成交易指纹。
3. 没有交易单号时,以“时间 + 对方 + 收支方向 + 整数分金额 + 来源”生成交易指纹。
4. 不覆盖已有交易,重复记录使用数据库唯一约束跳过。
## 一致性与故障处理
- 所有金额在 Rust 中转换为整数分,不经过浮点数。
- 任何一行不合法时,整份 CSV 不写入数据库。
- SQLite 使用 WAL 日志模式。
- 数据库位于 Windows 用户应用数据目录,安装和升级不覆盖。
- 当前迁移版本为 1,并记录在 `schema_migrations` 表。
## 已知限制
- 还没有字段映射向导。
- 还没有 GBK 文件转码。
- 还没有导入预览和逐行忽略。
- 待确认结果尚未持久化,本轮只保存原始流水与初始分类。
+25
View File
@@ -0,0 +1,25 @@
# 决策 0001Windows 本地优先基础
- 状态:已采用
- 日期:2026-07-31
## 背景
目标用户更熟悉 Windows 财务软件,并且对票据、流水和账簿保存在本机有更高的心理信任。产品后期仍需要 AI、规则更新、备份和申报协作等联网能力。
## 决策
使用 Tauri 2 作为 Windows 桌面容器,React 与 TypeScript 实现界面和主要业务逻辑。完整账簿默认本地保存,云端能力通过明确授权按需调用。
## 约束
- 不把远程网页直接包装成桌面软件。
- 桌面容器只加载随安装包发布的本地界面资源。
- Rust 层保持小而稳定,只负责系统级安全能力。
- 云端不可用时,本地查看和整理能力仍应工作。
- 移动端未来作为拍照与提醒助手,不取代 Windows 主工作台。
## 后果
优势是数据边界清晰、安装包较轻、界面代码可复用。代价是项目同时包含 TypeScript 与少量 Rust,需要持续维护桌面构建工具链。
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#164d45" />
<title>小白记账</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4274
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "xiaobai-bookkeeping-desktop",
"private": true,
"version": "0.9.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"tauri": "tauri",
"desktop:dev": "tauri dev",
"desktop:build": "tauri build",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@fluentui/react-components": "^9.74.4",
"@fluentui/react-icons": "^2.0.334",
"@tauri-apps/api": "^2.11.1",
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.4",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^4.7.0",
"typescript": "^5.9.2",
"vite": "^7.1.3",
"vitest": "^3.2.4"
}
}
+3
View File
@@ -0,0 +1,3 @@
交易时间,对方,收支类型,金额,来源,交易单号
2026-08-01 09:00,测试供应商,支出,100.00,测试银行,REVIEW-001
2026-08-01 10:00,测试顾客,收入,200.00,测试银行,REVIEW-002
1 交易时间 对方 收支类型 金额 来源 交易单号
2 2026-08-01 09:00 测试供应商 支出 100.00 测试银行 REVIEW-001
3 2026-08-01 10:00 测试顾客 收入 200.00 测试银行 REVIEW-002
+2
View File
@@ -0,0 +1,2 @@
交易时间,对方,收支类型,金额,来源,分类,交易单号
2026-7-30 18:41,支付宝商家收款,收入,860.5,支付宝,销售收入,ALI-20260730-002
1 交易时间 对方 收支类型 金额 来源 分类 交易单号
2 2026-7-30 18:41 支付宝商家收款 收入 860.5 支付宝 销售收入 ALI-20260730-002
+32
View File
@@ -0,0 +1,32 @@
$ErrorActionPreference = "Stop"
Write-Host "[1/3] Running domain tests"
npm test
Write-Host "[2/3] Building production interface"
npm run build
Write-Host "[3/3] Testing desktop project"
$cargoBin = Join-Path $env:USERPROFILE ".cargo\bin"
$cargo = Join-Path $cargoBin "cargo.exe"
$vcVarsCandidates = @(
"C:\VSBuildTools2022\VC\Auxiliary\Build\vcvars64.bat",
"C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvars64.bat"
)
$vcVars = $vcVarsCandidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
if ((Test-Path -LiteralPath $cargo) -and $vcVars) {
$asciiTarget = Join-Path $env:USERPROFILE ".cargo\xiaobai-target"
$perlBin = Join-Path $env:USERPROFILE ".cargo\strawberry-perl\perl\bin"
$extraPath = if (Test-Path -LiteralPath $perlBin) { ";$perlBin" } else { "" }
$desktopCheck = "call `"$vcVars`" && set `"PATH=$cargoBin;%PATH%$extraPath`" && set `"CARGO_TARGET_DIR=$asciiTarget`" && cargo test --offline --manifest-path src-tauri\Cargo.toml --lib"
& cmd.exe /d /s /c $desktopCheck
if ($LASTEXITCODE -ne 0) {
throw "Desktop tests failed with exit code $LASTEXITCODE"
}
}
else {
Write-Warning "Rust or Visual C++ Build Tools not found; skipped desktop compile check."
}
Write-Host "Xiaobai Bookkeeping verification completed."
+4542
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "xiaobai-bookkeeping"
version = "0.9.0"
description = "Xiaobai Bookkeeping local-first desktop application"
authors = ["Xiaobai Bookkeeping Product Team"]
edition = "2021"
[lib]
name = "xiaobai_bookkeeping_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
csv = "1.3"
getrandom = "0.2"
rusqlite = { version = "0.32", features = ["backup", "bundled-sqlcipher-vendored-openssl"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
tauri = { version = "2", features = [] }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security_Cryptography"] }
+4
View File
@@ -0,0 +1,4 @@
fn main() {
tauri_build::build()
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 925 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 770 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 647 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 851 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

+1795
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
xiaobai_bookkeeping_lib::run();
}
+51
View File
@@ -0,0 +1,51 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "小白记账",
"version": "0.9.0",
"identifier": "cn.xiaobaijizhang.desktop",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "main",
"title": "小白记账",
"width": 1440,
"height": 900,
"minWidth": 1080,
"minHeight": 700,
"center": true,
"resizable": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": ["nsis"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.ico"
],
"category": "Finance",
"shortDescription": "面向个体工商户的本地优先 AI 记账与申报协作软件",
"longDescription": "小白记账帮助没有会计基础的个体工商户归集经营数据、理解收支、整理账簿并准备申报材料。",
"windows": {
"wix": {
"language": "zh-CN"
},
"nsis": {
"languages": ["SimpChinese"],
"displayLanguageSelector": false
}
}
}
}
+660
View File
@@ -0,0 +1,660 @@
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type FormEvent, type ReactNode } from "react";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import {
Add24Regular,
Alert24Regular,
ArrowRight24Regular,
ArrowSwap24Regular,
BookOpen24Regular,
CalendarLtr24Regular,
CheckmarkCircle24Regular,
ChevronRight24Regular,
Document24Regular,
Home24Regular,
Search24Regular,
Settings24Regular,
ShieldCheckmark24Regular,
} from "@fluentui/react-icons";
import { invoke } from "@tauri-apps/api/core";
import { demoTransactions, monthSummary } from "./data/demo";
import { formatCurrency, summarizeTransactions, type Transaction } from "./domain/transactions";
type Page =
| "dashboard"
| "confirm"
| "documents"
| "transactions"
| "ledger"
| "tax"
| "settings";
type IconName =
| "home"
| "check"
| "file"
| "flow"
| "book"
| "calendar"
| "bell"
| "search"
| "plus"
| "arrow"
| "shield"
| "settings"
| "chevron";
const navItems: Array<{ id: Page; label: string; icon: IconName; badge?: number }> = [
{ id: "dashboard", label: "经营首页", icon: "home" },
{ id: "confirm", label: "待我确认", icon: "check", badge: 3 },
{ id: "documents", label: "票据中心", icon: "file" },
{ id: "transactions", label: "收支流水", icon: "flow" },
{ id: "ledger", label: "账簿", icon: "book" },
{ id: "tax", label: "报税日历", icon: "calendar" },
];
interface ImportResult {
importedCount: number;
duplicateCount: number;
totalCount: number;
fileHash: string;
}
interface ReviewResult {
transactionId: string;
decision: "business" | "personal";
changed: boolean;
}
interface BusinessProfile {
id: string;
name: string;
entityType: "sole_proprietor" | "small_business";
region: string;
createdAt: string;
updatedAt: string;
}
interface BusinessProfileInput {
name: string;
entityType: BusinessProfile["entityType"];
region: string;
}
interface BackupInfo {
fileName: string;
sizeBytes: number;
createdAt: string;
checksum: string;
isValid: boolean;
}
interface TransactionInput {
occurredOn: string;
counterparty: string;
category: string;
kind: "income" | "expense";
amount: string;
}
interface TransactionCorrectionInput extends TransactionInput {
transactionId: string;
reason: string;
}
interface ExportInfo {
fileName: string;
filePath: string;
rowCount: number;
checksum: string;
}
function entityTypeLabel(entityType: BusinessProfile["entityType"]) {
return entityType === "sole_proprietor" ? "个体工商户" : "小微企业";
}
function businessNameSizeClass(name: string) {
const length = Array.from(name).length;
if (length <= 8) return "name-short";
if (length <= 12) return "name-medium";
if (length <= 16) return "name-long";
return "name-extra-long";
}
function isTauriRuntime() {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
const icons: Record<IconName, typeof Home24Regular> = {
home: Home24Regular,
check: CheckmarkCircle24Regular,
file: Document24Regular,
flow: ArrowSwap24Regular,
book: BookOpen24Regular,
calendar: CalendarLtr24Regular,
bell: Alert24Regular,
search: Search24Regular,
plus: Add24Regular,
arrow: ArrowRight24Regular,
shield: ShieldCheckmark24Regular,
settings: Settings24Regular,
chevron: ChevronRight24Regular,
};
function Icon({ name, size = 18 }: { name: IconName; size?: number }) {
const Glyph = icons[name];
return <Glyph aria-hidden="true" className="icon" fontSize={size} />;
}
function App() {
const [page, setPage] = useState<Page>("dashboard");
const [toast, setToast] = useState<string | null>(null);
const [transactions, setTransactions] = useState<Transaction[]>(demoTransactions);
const [usingLocalData, setUsingLocalData] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [reviewingId, setReviewingId] = useState<string | null>(null);
const [businessProfile, setBusinessProfile] = useState<BusinessProfile | null>(null);
const [isSavingProfile, setIsSavingProfile] = useState(false);
const [backups, setBackups] = useState<BackupInfo[]>([]);
const [backupBusy, setBackupBusy] = useState<"create" | "restore" | null>(null);
const [importResult, setImportResult] = useState<ImportResult | null>(null);
const pending = useMemo(
() => transactions.filter((transaction) => transaction.confidence < 0.98),
[transactions],
);
const activeSummary = useMemo(() => {
if (!usingLocalData) return monthSummary;
return { ...summarizeTransactions(transactions), estimatedTaxInCents: 0 };
}, [transactions, usingLocalData]);
const displayedBusinessName = businessProfile?.name ?? (isTauriRuntime() ? "尚未创建经营主体" : "禾木小食店");
const notify = (message: string) => {
setToast(message);
window.setTimeout(() => setToast(null), 2600);
};
const loadLocalTransactions = async () => {
const stored = await invoke<Transaction[]>("list_local_transactions");
if (stored.length > 0) {
setTransactions(stored);
setUsingLocalData(true);
} else {
setTransactions(demoTransactions);
setUsingLocalData(false);
}
return stored;
};
const loadBusinessProfile = async () => {
const stored = await invoke<BusinessProfile | null>("get_business_profile");
setBusinessProfile(stored);
return stored;
};
const loadLocalBackups = async () => {
const stored = await invoke<BackupInfo[]>("list_local_backups");
setBackups(stored);
return stored;
};
useEffect(() => {
if (!isTauriRuntime()) return;
loadLocalTransactions().catch((error) => {
setToast(`本地账本读取失败:${String(error)}`);
});
loadBusinessProfile().catch((error) => {
setToast(`经营主体读取失败:${String(error)}`);
});
loadLocalBackups().catch((error) => {
setToast(`备份列表读取失败:${String(error)}`);
});
}, []);
useEffect(() => {
const handleShortcut = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
setPage("transactions");
}
};
window.addEventListener("keydown", handleShortcut);
return () => window.removeEventListener("keydown", handleShortcut);
}, []);
const importCsv = async (file: File) => {
if (!isTauriRuntime()) {
notify("CSV 导入请在 Windows 客户端中使用");
return;
}
setIsImporting(true);
try {
const result = await invoke<ImportResult>("import_transactions_csv", {
csvText: await file.text(),
fileName: file.name,
});
setImportResult(result);
const stored = await loadLocalTransactions();
setPage("transactions");
notify(
result.importedCount > 0
? `已导入 ${result.importedCount} 笔,跳过 ${result.duplicateCount} 笔重复流水`
: `没有新增流水,${result.duplicateCount} 笔均已存在`,
);
if (stored.length === 0) setUsingLocalData(false);
} catch (error) {
notify(`导入失败:${String(error)}`);
} finally {
setIsImporting(false);
}
};
const confirmTransaction = async (
transactionId: string,
decision: "business" | "personal",
) => {
if (!isTauriRuntime() || !usingLocalData) {
notify("确认保存请在导入本地流水后的 Windows 客户端中使用");
return;
}
setReviewingId(transactionId);
try {
const result = await invoke<ReviewResult>("confirm_transaction", {
transactionId,
decision,
});
await loadLocalTransactions();
notify(
result.changed
? decision === "business"
? "已保存为经营收支,并留下确认记录"
: "已保存为个人收支,不再计入经营汇总"
: "这项确认已经保存,无需重复处理",
);
} catch (error) {
notify(`确认保存失败:${String(error)}`);
} finally {
setReviewingId(null);
}
};
const saveBusinessProfile = async (input: BusinessProfileInput) => {
if (!isTauriRuntime()) {
notify("经营主体保存请在 Windows 客户端中使用");
return;
}
setIsSavingProfile(true);
try {
const stored = await invoke<BusinessProfile>("save_business_profile", { input });
setBusinessProfile(stored);
notify("经营主体已保存到本机");
} catch (error) {
notify(`建档保存失败:${String(error)}`);
throw error;
} finally {
setIsSavingProfile(false);
}
};
const createLocalBackup = async () => {
if (!isTauriRuntime()) {
notify("本地备份请在 Windows 客户端中使用");
return;
}
setBackupBusy("create");
try {
await invoke<BackupInfo>("create_local_backup");
await loadLocalBackups();
notify("本地账本备份已创建并通过完整性校验");
} catch (error) {
notify(`创建备份失败:${String(error)}`);
} finally {
setBackupBusy(null);
}
};
const restoreLocalBackup = async (fileName: string) => {
if (!isTauriRuntime()) {
notify("备份恢复请在 Windows 客户端中使用");
return;
}
setBackupBusy("restore");
try {
await invoke<BackupInfo>("restore_local_backup", { fileName });
await Promise.all([loadLocalTransactions(), loadBusinessProfile(), loadLocalBackups()]);
notify("备份恢复成功;恢复前的账本也已自动保存");
} catch (error) {
notify(`恢复失败:${String(error)}`);
} finally {
setBackupBusy(null);
}
};
const createManualTransaction = async (input: TransactionInput) => {
if (!isTauriRuntime()) throw new Error("手工记账请在 Windows 客户端中使用");
try {
await invoke<string>("create_manual_transaction", { input });
await loadLocalTransactions();
notify("流水已保存到本地加密账本");
} catch (error) {
notify(`手工记账失败:${String(error)}`);
throw error;
}
};
const correctStoredTransaction = async (input: TransactionCorrectionInput) => {
if (!isTauriRuntime() || !usingLocalData) throw new Error("请先使用本地账本");
try {
await invoke<void>("correct_transaction", { input });
await loadLocalTransactions();
notify("修改已保存,原始流水和修改原因均已保留");
} catch (error) {
notify(`修改失败:${String(error)}`);
throw error;
}
};
return (
<FluentProvider theme={webLightTheme} className="app-provider">
<div className="app-shell">
<aside className="sidebar">
<div className="brand">
<div className="brand-mark" aria-hidden="true"></div>
<div><strong></strong><span>XIAOBAI BOOKS</span></div>
</div>
<button className="business-switcher" onClick={() => setPage("settings")}>
<span className="business-details"><strong className={businessNameSizeClass(displayedBusinessName)} title={displayedBusinessName}>{displayedBusinessName}</strong><small>{businessProfile ? entityTypeLabel(businessProfile.entityType) : isTauriRuntime() ? "点击完成建档" : "个体工商户"}</small></span>
<Icon name="chevron" size={15} />
</button>
<nav className="primary-nav" aria-label="主导航">
<p className="nav-label"></p>
{navItems.slice(0, 4).map((item) => {
const badge = item.id === "confirm" ? pending.length : item.badge;
return <button key={item.id} className={page === item.id ? "active" : ""} onClick={() => setPage(item.id)}>
<Icon name={item.icon} /><span>{item.label}</span>
{badge ? <b>{badge}</b> : null}
</button>;
})}
<p className="nav-label"></p>
{navItems.slice(4).map((item) => (
<button key={item.id} className={page === item.id ? "active" : ""} onClick={() => setPage(item.id)}>
<Icon name={item.icon} /><span>{item.label}</span>
</button>
))}
</nav>
<div className="sidebar-foot">
<div className="local-status"><Icon name="shield" size={17}/><span><strong></strong><small>Windows </small></span></div>
<button className={page === "settings" ? "active" : ""} onClick={() => setPage("settings")}><Icon name="settings"/><span></span></button>
</div>
</aside>
<main className="main-area">
<header className="topbar">
<button className="search" onClick={() => setPage("transactions")} aria-label="前往搜索收支流水"><Icon name="search" size={17}/><span></span><kbd>Ctrl K</kbd></button>
<div className="top-actions">
<span className={`demo-badge ${usingLocalData ? "local" : ""}`}>{usingLocalData ? "本地流水" : "演示数据"}</span>
<button className="icon-button" aria-label="通知" onClick={() => notify("目前没有新的系统通知")}><Icon name="bell"/></button>
<button className="avatar" aria-label="当前经营主体" onClick={() => setPage("settings")}>{Array.from(displayedBusinessName)[0] ?? "白"}</button>
</div>
</header>
{page === "dashboard" ? <Dashboard transactions={transactions} summary={activeSummary} isLocal={usingLocalData} onNavigate={setPage} onNotify={notify} /> : null}
{page === "confirm" ? <ConfirmPage pending={pending} isLocal={usingLocalData} reviewingId={reviewingId} onConfirm={confirmTransaction} onNotify={notify} /> : null}
{page === "documents" ? <DocumentsPage onNotify={notify} /> : null}
{page === "transactions" ? <TransactionsPage transactions={transactions} isLocal={usingLocalData} isImporting={isImporting} importResult={importResult} onImport={importCsv} onCreate={createManualTransaction} onCorrect={correctStoredTransaction} onNotify={notify} /> : null}
{page === "ledger" ? <LedgerPage transactions={transactions} isLocal={usingLocalData} onNotify={notify} /> : null}
{page === "tax" ? <TaxPage onNotify={notify} /> : null}
{page === "settings" ? <SettingsPage profile={businessProfile} isSaving={isSavingProfile} backups={backups} backupBusy={backupBusy} onSave={saveBusinessProfile} onCreateBackup={createLocalBackup} onRestoreBackup={restoreLocalBackup} /> : null}
</main>
{toast ? <div className="toast" role="status">{toast}</div> : null}
</div>
</FluentProvider>
);
}
function PageIntro({ eyebrow, title, description, action }: { eyebrow: string; title: string; description: string; action?: ReactNode }) {
return <div className="page-intro"><div><span className="page-context">{eyebrow}</span><h1>{title}</h1><p>{description}</p></div>{action}</div>;
}
function Dashboard({ transactions, summary, isLocal, onNavigate, onNotify }: {
transactions: Transaction[];
summary: { incomeInCents: number; expenseInCents: number; profitInCents: number; estimatedTaxInCents: number };
isLocal: boolean;
onNavigate: (page: Page) => void;
onNotify: (message: string) => void;
}) {
const pending = transactions.filter((transaction) => transaction.confidence < 0.98);
const businessTransactions = transactions.filter((transaction) => transaction.reviewDecision !== "personal");
const incomeCount = businessTransactions.filter((transaction) => transaction.kind === "income").length;
const expenseCount = businessTransactions.filter((transaction) => transaction.kind === "expense").length;
return <div className="page dashboard-page">
<PageIntro eyebrow={isLocal ? "本地账本" : "2026 年 7 月"} title="账本已经准备好" description={isLocal ? `本机已保存 ${transactions.length} 笔流水,低置信度项目会进入待确认。` : "当前展示示例经营数据,开始记账后会自动切换为你的本地账本。"} action={<button className="primary-button" onClick={() => onNavigate("transactions")}><Icon name="plus" size={17}/></button>} />
<section className="summary-grid" aria-label="本月经营概览">
<article className="summary-card featured"><span></span><strong>{formatCurrency(summary.profitInCents)}</strong><small>{isLocal ? <><i>{transactions.length} </i> </> : <><i> 8.6%</i> ¥1,526.40</>}</small></article>
<article className="summary-card"><span></span><strong>{formatCurrency(summary.incomeInCents)}</strong><small> {isLocal ? incomeCount : 286} </small></article>
<article className="summary-card"><span></span><strong>{formatCurrency(summary.expenseInCents)}</strong><small>{isLocal ? `共归集 ${expenseCount} 笔支出` : "进货成本占支出 62%"}</small></article>
<article className="summary-card tax-card"><span></span><strong>{formatCurrency(summary.estimatedTaxInCents)}</strong><small><em>使</em></small></article>
</section>
<div className="dashboard-columns">
<section className="panel activity-panel">
<div className="panel-heading"><div><h2></h2><p></p></div><button className="text-button" onClick={() => onNavigate("transactions")}> <Icon name="arrow" size={15}/></button></div>
<TransactionTable transactions={transactions} compact />
</section>
<aside className="right-rail">
<section className="panel action-panel">
<div className="action-panel-title"><span className="attention-dot">{pending.length}</span><div><h2></h2><p>{pending.length ? "只需回答业务问题" : "当前没有待处理项目"}</p></div></div>
{pending.slice(0, 2).map((item) => <button className="review-item" key={item.id} onClick={() => onNavigate("confirm")}><span className="file-chip"></span><span><strong>{item.counterparty}</strong><small>{item.category}{formatCurrency(item.amountInCents)}</small></span><Icon name="chevron" size={16}/></button>)}
{pending.length === 0 ? <div className="empty-review"><Icon name="shield" size={22}/><strong></strong><small></small></div> : <button className="secondary-button" onClick={() => onNavigate("confirm")}></button>}
</section>
<section className="panel threshold-panel">
<div className="panel-heading"><div><h2></h2><p> · </p></div><span className="safe-pill"></span></div>
<div className="threshold-number"><strong>¥8.46</strong><span>/ ¥30</span></div>
<div className="progress"><span style={{ width: "28.2%" }}/></div>
<p></p>
<button className="text-button" onClick={() => onNavigate("tax")}> <Icon name="arrow" size={15}/></button>
</section>
</aside>
</div>
<section className="month-note">
<div className="note-date"><strong>01</strong><span></span></div>
<div><span className="eyebrow"></span><h2>7 8 1 </h2><p></p></div>
<button className="secondary-button" onClick={() => onNotify("经营小结样稿将在报表模块完成后开放")}></button>
</section>
</div>;
}
function TransactionTable({ transactions, compact = false, onEdit }: { transactions: Transaction[]; compact?: boolean; onEdit?: (transaction: Transaction) => void }) {
const rows = compact ? transactions.slice(0, 4) : transactions;
return <div className="table-wrap"><table><thead><tr><th></th><th></th><th></th><th className="amount"></th><th></th></tr></thead><tbody>{rows.map((item) => <tr key={item.id}><td><strong>{item.occurredOn}</strong><small>{item.source}{item.isCorrected ? " · 已修改" : ""}</small></td><td>{item.counterparty}</td><td>{item.category}</td><td className={`amount ${item.kind}`}>{item.kind === "income" ? "+" : ""}{formatCurrency(item.amountInCents).replace("¥", "¥ ")}</td><td><div className="row-status">{item.reviewDecision === "personal" ? <span className="status personal"></span> : item.reviewDecision === "business" ? <span className="status done"></span> : item.confidence >= 0.98 ? <span className="status done"></span> : <span className="status waiting"></span>}{onEdit ? <button className="text-button" onClick={() => onEdit(item)}></button> : null}</div></td></tr>)}</tbody></table></div>;
}
function ConfirmPage({ pending, isLocal, reviewingId, onConfirm, onNotify }: {
pending: Transaction[];
isLocal: boolean;
reviewingId: string | null;
onConfirm: (transactionId: string, decision: "business" | "personal") => Promise<void>;
onNotify: (message: string) => void;
}) {
return <div className="page"><PageIntro eyebrow="待确认事项" title={pending.length ? `${pending.length} 项需要你看一眼` : "目前没有需要确认的流水"} description="我们只问业务问题。确认后,系统会自动完成后续记账。" />
<section className="confirm-layout"><div className="confirm-list">{pending.length === 0 ? <div className="empty-state"><Icon name="shield" size={28}/><h2></h2><p></p></div> : pending.map((item, index) => { const saving = reviewingId === item.id; return <article className="confirm-card" key={item.id}><div className="confirm-index">{String(index + 1).padStart(2, "0")}</div><div className="confirm-content"><span className="source-line">{item.source} · {item.occurredOn}</span><h2>{item.counterparty}</h2><strong className="confirm-amount">{item.kind === "income" ? "+" : ""}{formatCurrency(item.amountInCents)}</strong><p>{item.category}</p><div className="confirm-actions"><button className="primary-button" disabled={reviewingId !== null} onClick={() => onConfirm(item.id, "business")}>{saving ? "正在保存" : item.kind === "income" ? "是,经营收入" : "是,经营支出"}</button><button className="secondary-button" disabled={reviewingId !== null} onClick={() => onConfirm(item.id, "personal")}></button><button className="text-button" disabled={reviewingId !== null} onClick={() => onNotify("更多分类将在规则模块接入后开放")}></button></div></div></article>; })}</div><aside className="explain-card"><Icon name="shield" size={22}/><h3></h3><p> 98%</p><small>{isLocal ? "确认不会改写原始流水;重复点击也不会产生重复记录。" : "当前是演示数据;导入本地流水后可真实保存。"}</small></aside></section>
</div>;
}
function DocumentsPage({ onNotify }: { onNotify: (message: string) => void }) {
return <div className="page"><PageIntro eyebrow="票据中心" title="票据归档正在准备中" description="0.9 内测版尚未启用票据上传和识别,避免把预览数据误当成真实票据。" />
<section className="empty-state feature-preview"><Icon name="file" size={32}/><span className="safe-pill"></span><h2></h2><p></p><button className="secondary-button" onClick={() => onNotify("票据模块会在原始文件存储与恢复测试通过后开放")}></button></section>
</div>;
}
function TransactionsPage({ transactions, isLocal, isImporting, importResult, onImport, onCreate, onCorrect, onNotify }: {
transactions: Transaction[];
isLocal: boolean;
isImporting: boolean;
importResult: ImportResult | null;
onImport: (file: File) => Promise<void>;
onCreate: (input: TransactionInput) => Promise<void>;
onCorrect: (input: TransactionCorrectionInput) => Promise<void>;
onNotify: (message: string) => void;
}) {
const fileInput = useRef<HTMLInputElement>(null);
const [filter, setFilter] = useState<"all" | "income" | "expense" | "pending">("all");
const [query, setQuery] = useState("");
const [entryMode, setEntryMode] = useState<"create" | "edit" | null>(null);
const [editing, setEditing] = useState<Transaction | null>(null);
const [occurredOn, setOccurredOn] = useState("");
const [counterparty, setCounterparty] = useState("");
const [category, setCategory] = useState("销售收入");
const [kind, setKind] = useState<"income" | "expense">("income");
const [amount, setAmount] = useState("");
const [reason, setReason] = useState("");
const [saving, setSaving] = useState(false);
const filtered = useMemo(() => transactions.filter((item) => {
const matchesFilter = filter === "all" || item.kind === filter || (filter === "pending" && item.confidence < 0.98);
const normalized = query.trim().toLocaleLowerCase("zh-CN");
const matchesQuery = !normalized || [item.counterparty, item.category, item.source, item.occurredOn].some((value) => value.toLocaleLowerCase("zh-CN").includes(normalized));
return matchesFilter && matchesQuery;
}), [transactions, filter, query]);
const openCreate = () => {
if (!isTauriRuntime()) { onNotify("手工记账请在 Windows 客户端中使用"); return; }
const now = new Date();
setOccurredOn(new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 16));
setCounterparty(""); setCategory("销售收入"); setKind("income"); setAmount(""); setReason(""); setEditing(null); setEntryMode("create");
};
const openEdit = (item: Transaction) => {
if (!isLocal) { onNotify("演示流水不能修改,请先使用本地账本"); return; }
setOccurredOn(item.occurredOn.length >= 16 ? item.occurredOn.slice(0, 16).replace(" ", "T") : "");
setCounterparty(item.counterparty); setCategory(item.category); setKind(item.kind); setAmount((item.amountInCents / 100).toFixed(2)); setReason(""); setEditing(item); setEntryMode("edit");
};
const submitEntry = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!occurredOn || !counterparty.trim() || !category.trim() || !amount.trim()) { onNotify("请完整填写时间、对方、分类和金额"); return; }
if (entryMode === "edit" && reason.trim().length < 2) { onNotify("请填写至少 2 个字的修改原因"); return; }
const input = { occurredOn: occurredOn.replace("T", " "), counterparty: counterparty.trim(), category: category.trim(), kind, amount: amount.trim() };
setSaving(true);
try {
if (entryMode === "edit" && editing) await onCorrect({ ...input, transactionId: editing.id, reason: reason.trim() });
else await onCreate(input);
setEntryMode(null); setEditing(null);
} finally { setSaving(false); }
};
const openFilePicker = () => {
if (!isTauriRuntime()) {
onNotify("CSV 导入请在 Windows 客户端中使用");
return;
}
fileInput.current?.click();
};
const handleFile = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) await onImport(file);
};
return <div className="page"><PageIntro eyebrow="收支流水" title="每一笔钱,都有来处" description={isLocal ? "本地流水可搜索、筛选和追溯修改;原始记录不会被覆盖。" : "当前展示演示数据。在 Windows 客户端记一笔或导入 CSV 后会切换到本地账本。"} action={<div className="page-actions"><button className="primary-button" onClick={openCreate}><Icon name="plus" size={17}/></button><input ref={fileInput} className="visually-hidden" type="file" accept=".csv,text/csv" onChange={handleFile}/><button className="secondary-button" disabled={isImporting} onClick={openFilePicker}>{isImporting ? "正在导入" : "导入 CSV"}</button></div>} />
{importResult ? <section className="import-result" role="status"><Icon name="shield" size={20}/><div><strong>{importResult.importedCount ? `成功新增 ${importResult.importedCount} 笔流水` : "这份文件已经导入过"}</strong><p> {importResult.totalCount} {importResult.duplicateCount} </p></div></section> : null}
{entryMode ? <form className="panel transaction-form" onSubmit={submitEntry}><div className="panel-heading"><div><h2>{entryMode === "create" ? "手工记一笔" : "修改流水"}</h2><p>{entryMode === "create" ? "保存后立即进入本地加密账本" : "修改会追加保存,原始流水继续保留"}</p></div><button type="button" className="text-button" onClick={() => setEntryMode(null)}></button></div><div className="transaction-form-grid"><label><span></span><input type="datetime-local" value={occurredOn} onChange={(event) => setOccurredOn(event.target.value)}/></label><label><span></span><select value={kind} onChange={(event) => setKind(event.target.value as "income" | "expense")}><option value="income"></option><option value="expense"></option></select></label><label><span></span><input maxLength={100} value={counterparty} onChange={(event) => setCounterparty(event.target.value)} placeholder="例如:社区顾客"/></label><label><span></span><input inputMode="decimal" value={amount} onChange={(event) => setAmount(event.target.value)} placeholder="0.00"/></label><label><span></span><input maxLength={40} value={category} onChange={(event) => setCategory(event.target.value)} placeholder="例如:销售收入"/></label>{entryMode === "edit" ? <label><span></span><input maxLength={200} value={reason} onChange={(event) => setReason(event.target.value)} placeholder="例如:按银行回单复核"/></label> : null}</div><div className="form-actions"><button className="primary-button" disabled={saving}>{saving ? "正在保存" : "保存流水"}</button><span></span></div></form> : null}
<section className="panel"><div className="panel-heading transaction-toolbar"><div><h2>{isLocal ? "本地流水" : "演示流水"}</h2><p> {filtered.length} {transactions.length} </p></div><input className="transaction-search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索对方、分类、来源或日期"/><div className="filter-row">{([["all","全部"],["income","收入"],["expense","支出"],["pending","待确认"]] as const).map(([value,label]) => <button key={value} className={`filter ${filter === value ? "active" : ""}`} onClick={() => setFilter(value)}>{label}</button>)}</div></div>{filtered.length ? <TransactionTable transactions={filtered} onEdit={isLocal ? openEdit : undefined}/> : <div className="table-empty"></div>}</section>
<section className="csv-help"><div><h2>CSV </h2><p></p></div><span>UTF-8 </span></section>
</div>;
}
function LedgerPage({ transactions, isLocal, onNotify }: { transactions: Transaction[]; isLocal: boolean; onNotify: (message: string) => void }) {
const [exporting, setExporting] = useState(false);
const [lastExport, setLastExport] = useState<ExportInfo | null>(null);
const businessRows = useMemo(() => transactions.filter((item) => item.reviewDecision !== "personal"), [transactions]);
const summary = useMemo(() => summarizeTransactions(businessRows), [businessRows]);
const categories = useMemo(() => {
const grouped = new Map<string, { name: string; kind: "income" | "expense"; amount: number; count: number }>();
businessRows.forEach((item) => {
const key = `${item.kind}|${item.category}`;
const current = grouped.get(key) ?? { name: item.category, kind: item.kind, amount: 0, count: 0 };
current.amount += item.amountInCents; current.count += 1; grouped.set(key, current);
});
return [...grouped.values()].sort((left, right) => left.kind.localeCompare(right.kind) || right.amount - left.amount || left.name.localeCompare(right.name, "zh-CN"));
}, [businessRows]);
const exportLedger = async () => {
if (!isTauriRuntime() || !isLocal) { onNotify("请先在 Windows 客户端记账或导入本地流水"); return; }
setExporting(true);
try {
const result = await invoke<ExportInfo>("export_ledger_csv");
setLastExport(result);
onNotify(`已导出 ${result.rowCount} 笔:${result.fileName}`);
} catch (error) { onNotify(`账簿导出失败:${String(error)}`); }
finally { setExporting(false); }
};
return <div className="page"><PageIntro eyebrow="经营账簿" title="账已经替你整理好了" description={isLocal ? "金额由当前本地流水逐笔汇总;个人收支不会计入经营账簿。" : "当前使用演示数据,记一笔或导入 CSV 后会显示真实账簿。"} action={<button className="secondary-button" disabled={exporting} onClick={exportLedger}>{exporting ? "正在导出" : "导出当前账簿"}</button>} /><section className="ledger-overview"><article><span></span><strong>{formatCurrency(summary.incomeInCents)}</strong></article><article><span></span><strong>{formatCurrency(summary.expenseInCents)}</strong></article><article><span></span><strong>{formatCurrency(summary.profitInCents)}</strong></article><article><span></span><strong>{businessRows.length} </strong></article></section>{lastExport ? <section className="import-result export-result" role="status"><Icon name="shield" size={20}/><div><strong>簿{lastExport.fileName}</strong><p title={lastExport.filePath}>{lastExport.filePath}</p><p>{lastExport.checksum} · CSV </p></div></section> : null}<section className="panel"><div className="panel-heading"><div><h2>簿</h2><p></p></div><span className="safe-pill"> {businessRows.length} </span></div><div className="ledger-rows">{categories.length ? categories.map((item) => <button key={`${item.kind}-${item.name}`} onClick={() => onNotify(`${item.name}包含 ${item.count}${item.kind === "income" ? "收入" : "支出"}`)}><span><strong>{item.name}</strong><small>{item.count} </small></span><b>{item.kind === "income" ? "+" : ""}{formatCurrency(item.amount)}</b><Icon name="chevron" size={16}/></button>) : <div className="table-empty"></div>}</div></section><section className="csv-help"><div><h2></h2><p>CSV SHA-256 </p></div><span>Excel </span></section></div>;
}
function TaxPage({ onNotify }: { onNotify: (message: string) => void }) {
return <div className="page"><PageIntro eyebrow="报税日历" title="申报功能尚未启用" description="0.9 内测版只整理账本,不计算或提交真实税务数据。" action={<button className="secondary-button" onClick={() => onNotify("税务规则需要专业复核和地区版本管理后才能启用")}></button>} /><section className="tax-hero"><div><span className="safe-pill"></span><h2>稿</h2><p></p></div><div className="tax-estimate"><span></span><strong></strong><small></small></div></section><section className="timeline"><article className="done"><i>1</i><div><span></span><h3></h3><p></p></div></article><article><i>2</i><div><span></span><h3></h3><p></p></div></article><article><i>3</i><div><span></span><h3></h3><p></p></div></article></section></div>;
}
function SettingsPage({ profile, isSaving, backups, backupBusy, onSave, onCreateBackup, onRestoreBackup }: {
profile: BusinessProfile | null;
isSaving: boolean;
backups: BackupInfo[];
backupBusy: "create" | "restore" | null;
onSave: (input: BusinessProfileInput) => Promise<void>;
onCreateBackup: () => Promise<void>;
onRestoreBackup: (fileName: string) => Promise<void>;
}) {
const [name, setName] = useState(profile?.name ?? "");
const [entityType, setEntityType] = useState<BusinessProfileInput["entityType"]>(profile?.entityType ?? "sole_proprietor");
const [region, setRegion] = useState(profile?.region ?? "");
const [validationMessage, setValidationMessage] = useState<string | null>(null);
const [restoreCandidate, setRestoreCandidate] = useState<string | null>(null);
useEffect(() => {
setName(profile?.name ?? "");
setEntityType(profile?.entityType ?? "sole_proprietor");
setRegion(profile?.region ?? "");
}, [profile]);
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const trimmedName = name.trim();
const trimmedRegion = region.trim();
if (trimmedName.length < 2 || trimmedName.length > 60) {
setValidationMessage("经营主体名称请填写 2 到 60 个字符");
return;
}
if (trimmedRegion.length < 2 || trimmedRegion.length > 40) {
setValidationMessage("所在地区请填写 2 到 40 个字符");
return;
}
setValidationMessage(null);
try {
await onSave({ name: trimmedName, entityType, region: trimmedRegion });
} catch {
// 全局提示会展示底层返回的具体失败原因。
}
};
return <div className="page settings-page">
<PageIntro eyebrow="设置与数据" title={profile ? "经营主体资料" : "先完成经营主体建档"} description="这份资料决定流水和后续账簿属于谁。当前版本只保存必要的非敏感信息。" />
<div className="settings-layout">
<form className="panel profile-form" onSubmit={submit}>
<div className="panel-heading"><div><h2>{profile ? "修改主体资料" : "创建经营主体"}</h2><p></p></div>{profile ? <span className="safe-pill"></span> : null}</div>
<label><span></span><small></small><input value={name} maxLength={60} placeholder="例如:禾木小食店" onChange={(event) => setName(event.target.value)} /></label>
<label><span></span><small></small><select value={entityType} onChange={(event) => setEntityType(event.target.value as BusinessProfileInput["entityType"])}><option value="sole_proprietor"></option><option value="small_business"></option></select></label>
<label><span></span><small></small><input value={region} maxLength={40} placeholder="例如:浙江省杭州市" onChange={(event) => setRegion(event.target.value)} /></label>
{validationMessage ? <p className="form-error" role="alert">{validationMessage}</p> : null}
<div className="form-actions"><button className="primary-button" type="submit" disabled={isSaving}>{isSaving ? "正在保存" : profile ? "保存修改" : "完成建档"}</button><span></span></div>
</form>
<aside className="profile-safety"><Icon name="shield" size={24}/><h3></h3><p> Windows </p><div><strong></strong><span> · · </span></div><small></small></aside>
</div>
<section className="panel backup-panel">
<div className="panel-heading"><div><h2></h2><p> SHA-256 </p></div><button className="primary-button" disabled={backupBusy !== null} onClick={onCreateBackup}>{backupBusy === "create" ? "正在备份" : "立即创建备份"}</button></div>
{backups.length === 0 ? <div className="backup-empty"><Icon name="shield" size={23}/><div><strong></strong><span></span></div></div> : <div className="backup-list">{backups.map((backup) => <article key={backup.fileName}><div className={`backup-state ${backup.isValid ? "valid" : "invalid"}`}><Icon name={backup.isValid ? "shield" : "bell"} size={18}/></div><div className="backup-meta"><strong>{backup.fileName.startsWith("pre-restore-") ? "恢复前安全快照" : "手动账本备份"}</strong><span>{backup.createdAt} · {Math.max(1, Math.round(backup.sizeBytes / 1024))} KB</span><small title={backup.checksum}> {backup.checksum.slice(0, 12)} · {backup.isValid ? "完整性正常" : "文件已损坏,禁止恢复"}</small></div>{restoreCandidate === backup.fileName ? <div className="restore-confirm"><strong></strong><span></span><div><button className="danger-button" disabled={backupBusy !== null} onClick={async () => { await onRestoreBackup(backup.fileName); setRestoreCandidate(null); }}>{backupBusy === "restore" ? "正在恢复" : "确认恢复"}</button><button className="text-button" disabled={backupBusy !== null} onClick={() => setRestoreCandidate(null)}></button></div></div> : <button className="secondary-button" disabled={!backup.isValid || backupBusy !== null} onClick={() => setRestoreCandidate(backup.fileName)}></button>}</article>)}</div>}
<div className="backup-note"><Icon name="shield" size={16}/><span></span></div>
</section>
</div>;
}
export default App;
+71
View File
@@ -0,0 +1,71 @@
import type { Transaction } from "../domain/transactions";
export const demoTransactions: Transaction[] = [
{
id: "TX-0729-001",
occurredOn: "07月29日 18:42",
counterparty: "门店收款汇总",
category: "销售收入",
kind: "income",
amountInCents: 386820,
confidence: 0.995,
source: "微信支付",
},
{
id: "TX-0729-002",
occurredOn: "07月29日 09:16",
counterparty: "城南生鲜供应站",
category: "进货成本",
kind: "expense",
amountInCents: 128640,
confidence: 0.96,
source: "电子发票",
},
{
id: "TX-0728-003",
occurredOn: "07月28日 21:05",
counterparty: "支付宝商家收款",
category: "销售收入",
kind: "income",
amountInCents: 241500,
confidence: 0.99,
source: "支付宝",
},
{
id: "TX-0728-004",
occurredOn: "07月28日 12:30",
counterparty: "安居物业",
category: "店铺费用",
kind: "expense",
amountInCents: 32800,
confidence: 0.87,
source: "银行流水",
},
{
id: "TX-0727-005",
occurredOn: "07月27日 08:51",
counterparty: "华东包装材料",
category: "包装耗材",
kind: "expense",
amountInCents: 21650,
confidence: 0.985,
source: "数电票",
},
{
id: "TX-0726-006",
occurredOn: "07月26日 16:18",
counterparty: "周先生",
category: "待确认收入",
kind: "income",
amountInCents: 860000,
confidence: 0.94,
source: "银行流水",
},
];
export const monthSummary = {
incomeInCents: 4862380,
expenseInCents: 2935410,
profitInCents: 1926970,
estimatedTaxInCents: 0,
};
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import {
formatCurrency,
summarizeTransactions,
type Transaction,
} from "./transactions";
const demo: Transaction[] = [
{
id: "1",
occurredOn: "2026-07-30",
counterparty: "门店收款",
category: "销售收入",
kind: "income",
amountInCents: 128000,
confidence: 0.99,
source: "微信支付",
},
{
id: "2",
occurredOn: "2026-07-30",
counterparty: "鲜品供应商",
category: "进货",
kind: "expense",
amountInCents: 43560,
confidence: 0.91,
source: "电子发票",
},
];
describe("summarizeTransactions", () => {
it("使用整数分汇总收入、支出和利润", () => {
expect(summarizeTransactions(demo)).toEqual({
incomeInCents: 128000,
expenseInCents: 43560,
profitInCents: 84440,
pendingCount: 1,
});
});
it("拒绝浮点分,避免财务精度悄悄丢失", () => {
expect(() =>
summarizeTransactions([{ ...demo[0], amountInCents: 1.2 }]),
).toThrow("交易金额必须使用整数分保存");
});
it("个人收支不进入经营汇总", () => {
expect(
summarizeTransactions([
demo[0],
{ ...demo[1], confidence: 1, reviewDecision: "personal" },
]),
).toEqual({
incomeInCents: 128000,
expenseInCents: 0,
profitInCents: 128000,
pendingCount: 0,
});
});
});
describe("formatCurrency", () => {
it("以人民币格式展示整数分", () => {
expect(formatCurrency(128000)).toContain("1,280.00");
});
});
+70
View File
@@ -0,0 +1,70 @@
export type TransactionKind = "income" | "expense";
export type ReviewDecision = "business" | "personal";
export interface Transaction {
id: string;
occurredOn: string;
counterparty: string;
category: string;
kind: TransactionKind;
amountInCents: number;
confidence: number;
source: string;
reviewDecision?: ReviewDecision | null;
isCorrected?: boolean;
}
export interface TransactionSummary {
incomeInCents: number;
expenseInCents: number;
profitInCents: number;
pendingCount: number;
}
export function summarizeTransactions(
transactions: readonly Transaction[],
): TransactionSummary {
return transactions.reduce<TransactionSummary>(
(summary, transaction) => {
if (!Number.isInteger(transaction.amountInCents)) {
throw new Error("交易金额必须使用整数分保存");
}
if (transaction.reviewDecision === "personal") {
return summary;
}
if (transaction.kind === "income") {
summary.incomeInCents += transaction.amountInCents;
} else {
summary.expenseInCents += transaction.amountInCents;
}
if (transaction.confidence < 0.98) {
summary.pendingCount += 1;
}
summary.profitInCents =
summary.incomeInCents - summary.expenseInCents;
return summary;
},
{
incomeInCents: 0,
expenseInCents: 0,
profitInCents: 0,
pendingCount: 0,
},
);
}
export function formatCurrency(amountInCents: number): string {
if (!Number.isInteger(amountInCents)) {
throw new Error("金额必须使用整数分格式化");
}
return new Intl.NumberFormat("zh-CN", {
style: "currency",
currency: "CNY",
minimumFractionDigits: 2,
}).format(amountInCents / 100);
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+441
View File
@@ -0,0 +1,441 @@
:root {
font-family: "Segoe UI Variable", "Segoe UI", "Microsoft YaHei UI", "PingFang SC", sans-serif;
color: #17212b;
background: #f4f6f8;
font-synthesis: none;
text-rendering: optimizeLegibility;
--accent-800: #084c45;
--accent-700: #0d5c52;
--accent-600: #0f6f61;
--accent-100: #d8eee8;
--accent-050: #edf7f4;
--text: #17212b;
--muted: #5e6a75;
--subtle: #77838e;
--border: #dfe3e8;
--border-strong: #cbd2d9;
--surface: #ffffff;
--surface-subtle: #f8fafb;
--canvas: #f4f6f8;
--warning: #8a6100;
--warning-bg: #fff5d6;
--danger: #b42318;
--radius-surface: 12px;
--radius-control: 8px;
--shadow-float: 0 12px 32px rgba(31, 48, 61, .10);
}
* { box-sizing: border-box; }
html { background: var(--canvas); }
body { margin: 0; min-width: 1024px; min-height: 100vh; background: var(--canvas); }
button, input, select { font: inherit; }
button { color: inherit; }
button:focus-visible { outline: 2px solid var(--accent-600); outline-offset: 2px; }
.app-provider { min-height: 100vh; background: var(--canvas); }
.icon { flex: 0 0 auto; }
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 248px minmax(0, 1fr); }
.sidebar {
position: fixed;
inset: 0 auto 0 0;
width: 248px;
padding: 22px 14px 16px;
display: flex;
flex-direction: column;
z-index: 3;
color: #26323c;
background: #f0f3f4;
border-right: 1px solid #d9dfe3;
}
.brand { display: flex; align-items: center; gap: 11px; padding: 0 10px 22px; }
.brand-mark {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border-radius: 10px;
background: var(--accent-700);
color: #fff;
font-size: 20px;
font-weight: 700;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .18);
}
.brand strong, .brand span { display: block; }
.brand strong { font-size: 18px; font-weight: 700; letter-spacing: .05em; color: #15222a; }
.brand span { margin-top: 2px; color: #72808a; font-size: 9px; font-weight: 650; letter-spacing: .16em; }
.business-switcher {
min-height: 62px;
padding: 10px 11px 10px 13px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
text-align: left;
cursor: pointer;
color: var(--text);
background: #fff;
border: 1px solid var(--border-strong);
border-radius: var(--radius-surface);
box-shadow: 0 1px 2px rgba(31, 48, 61, .04);
transition: border-color .16s ease, box-shadow .16s ease, transform .16s ease;
}
.business-switcher:hover { border-color: #aeb9c2; box-shadow: 0 3px 10px rgba(31, 48, 61, .07); }
.business-switcher:active { transform: scale(.985); }
.business-switcher .icon { color: #83909a; transform: rotate(90deg); }
.business-details { min-width: 0; }
.business-switcher strong, .business-switcher small { display: block; }
.business-switcher strong { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; line-height: 1.25; }
.business-switcher strong.name-short { font-size: 13px; }
.business-switcher strong.name-medium { font-size: 12px; }
.business-switcher strong.name-long { font-size: 11px; letter-spacing: -.01em; }
.business-switcher strong.name-extra-long { font-size: 10px; letter-spacing: -.025em; }
.business-switcher small { margin-top: 3px; color: var(--muted); font-size: 11px; }
.primary-nav { margin-top: 18px; }
.nav-label { margin: 20px 10px 7px; color: #849099; font-size: 10px; font-weight: 600; letter-spacing: .08em; }
.primary-nav button, .sidebar-foot > button {
position: relative;
width: 100%;
height: 42px;
padding: 0 11px;
display: flex;
align-items: center;
gap: 11px;
border: 0;
border-radius: var(--radius-control);
color: #52606b;
background: transparent;
cursor: pointer;
text-align: left;
font-size: 13px;
transition: background .14s ease, color .14s ease, transform .14s ease;
}
.primary-nav button:hover, .sidebar-foot > button:hover { color: var(--text); background: #e4e9eb; }
.primary-nav button:active, .sidebar-foot > button:active { transform: scale(.985); }
.primary-nav button.active { color: var(--accent-800); background: #dcece8; font-weight: 650; }
.sidebar-foot > button.active { color: var(--accent-800); background: #dcece8; font-weight: 650; }
.primary-nav button.active::before { content: ""; position: absolute; left: 0; width: 3px; height: 22px; border-radius: 0 3px 3px 0; background: var(--accent-600); }
.primary-nav button b {
margin-left: auto;
min-width: 20px;
height: 20px;
padding: 0 5px;
display: grid;
place-items: center;
border-radius: 10px;
background: var(--warning-bg);
color: var(--warning);
font-size: 10px;
}
.sidebar-foot { margin-top: auto; padding-top: 13px; border-top: 1px solid #d9dfe3; }
.local-status { padding: 10px 11px 14px; display: flex; align-items: center; gap: 10px; color: var(--accent-700); }
.local-status strong, .local-status small { display: block; }
.local-status strong { color: #34434b; font-size: 11px; }
.local-status small { margin-top: 3px; color: #74818a; font-size: 10px; }
.main-area { grid-column: 2; min-width: 0; }
.topbar {
height: 64px;
padding: 0 32px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 2;
background: rgba(255, 255, 255, .94);
border-bottom: 1px solid var(--border);
backdrop-filter: blur(16px);
}
.search {
width: 360px;
height: 36px;
padding: 0 11px;
display: flex;
align-items: center;
gap: 9px;
color: #7b8790;
background: #f6f8f9;
border: 1px solid var(--border);
border-radius: var(--radius-control);
font-size: 12px;
cursor: pointer;
text-align: left;
}
.search kbd { margin-left: auto; padding: 2px 6px; color: #6f7b84; background: #fff; border: 1px solid #d5dbe0; border-radius: 4px; font-family: inherit; font-size: 10px; }
.top-actions { display: flex; align-items: center; gap: 8px; }
.demo-badge { padding: 5px 8px; border-radius: 5px; background: #eef1f3; color: #68757f; font-size: 10px; }
.demo-badge.local { color: var(--accent-700); background: var(--accent-050); }
.icon-button, .avatar { border: 0; cursor: pointer; }
.icon-button { width: 36px; height: 36px; display: grid; place-items: center; border-radius: var(--radius-control); background: transparent; color: #4f5e68; }
.icon-button:hover { background: #eef1f3; }
.avatar { width: 32px; height: 32px; border-radius: 8px; background: #24343d; color: #fff; font-size: 12px; }
.page { max-width: 1480px; margin: 0 auto; padding: 32px 36px 52px; animation: page-in .2s cubic-bezier(.16, 1, .3, 1) both; }
@keyframes page-in { from { opacity: 0; transform: translateY(5px); } }
.page-intro { min-height: 86px; margin-bottom: 22px; display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; }
.page-context { display: block; margin-bottom: 7px; color: var(--accent-700); font-size: 11px; font-weight: 650; }
.page-intro h1 { margin: 0; color: #111c24; font-size: 27px; line-height: 1.25; font-weight: 690; letter-spacing: -.025em; }
.page-intro p { margin: 8px 0 0; max-width: 72ch; color: var(--muted); font-size: 13px; line-height: 1.55; }
.primary-button, .secondary-button {
min-height: 38px;
padding: 0 15px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
border-radius: var(--radius-control);
cursor: pointer;
white-space: nowrap;
font-size: 12px;
font-weight: 650;
transition: background .14s ease, border-color .14s ease, transform .14s ease, box-shadow .14s ease;
}
.primary-button { color: #fff; background: var(--accent-700); border: 1px solid var(--accent-700); box-shadow: 0 2px 5px rgba(13, 92, 82, .18); }
.primary-button:hover { background: var(--accent-800); border-color: var(--accent-800); }
.primary-button:active, .secondary-button:active { transform: scale(.98); }
.primary-button:disabled { cursor: wait; opacity: .66; box-shadow: none; }
.secondary-button { color: #26343d; background: #fff; border: 1px solid var(--border-strong); }
.secondary-button:hover { background: #f6f8f9; border-color: #aeb8c0; }
.text-button { padding: 0; display: inline-flex; align-items: center; gap: 5px; color: var(--accent-700); background: transparent; border: 0; cursor: pointer; font-size: 12px; font-weight: 600; }
.text-button:hover { color: var(--accent-800); }
.summary-grid {
margin-bottom: 16px;
display: grid;
grid-template-columns: 1.22fr repeat(3, 1fr);
overflow: hidden;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-surface);
box-shadow: 0 1px 2px rgba(31, 48, 61, .03);
}
.summary-card { min-height: 126px; padding: 19px 21px; background: var(--surface); border: 0; border-right: 1px solid #e7eaed; }
.summary-card:last-child { border-right: 0; }
.summary-card > span { color: var(--muted); font-size: 11px; }
.summary-card strong { display: block; margin-top: 13px; color: #142028; font-size: 24px; font-weight: 690; font-variant-numeric: tabular-nums; letter-spacing: -.025em; }
.summary-card small { display: block; margin-top: 11px; color: var(--subtle); font-size: 10.5px; }
.summary-card small i { margin-right: 4px; color: var(--accent-700); font-style: normal; font-weight: 700; }
.summary-card.featured { background: var(--accent-050); box-shadow: inset 3px 0 0 var(--accent-600); }
.summary-card.featured > span, .summary-card.featured small { color: #60766f; }
.summary-card.featured small i { color: var(--accent-700); }
.summary-card.tax-card { background: #fbfcfc; }
.summary-card.tax-card em { color: var(--accent-700); font-style: normal; }
.dashboard-columns { display: grid; grid-template-columns: minmax(610px, 1.7fr) minmax(282px, .72fr); gap: 16px; align-items: start; }
.panel { overflow: hidden; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-surface); box-shadow: 0 1px 2px rgba(31, 48, 61, .025); }
.panel-heading { min-height: 68px; padding: 16px 19px; display: flex; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid #e9ecef; }
.panel-heading h2, .action-panel h2 { margin: 0; color: #17232b; font-size: 14px; font-weight: 680; }
.panel-heading p, .action-panel p { margin: 4px 0 0; color: var(--muted); font-size: 11px; }
.table-wrap { overflow: auto hidden; }
table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 11.5px; }
th { height: 36px; padding: 0 13px; color: #6e7a84; background: #f7f9fa; text-align: left; font-size: 10px; font-weight: 600; }
td { height: 58px; padding: 0 13px; border-top: 1px solid #edf0f2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
tbody tr:hover { background: #f8fafb; }
td strong, td small { display: block; }
td strong { font-size: 11px; font-weight: 650; }
td small { margin-top: 4px; color: #7c8790; font-size: 10px; }
th:nth-child(1) { width: 21%; } th:nth-child(2) { width: 25%; } th:nth-child(3) { width: 19%; } th:nth-child(4) { width: 20%; } th:nth-child(5) { width: 15%; }
.amount { text-align: right; font-variant-numeric: tabular-nums; font-weight: 680; }
.amount.income { color: var(--accent-700); }
.amount.expense { color: #303b43; }
.status { padding: 4px 7px; display: inline-flex; align-items: center; border-radius: 6px; font-size: 9px; font-weight: 650; }
.status.done { color: var(--accent-700); background: var(--accent-050); }
.status.waiting { color: var(--warning); background: var(--warning-bg); }
.status.personal { color: #5b6472; background: #eef0f3; }
.right-rail { display: grid; gap: 16px; }
.action-panel-title { padding: 17px 17px 11px; display: flex; align-items: center; gap: 11px; }
.attention-dot { width: 31px; height: 31px; display: grid; place-items: center; border-radius: 8px; background: var(--warning-bg); color: var(--warning); font-weight: 700; }
.review-item { width: calc(100% - 24px); margin: 0 12px; padding: 13px 5px; display: grid; grid-template-columns: 31px 1fr auto; align-items: center; gap: 9px; text-align: left; background: transparent; border: 0; border-top: 1px solid #edf0f2; cursor: pointer; }
.review-item:hover { background: #f8fafb; }
.review-item strong, .review-item small { display: block; }
.review-item strong { font-size: 11px; }
.review-item small { margin-top: 5px; color: var(--muted); font-size: 9.5px; }
.review-item .icon { color: #89959e; }
.file-chip { width: 29px; height: 31px; display: grid; place-items: center; border-radius: 7px; background: var(--accent-050); color: var(--accent-700); font-size: 10px; font-weight: 700; }
.file-chip.muted { color: #5e6972; background: #eef1f3; }
.action-panel > .secondary-button { width: calc(100% - 32px); margin: 8px 16px 16px; }
.empty-review { margin: 0 16px 16px; padding: 18px 12px; display: grid; justify-items: center; gap: 5px; text-align: center; color: var(--accent-700); background: var(--accent-050); border-radius: 9px; }
.empty-review strong { color: #29433e; font-size: 11px; }
.empty-review small { color: #667b76; font-size: 9.5px; }
.threshold-panel { padding-bottom: 17px; }
.threshold-panel .panel-heading { padding-left: 17px; padding-right: 17px; border: 0; }
.safe-pill { width: max-content; padding: 4px 8px; display: inline-flex; align-items: center; border-radius: 6px; color: var(--accent-700); background: var(--accent-050); font-size: 9px; font-weight: 650; }
.threshold-number { padding: 0 17px; display: flex; align-items: baseline; gap: 4px; }
.threshold-number strong { font-size: 22px; font-variant-numeric: tabular-nums; }
.threshold-number span { color: #7c8790; font-size: 11px; }
.progress { height: 6px; margin: 12px 17px 11px; overflow: hidden; background: #e7eceb; border-radius: 3px; }
.progress span { display: block; height: 100%; background: var(--accent-600); border-radius: inherit; }
.threshold-panel > p { margin: 0 17px 13px; color: var(--muted); font-size: 10.5px; line-height: 1.6; }
.threshold-panel > .text-button { margin-left: 17px; }
.month-note { margin-top: 16px; padding: 18px 20px; display: grid; grid-template-columns: 52px 1fr auto; align-items: center; gap: 17px; background: #fff; border: 1px solid var(--border); border-radius: var(--radius-surface); }
.note-date { width: 48px; height: 52px; display: grid; place-content: center; text-align: center; color: var(--accent-800); background: var(--accent-050); border-radius: 8px; }
.note-date strong { font-size: 18px; line-height: 1; }
.note-date span { margin-top: 5px; font-size: 9px; }
.eyebrow { display: block; margin-bottom: 5px; color: var(--accent-700); font-size: 10px; font-weight: 650; }
.month-note h2 { margin: 0; font-size: 14px; }
.month-note p { margin: 6px 0 0; color: var(--muted); font-size: 11px; }
.confirm-layout { display: grid; grid-template-columns: minmax(610px, 1fr) 286px; gap: 16px; align-items: start; }
.confirm-list { display: grid; gap: 12px; }
.empty-state { min-height: 260px; padding: 32px; display: grid; place-content: center; justify-items: center; text-align: center; color: var(--accent-700); background: #fff; border: 1px solid var(--border); border-radius: var(--radius-surface); }
.empty-state h2 { margin: 13px 0 6px; color: #243139; font-size: 16px; }
.empty-state p { margin: 0; color: var(--muted); font-size: 11px; }
.feature-preview { min-height: 430px; gap: 9px; }
.feature-preview .safe-pill { margin-top: 5px; }
.feature-preview p { max-width: 520px; line-height: 1.7; }
.feature-preview .secondary-button { margin-top: 10px; }
.confirm-card { padding: 21px; display: grid; grid-template-columns: 38px 1fr; gap: 15px; background: #fff; border: 1px solid var(--border); border-radius: var(--radius-surface); }
.confirm-index { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 8px; color: var(--accent-700); background: var(--accent-050); font-size: 11px; font-weight: 700; }
.source-line { color: var(--muted); font-size: 10px; }
.confirm-content h2 { margin: 7px 0 4px; font-size: 16px; }
.confirm-amount { display: block; margin: 5px 0 16px; font-size: 23px; font-variant-numeric: tabular-nums; }
.confirm-content p { margin: 0 0 18px; padding: 12px 14px; color: #39464f; background: #f6f8f9; border-left: 3px solid var(--accent-600); font-size: 12px; }
.confirm-actions { display: flex; align-items: center; gap: 8px; }
.confirm-actions button:disabled { cursor: wait; opacity: .62; }
.explain-card { padding: 21px; color: #2c514b; background: var(--accent-050); border: 1px solid #cce2dc; border-radius: var(--radius-surface); }
.explain-card h3 { margin: 13px 0 8px; font-size: 14px; }
.explain-card p { margin: 0; color: #536e68; font-size: 11px; line-height: 1.7; }
.explain-card small { display: block; margin-top: 17px; padding-top: 13px; color: #637c76; border-top: 1px solid #d4e6e1; font-size: 10px; }
.document-stats, .ledger-overview { margin-bottom: 16px; display: grid; grid-template-columns: repeat(3, 1fr); overflow: hidden; background: #fff; border: 1px solid var(--border); border-radius: var(--radius-surface); }
.document-stats article, .ledger-overview article { padding: 17px 19px; background: #fff; border-right: 1px solid #e8ebee; }
.document-stats article:last-child, .ledger-overview article:last-child { border-right: 0; }
.document-stats span, .ledger-overview span { display: block; color: var(--muted); font-size: 11px; }
.document-stats strong, .ledger-overview strong { display: block; margin-top: 8px; font-size: 22px; font-variant-numeric: tabular-nums; }
.document-stats small { display: block; margin-top: 5px; color: #7c8790; font-size: 10px; }
.filter-row { display: flex; gap: 4px; }
.filter { height: 29px; padding: 0 10px; color: var(--muted); background: transparent; border: 1px solid transparent; border-radius: 6px; cursor: pointer; font-size: 10px; }
.filter:hover { background: #f1f4f5; }
.filter.active { color: var(--accent-800); background: var(--accent-050); border-color: #d1e6e0; font-weight: 650; }
.page-actions { display: flex; gap: 8px; }
.row-status { display: flex; align-items: center; gap: 9px; }
.transaction-form { margin-bottom: 16px; padding-bottom: 18px; }
.transaction-form-grid { padding: 18px 20px 4px; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 15px; }
.transaction-form-grid label > span { display: block; margin-bottom: 6px; color: #26343c; font-size: 10.5px; font-weight: 650; }
.transaction-form-grid input, .transaction-form-grid select, .transaction-search { width: 100%; height: 36px; padding: 0 10px; color: var(--text); background: #fff; border: 1px solid #cbd3d8; border-radius: var(--radius-control); outline: none; font-size: 10.5px; }
.transaction-form-grid input:focus, .transaction-form-grid select:focus, .transaction-search:focus { border-color: var(--accent-600); box-shadow: 0 0 0 2px rgba(38, 124, 112, .13); }
.transaction-toolbar { flex-wrap: wrap; }
.transaction-search { width: min(270px, 34vw); margin-left: auto; }
.table-empty { min-height: 150px; display: grid; place-items: center; color: var(--muted); border-top: 1px solid #edf0f2; font-size: 11px; }
.document-grid { padding: 17px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 13px; }
.document-grid article { min-width: 0; overflow: hidden; border: 1px solid #e1e5e8; border-radius: 10px; }
.document-preview { height: 108px; display: grid; place-content: center; justify-items: center; gap: 7px; color: #60786f; background: #eef5f3; }
.document-preview span { font-size: 9px; letter-spacing: .08em; }
.document-grid article > div:last-child { padding: 12px; }
.document-grid strong, .document-grid span, .document-grid small { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.document-grid strong { font-size: 11px; }
.document-grid span { margin-top: 5px; color: var(--muted); font-size: 9px; }
.document-grid small { margin-top: 9px; color: var(--accent-700); font-size: 9px; }
.visually-hidden { position: fixed; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
.import-result { margin-bottom: 14px; padding: 14px 16px; display: flex; align-items: flex-start; gap: 11px; color: var(--accent-700); background: var(--accent-050); border: 1px solid #cce2dc; border-radius: var(--radius-surface); }
.import-result strong { color: #24433d; font-size: 12px; }
.import-result p { margin: 4px 0 0; color: #58716b; font-size: 10.5px; line-height: 1.5; }
.export-result p { max-width: 900px; overflow-wrap: anywhere; }
.csv-help { margin-top: 14px; padding: 16px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; color: #34434b; background: #fff; border: 1px solid var(--border); border-radius: var(--radius-surface); }
.csv-help h2 { margin: 0; font-size: 12px; }
.csv-help p { margin: 5px 0 0; color: var(--muted); font-size: 10.5px; }
.csv-help span { flex: 0 0 auto; padding: 4px 8px; color: #627079; background: #f0f3f4; border-radius: 6px; font-size: 9px; }
.ledger-overview { grid-template-columns: repeat(4, 1fr); }
.ledger-rows button { width: 100%; min-height: 63px; padding: 0 19px; display: grid; grid-template-columns: 1fr auto 20px; gap: 18px; align-items: center; text-align: left; background: #fff; border: 0; border-top: 1px solid #edf0f2; cursor: pointer; }
.ledger-rows button:hover { background: #f8fafb; }
.ledger-rows strong, .ledger-rows small { display: block; }
.ledger-rows strong { font-size: 12px; }
.ledger-rows small { margin-top: 5px; color: var(--muted); font-size: 10px; }
.ledger-rows b { font-size: 12px; font-variant-numeric: tabular-nums; }
.ledger-rows .icon { color: #89959e; }
.tax-hero { padding: 25px 26px; display: flex; align-items: center; justify-content: space-between; color: #14332f; background: var(--accent-050); border: 1px solid #cce2dc; border-radius: var(--radius-surface); box-shadow: inset 4px 0 0 var(--accent-600); }
.tax-hero h2 { margin: 13px 0 7px; font-size: 19px; }
.tax-hero p { margin: 0; color: #58706b; font-size: 11px; }
.tax-hero .safe-pill { color: #fff; background: var(--accent-700); }
.tax-estimate { min-width: 210px; padding-left: 29px; border-left: 1px solid #bfd9d2; }
.tax-estimate span, .tax-estimate small { display: block; color: #607a74; font-size: 10px; }
.tax-estimate strong { display: block; margin: 7px 0; color: #123d37; font-size: 27px; font-variant-numeric: tabular-nums; }
.timeline { margin-top: 16px; padding: 8px 23px; background: #fff; border: 1px solid var(--border); border-radius: var(--radius-surface); }
.timeline article { min-height: 94px; display: grid; grid-template-columns: 32px 1fr; gap: 15px; align-items: center; position: relative; }
.timeline article:not(:last-child)::after { content: ""; position: absolute; left: 15px; top: 63px; bottom: -31px; width: 1px; background: #dfe4e7; }
.timeline i { width: 31px; height: 31px; display: grid; place-items: center; z-index: 1; color: #6e7a84; background: #fff; border: 1px solid #cbd3d9; border-radius: 8px; font-size: 11px; font-style: normal; }
.timeline .done i { color: #fff; background: var(--accent-600); border-color: var(--accent-600); }
.timeline span { color: var(--accent-700); font-size: 9px; }
.timeline h3 { margin: 4px 0; font-size: 12px; }
.timeline p { margin: 0; color: var(--muted); font-size: 10px; }
.settings-layout { display: grid; grid-template-columns: minmax(560px, 1fr) 300px; gap: 16px; align-items: start; }
.profile-form { padding-bottom: 21px; }
.profile-form > label { margin: 0 21px 17px; display: block; }
.profile-form label > span, .profile-form label > small { display: block; }
.profile-form label > span { margin-bottom: 5px; color: #26343c; font-size: 11px; font-weight: 650; }
.profile-form label > small { margin-bottom: 8px; color: var(--muted); font-size: 9.5px; line-height: 1.5; }
.profile-form input, .profile-form select { width: 100%; height: 38px; padding: 0 11px; color: var(--text); background: #fff; border: 1px solid #cbd3d8; border-radius: var(--radius-control); outline: none; font-size: 11px; }
.profile-form input:focus, .profile-form select:focus { border-color: var(--accent-600); box-shadow: 0 0 0 2px rgba(38, 124, 112, .13); }
.profile-form input::placeholder { color: #9aa4ab; }
.form-error { margin: -4px 21px 14px; padding: 9px 11px; color: #8b3a2e; background: #fff1ee; border-radius: 6px; font-size: 10px; }
.form-actions { margin: 4px 21px 0; padding-top: 17px; display: flex; align-items: center; gap: 13px; border-top: 1px solid #e7eaed; }
.form-actions span { color: var(--muted); font-size: 9.5px; }
.profile-safety { padding: 21px; color: #2c514b; background: var(--accent-050); border: 1px solid #cce2dc; border-radius: var(--radius-surface); }
.profile-safety h3 { margin: 13px 0 8px; font-size: 14px; }
.profile-safety p { margin: 0; color: #536e68; font-size: 11px; line-height: 1.7; }
.profile-safety div { margin: 17px 0 13px; padding: 12px; background: rgba(255, 255, 255, .58); border-radius: 8px; }
.profile-safety strong, .profile-safety span { display: block; }
.profile-safety strong { margin-bottom: 5px; font-size: 10px; }
.profile-safety span, .profile-safety small { color: #637c76; font-size: 9.5px; line-height: 1.6; }
.backup-panel { margin-top: 16px; overflow: hidden; }
.backup-empty { min-height: 92px; padding: 18px 21px; display: flex; align-items: center; gap: 12px; color: #698079; border-top: 1px solid #edf0f2; }
.backup-empty strong, .backup-empty span { display: block; }
.backup-empty strong { color: #33433f; font-size: 11px; }
.backup-empty span { margin-top: 4px; color: var(--muted); font-size: 9.5px; }
.backup-list article { min-height: 77px; padding: 12px 18px; display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 12px; border-top: 1px solid #edf0f2; }
.backup-state { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 8px; }
.backup-state.valid { color: var(--accent-700); background: var(--accent-050); }
.backup-state.invalid { color: #a24a3b; background: #fff0ed; }
.backup-meta { min-width: 0; }
.backup-meta strong, .backup-meta span, .backup-meta small { display: block; }
.backup-meta strong { font-size: 11px; }
.backup-meta span { margin-top: 4px; color: var(--muted); font-size: 9.5px; }
.backup-meta small { margin-top: 5px; overflow: hidden; color: #7d8991; font-size: 8.5px; white-space: nowrap; text-overflow: ellipsis; }
.restore-confirm { min-width: 190px; padding-left: 15px; border-left: 1px solid #e1e5e8; }
.restore-confirm > strong, .restore-confirm > span { display: block; }
.restore-confirm > strong { color: #6f3228; font-size: 10px; }
.restore-confirm > span { margin: 3px 0 7px; color: var(--muted); font-size: 8.5px; }
.restore-confirm > div { display: flex; align-items: center; gap: 5px; }
.danger-button { min-height: 28px; padding: 0 10px; color: #fff; background: #a44b3d; border: 1px solid #8f3e32; border-radius: 6px; cursor: pointer; font-size: 9px; font-weight: 650; }
.danger-button:hover { background: #913e32; }
.danger-button:disabled { cursor: wait; opacity: .6; }
.backup-note { padding: 11px 18px; display: flex; align-items: center; gap: 8px; color: #58716b; background: var(--accent-050); border-top: 1px solid #d8e9e4; font-size: 9.5px; }
.toast { position: fixed; right: 24px; bottom: 24px; z-index: 10; padding: 12px 15px; color: #fff; background: #24343d; border-radius: var(--radius-control); box-shadow: var(--shadow-float); font-size: 12px; animation: toast-in .18s ease both; }
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}
@media (max-width: 1180px) {
.app-shell { grid-template-columns: 208px minmax(0, 1fr); }
.sidebar { width: 208px; padding-left: 10px; padding-right: 10px; }
.brand { padding-left: 8px; padding-right: 8px; }
.brand strong { font-size: 16px; }
.topbar { padding-left: 24px; padding-right: 24px; }
.search { width: 300px; }
.page { padding-left: 26px; padding-right: 26px; }
.summary-grid { grid-template-columns: repeat(2, 1fr); }
.summary-card:nth-child(2) { border-right: 0; }
.summary-card:nth-child(-n + 2) { border-bottom: 1px solid #e7eaed; }
.dashboard-columns { grid-template-columns: 1fr; }
.right-rail { grid-template-columns: 1fr 1fr; }
.settings-layout, .confirm-layout { grid-template-columns: 1fr; }
.profile-safety { display: grid; grid-template-columns: auto 1fr; column-gap: 14px; }
.profile-safety h3, .profile-safety p, .profile-safety div, .profile-safety small { grid-column: 2; }
.profile-safety .icon { grid-row: 1 / span 2; }
.transaction-form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.document-grid { grid-template-columns: repeat(2, 1fr); }
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["vite.config.ts"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 1420,
strictPort: true,
},
clearScreen: false,
});
Binary file not shown.