Compare commits

...

10 Commits

Author SHA1 Message Date
eb76c3a8dc fix: 修复微信账单金额解析问题(半角¥符号支持)
- 修复 parse_amount 函数同时支持全角¥和半角¥
- 新增 MonthRangePicker 日期选择组件
- 新增 /api/monthly-stats 接口获取月度统计
- 分析页面月度趋势使用全量数据
- 新增健康检查路由
2026-01-10 19:21:24 +08:00
9247e1ec7f fix: 优化分析页时间范围筛选器默认值逻辑
- DateRangePicker 组件默认值逻辑调整,支持父组件传递的初始值
- 分析页 startDate/endDate 初始化为本月范围,修复首次加载显示不正确问题
- DailyTrendChart 日期解析逻辑优化,兼容多种格式
- bills页与后端聚合统计相关逻辑微调
- 后端账单服务相关处理细节优化
2026-01-10 16:50:04 +08:00
6d33132a4a fix: 修复前后端时区问题和日期范围选择器性能
- 前端时区修复:统一使用本地时区格式化日期
- 日期范围选择器优化:使用 untrack 避免循环更新
- 后端时区修复:使用 time.ParseInLocation 指定本地时区
- 其他优化:修复分页逻辑
2026-01-10 01:55:45 +08:00
48332efce4 fix: 修复日期范围选择器时区和性能问题
- 修复时区问题:使用本地时区格式化日期,避免 toISOString() 导致的日期偏移
- 优化日期范围选择器性能:使用 untrack 避免循环更新
- 统一日期格式化方法:在 utils.ts 中添加 formatLocalDate 工具函数
- 修复分页逻辑:优化页码计算和显示
- 更新相关页面:bills 和 analysis 页面使用统一的日期格式化方法
2026-01-10 01:51:18 +08:00
087ae027cc feat: 完善项目架构并增强分析页面功能
- 新增项目文档和 Docker 配置
  - 添加 README.md 和 TODO.md 项目文档
  - 为各服务添加 Dockerfile 和 docker-compose 配置

- 重构后端架构
  - 新增 adapter 层(HTTP/Python 适配器)
  - 新增 repository 层(数据访问抽象)
  - 新增 router 模块统一管理路由
  - 新增账单处理 handler

- 扩展前端 UI 组件库
  - 新增 Calendar、DateRangePicker、Drawer、Popover 等组件
  - 集成 shadcn-svelte 组件库

- 增强分析页面功能
  - 添加时间范围筛选器(支持本月默认值)
  - 修复 DateRangePicker 默认值显示问题
  - 优化数据获取和展示逻辑

- 完善分析器服务
  - 新增 FastAPI 服务接口
  - 改进账单清理器实现
2026-01-10 01:23:36 +08:00
CHE LIANG ZHAO
94f8ea12e6 chore: 添加 mongo 目录到 gitignore 2026-01-09 19:05:41 +08:00
CHE LIANG ZHAO
c1ffe2e822 feat: server connect mongo 2026-01-08 23:42:01 +08:00
CHE LIANG ZHAO
ccd2d0386a feat(analysis): 趋势图增加本周选项、线性图简化为总金额曲线
- 添加本周时间范围选项
- 线性图模式只显示总支出曲线,不再显示分类曲线
- 图例根据图表类型动态切换(堆叠图显示分类,线性图显示总支出)
- 时间范围选项:7天、本周、30天、本月、3个月、本年
2026-01-08 11:33:30 +08:00
CHE LIANG ZHAO
b226c85fa7 feat(analysis): 添加账单详情查看和编辑功能
- BillRecordsTable: 新增点击行查看详情弹窗,支持编辑模式
- CategoryRanking: 分类支出表格支持点击查看/编辑账单详情
- DailyTrendChart: 每日趋势表格支持点击查看/编辑账单详情
- TopExpenses: Top10支出支持点击查看/编辑,前三名高亮显示
- OverviewCards/MonthlyTrend: 添加卡片hover效果
- 新增 categories.ts: 集中管理账单分类数据
- 分类下拉按使用频率排序
2026-01-08 10:48:11 +08:00
clz
9d409d6a93 feat(analysis): 增强图表交互功能
- 分类支出排行: 饼图支持点击类别切换显示/隐藏,百分比动态重新计算
- 每日支出趋势: 图例支持点击切换类别显示,隐藏类别不参与堆叠计算
- Dialog列表: 添加列排序功能(时间/商家/描述/金额)
- Dialog列表: 添加分页功能,每页10条(分类)/8条(每日)
- 饼图hover效果: 扇形放大、阴影增强、中心显示详情
2026-01-08 02:55:54 +08:00
262 changed files with 15988 additions and 87 deletions

1
.gitignore vendored
View File

@@ -21,3 +21,4 @@ server/uploads/
server/outputs/
*.log
mongodata/

265
README.md Normal file
View File

@@ -0,0 +1,265 @@
# 💰 BillAI - 智能账单分析系统
一个基于微服务架构的个人账单分析工具,支持微信和支付宝账单的自动解析、智能分类和可视化分析。
![架构](https://img.shields.io/badge/架构-微服务-blue)
![Go](https://img.shields.io/badge/Go-1.21-00ADD8)
![Python](https://img.shields.io/badge/Python-3.12-3776AB)
![Svelte](https://img.shields.io/badge/SvelteKit-5-FF3E00)
![MongoDB](https://img.shields.io/badge/MongoDB-8.0-47A248)
![Docker](https://img.shields.io/badge/Docker-Compose-2496ED)
## ✨ 功能特性
- 📊 **账单分析** - 自动解析微信/支付宝账单,生成可视化报表
- 🏷️ **智能分类** - 基于关键词匹配的交易分类推断
- 📈 **趋势图表** - 日/月消费趋势、分类排行、收支对比
- 🔍 **复核修正** - 对不确定的分类进行人工复核
- 🐳 **一键部署** - Docker Compose 快速启动全部服务
## 🏗️ 系统架构
```mermaid
graph TB
subgraph 用户层
User[👤 用户]
end
subgraph 前端服务
Web[🌐 Web 前端<br/>SvelteKit :3000]
end
subgraph 后端服务
Server[⚙️ Go 后端<br/>Gin :8080]
Analyzer[🐍 Python 分析服务<br/>FastAPI :8001]
end
subgraph 数据层
MongoDB[(🍃 MongoDB<br/>:27017)]
MongoExpress[📊 Mongo Express<br/>:8083]
end
User -->|访问| Web
Web -->|HTTP API| Server
Server -->|HTTP 调用| Analyzer
Server -->|读写数据| MongoDB
MongoExpress -->|管理| MongoDB
style Web fill:#ff3e00,color:#fff
style Server fill:#00ADD8,color:#fff
style Analyzer fill:#3776AB,color:#fff
style MongoDB fill:#47A248,color:#fff
```
### 数据流
```mermaid
sequenceDiagram
participant U as 👤 用户
participant W as 🌐 Web
participant S as ⚙️ Server
participant A as 🐍 Analyzer
participant D as 🍃 MongoDB
rect rgb(240, 248, 255)
Note over U,D: 上传账单流程
U->>W: 上传账单文件
W->>S: POST /api/upload
S->>A: POST /clean (清洗账单)
A-->>S: 清洗结果 + 分类
S->>D: 存储账单数据
D-->>S: 保存成功
S-->>W: 返回分析结果
W-->>U: 显示分析报表
end
rect rgb(255, 248, 240)
Note over U,D: 复核修正流程
U->>W: 查看待复核记录
W->>S: GET /api/review
S->>D: 查询不确定分类
D-->>S: 返回记录列表
S-->>W: 待复核数据
W-->>U: 显示复核界面
U->>W: 修正分类
W->>S: 更新分类
S->>D: 更新记录
end
```
## 📁 项目结构
```
BillAI/
├── web/ # 前端 (SvelteKit + TailwindCSS)
│ ├── src/
│ │ ├── routes/ # 页面路由
│ │ │ ├── analysis/ # 📊 账单分析页
│ │ │ ├── bills/ # 📋 账单列表页
│ │ │ └── review/ # ✅ 复核页面
│ │ └── lib/
│ │ ├── components/ # UI 组件
│ │ └── services/ # API 服务
│ └── Dockerfile
├── server/ # 后端 (Go + Gin)
│ ├── adapter/ # 适配器层
│ │ ├── http/ # HTTP 客户端
│ │ └── python/ # 子进程调用
│ ├── handler/ # HTTP 处理器
│ ├── service/ # 业务逻辑
│ ├── repository/ # 数据访问层
│ └── Dockerfile
├── analyzer/ # 分析服务 (Python + FastAPI)
│ ├── server.py # FastAPI 入口
│ ├── clean_bill.py # 账单清洗
│ ├── category.py # 分类推断
│ ├── cleaners/ # 清洗器
│ │ ├── alipay.py # 支付宝
│ │ └── wechat.py # 微信
│ └── Dockerfile
├── data/ # 测试数据目录
├── mongo/ # MongoDB 数据
└── docker-compose.yaml # 容器编排
```
## 🚀 快速开始
### 环境要求
- Docker & Docker Compose
- (可选) Go 1.21+、Python 3.12+、Node.js 20+
### 一键启动
```bash
# 克隆项目
git clone https://github.com/your-username/BillAI.git
cd BillAI
# 启动所有服务
docker-compose up -d --build
# 查看服务状态
docker-compose ps
```
### 访问地址
| 服务 | 地址 | 说明 |
|------|------|------|
| **前端页面** | http://localhost:3000 | Web 界面 |
| **后端 API** | http://localhost:8080 | RESTful API |
| **分析服务** | http://localhost:8001 | Python API |
| **Mongo Express** | http://localhost:8083 | 数据库管理 |
## 💻 本地开发
### 前端开发
```bash
cd web
yarn install
yarn dev
```
### 后端开发
```bash
cd server
go mod download
go run .
```
### 分析服务开发
```bash
cd analyzer
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
python server.py
```
## 📖 API 文档
### 后端 API (Go)
| 方法 | 路径 | 说明 |
|------|------|------|
| `POST` | `/api/upload` | 上传并分析账单 |
| `GET` | `/api/review` | 获取待复核记录 |
| `GET` | `/health` | 健康检查 |
### 分析服务 API (Python)
| 方法 | 路径 | 说明 |
|------|------|------|
| `POST` | `/clean` | 清洗账单文件 |
| `POST` | `/category/infer` | 推断交易分类 |
| `GET` | `/category/list` | 获取分类列表 |
| `POST` | `/detect` | 检测账单类型 |
| `GET` | `/health` | 健康检查 |
## ⚙️ 配置说明
### 环境变量
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `ANALYZER_URL` | `http://localhost:8001` | Python 分析服务地址 |
| `ANALYZER_MODE` | `http` | 适配器模式: http/subprocess |
| `MONGO_URI` | `mongodb://localhost:27017` | MongoDB 连接 URI |
| `MONGO_DATABASE` | `billai` | 数据库名称 |
### 配置文件
- `server/config.yaml` - Go 后端配置
- `analyzer/config/category.yaml` - 分类规则配置
## 🔧 技术栈
| 层级 | 技术 | 版本 |
|------|------|------|
| **前端** | SvelteKit + TailwindCSS | 5.x / 4.x |
| **后端** | Go + Gin | 1.21 / 1.9 |
| **分析服务** | Python + FastAPI | 3.12 / 0.109+ |
| **数据库** | MongoDB | 8.0 |
| **容器化** | Docker Compose | - |
## 📊 支持的账单格式
-**微信支付** - 微信支付账单流水文件 (CSV)
-**支付宝** - 支付宝交易明细 (CSV)
## 🛣️ 路线图
- [ ] 添加用户认证 (JWT)
- [ ] 支持更多账单格式(银行账单)
- [ ] AI 智能分类LLM
- [ ] 预算管理功能
- [ ] 移动端适配
- [ ] 数据导出 (Excel/PDF)
## 🤝 贡献指南
欢迎提交 Issue 和 Pull Request
1. Fork 本仓库
2. 创建特性分支 (`git checkout -b feature/amazing-feature`)
3. 提交更改 (`git commit -m 'Add amazing feature'`)
4. 推送分支 (`git push origin feature/amazing-feature`)
5. 创建 Pull Request
## 📄 许可证
本项目采用 MIT 许可证 - 详见 [LICENSE](LICENSE) 文件
## 🙏 致谢
- [SvelteKit](https://kit.svelte.dev/)
- [Gin](https://gin-gonic.com/)
- [FastAPI](https://fastapi.tiangolo.com/)
- [shadcn-svelte](https://shadcn-svelte.com/)

101
TODO.md Normal file
View File

@@ -0,0 +1,101 @@
# BillAI 开发计划
## 已完成功能
### 前端 (web)
- [x] 侧边栏导航布局
- [x] 上传账单页面
- [x] 智能复核页面
- [x] 账单管理页面(分页、筛选、响应式表格)
- [x] 数据分析页面(图表、统计)
- [x] 日期范围选择器 (DateRangePicker)
- [x] 主题切换(亮色/暗色/跟随系统)
- [x] 服务状态指示器(轮询检查)
- [x] 顶部导航栏(页面标题、状态指示)
- [x] shadcn-svelte UI 组件库集成
### 后端 (server)
- [x] 账单上传与解析
- [x] 智能分类Python 分析器)
- [x] 复核记录查询
- [x] 账单列表 API分页、筛选
- [x] 健康检查端点
- [x] MongoDB 数据存储
### 分析器 (analyzer)
- [x] 支付宝账单解析
- [x] 微信账单解析
- [x] 分类规则引擎
- [x] 重复记录检测
---
## 待实现功能
### 高优先级
- [ ] **SSE 实时状态推送**
- 服务器实现 `/events` SSE 端点
- 前端使用 EventSource 接收状态
- 支持服务状态、任务进度等实时推送
- [ ] **服务异常页面提示**
- 服务离线时显示遮罩层
- 提示用户检查服务器状态
- 自动重试连接
### 中优先级
- [ ] **账单编辑功能**
- 在账单管理页面编辑记录
- 修改分类、备注等字段
- 保存到数据库
- [ ] **账单删除功能**
- 单条删除
- 批量删除
- 删除确认对话框
- [ ] **数据导出**
- 导出为 CSV
- 导出为 Excel
- 自定义导出字段
- [ ] **分类管理**
- 自定义分类
- 分类图标配置
- 分类规则编辑
### 低优先级
- [ ] **用户认证**
- 登录/注册
- 多用户支持
- 权限管理
- [ ] **数据备份**
- 自动备份
- 导入/导出备份
- [ ] **移动端适配**
- PWA 支持
- 触摸手势优化
- [ ] **AI 智能分析**
- 消费趋势预测
- 异常消费提醒
- 智能预算建议
---
## 技术债务
- [ ] 统一错误处理
- [ ] 添加单元测试
- [ ] API 文档Swagger
- [ ] 日志系统完善
- [ ] 性能优化(大数据量分页)
---
*最后更新: 2026-01-10*

25
analyzer/Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
# Python 分析服务 Dockerfile
FROM python:3.12-slim
WORKDIR /app
# 配置国内镜像源pip + apt
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources && \
pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && \
pip config set global.trusted-host mirrors.aliyun.com
# 安装依赖
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 复制源代码
COPY . .
# 暴露端口
EXPOSE 8001
# 健康检查需要 curl
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
# 启动服务
CMD ["python", "server.py"]

View File

@@ -10,8 +10,15 @@
python clean_bill.py 账单.csv --start 2026-01-01 --end 2026-01-15
"""
import sys
import io
from pathlib import Path
# 解决 Windows 控制台编码问题
if sys.stdout.encoding != 'utf-8':
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
if sys.stderr.encoding != 'utf-8':
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
from cleaners.base import create_arg_parser, compute_date_range
from cleaners import AlipayCleaner, WechatCleaner

View File

@@ -60,6 +60,8 @@ class AlipayCleaner(BaseCleaner):
print(f"\n处理结果:")
print(f" 全额退款删除: {self.stats['fully_refunded']}")
print(f" 部分退款调整: {self.stats['partially_refunded']}")
if self.stats.get("zero_amount", 0) > 0:
print(f" 0元记录过滤: {self.stats['zero_amount']}")
print(f" 最终保留行数: {len(final_rows)}")
# 第五步:重新分类并添加"需复核"标注
@@ -134,7 +136,11 @@ class AlipayCleaner(BaseCleaner):
self.stats["partially_refunded"] += 1
print(f" 部分退款: {row[0]} | {row[2]} | 原{expense_amount}元 -> {format_amount(remaining)}")
else:
final_rows.append(row)
# 过滤掉金额为 0 的记录(预下单/加购物车等无效记录)
if expense_amount > 0:
final_rows.append(row)
else:
self.stats["zero_amount"] = self.stats.get("zero_amount", 0) + 1
else:
final_rows.append(row)

View File

@@ -25,9 +25,10 @@ def parse_date(date_str: str) -> date:
def parse_amount(amount_str: str) -> Decimal:
"""解析金额字符串为Decimal去掉¥符号"""
"""解析金额字符串为Decimal去掉¥符号)"""
try:
clean = amount_str.replace("", "").replace(" ", "").strip()
# 同时处理全角¥和半角¥
clean = amount_str.replace("", "").replace("¥", "").replace(" ", "").strip()
return Decimal(clean)
except:
return Decimal("0")
@@ -85,6 +86,58 @@ def compute_date_range(args) -> tuple[date | None, date | None]:
return start_date, end_date
def compute_date_range_from_values(
year: str = None,
month: str = None,
start: str = None,
end: str = None
) -> tuple[date | None, date | None]:
"""
根据参数值计算日期范围(不依赖 argparse
供 HTTP API 调用使用
Returns:
(start_date, end_date) 或 (None, None) 表示不筛选
"""
start_date = None
end_date = None
# 1. 根据年份设置范围
if year:
y = int(year)
start_date = date(y, 1, 1)
end_date = date(y, 12, 31)
# 2. 根据月份进一步收窄
if month:
m = int(month)
y = int(year) if year else datetime.now().year
if not start_date:
start_date = date(y, 1, 1)
end_date = date(y, 12, 31)
month_start = date(y, m, 1)
if m == 12:
month_end = date(y, 12, 31)
else:
month_end = date(y, m + 1, 1) - timedelta(days=1)
start_date = max(start_date, month_start) if start_date else month_start
end_date = min(end_date, month_end) if end_date else month_end
# 3. 根据 start/end 参数进一步收窄
if start:
custom_start = parse_date(start)
start_date = max(start_date, custom_start) if start_date else custom_start
if end:
custom_end = parse_date(end)
end_date = min(end_date, custom_end) if end_date else custom_end
return start_date, end_date
def is_in_date_range(date_str: str, start_date: date | None, end_date: date | None) -> bool:
"""检查日期字符串是否在指定范围内"""
if start_date is None and end_date is None:

View File

@@ -58,6 +58,8 @@ class WechatCleaner(BaseCleaner):
print(f"\n处理结果:")
print(f" 全额退款删除: {self.stats['fully_refunded']}")
print(f" 部分退款调整: {self.stats['partially_refunded']}")
if self.stats.get("zero_amount", 0) > 0:
print(f" 0元记录过滤: {self.stats['zero_amount']}")
print(f" 保留支出条目: {len(final_expense_rows)}")
print(f" 保留收入条目: {len(income_rows)}")
@@ -177,7 +179,11 @@ class WechatCleaner(BaseCleaner):
if merchant in transfer_refunds:
del transfer_refunds[merchant]
else:
final_expense_rows.append((row, None))
# 过滤掉金额为 0 的记录(预下单/加购物车等无效记录)
if original_amount > 0:
final_expense_rows.append((row, None))
else:
self.stats["zero_amount"] = self.stats.get("zero_amount", 0) + 1
return final_expense_rows, income_rows

View File

@@ -218,6 +218,7 @@
- 煲仔饭
- 蛙来哒 # 牛蛙餐厅
- 粒上皇 # 炒货零食店
- 盒马
# 转账红包
转账红包:

View File

@@ -1,2 +1,4 @@
pyyaml>=6.0
fastapi>=0.109.0
uvicorn[standard]>=0.27.0
python-multipart>=0.0.6

351
analyzer/server.py Normal file
View File

@@ -0,0 +1,351 @@
#!/usr/bin/env python3
"""
账单分析 FastAPI 服务
提供 HTTP API 供 Go 服务调用,替代子进程通信方式
"""
import os
import sys
import io
import tempfile
import shutil
from pathlib import Path
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, UploadFile, File, Form
from fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel
from typing import Optional
# 解决编码问题
if sys.stdout.encoding != 'utf-8':
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
from cleaners.base import compute_date_range_from_values
from cleaners import AlipayCleaner, WechatCleaner
from category import infer_category, get_all_categories, get_all_income_categories
# 应用版本
APP_VERSION = "0.0.1"
# =============================================================================
# Pydantic 模型
# =============================================================================
class CleanRequest(BaseModel):
"""清洗请求"""
input_path: str
output_path: str
year: Optional[str] = None
month: Optional[str] = None
start: Optional[str] = None
end: Optional[str] = None
format: Optional[str] = "csv"
bill_type: Optional[str] = "auto" # auto, alipay, wechat
class CleanResponse(BaseModel):
"""清洗响应"""
success: bool
bill_type: str
message: str
output_path: Optional[str] = None
class CategoryRequest(BaseModel):
"""分类推断请求"""
merchant: str
product: str
income_expense: str # "收入" 或 "支出"
class CategoryResponse(BaseModel):
"""分类推断响应"""
category: str
is_certain: bool
class HealthResponse(BaseModel):
"""健康检查响应"""
status: str
version: str
# =============================================================================
# 辅助函数
# =============================================================================
def detect_bill_type(filepath: str) -> str | None:
"""
检测账单类型
Returns:
'alipay' | 'wechat' | None
"""
try:
with open(filepath, "r", encoding="utf-8") as f:
for _ in range(20):
line = f.readline()
if not line:
break
# 支付宝特征
if "交易分类" in line and "对方账号" in line:
return "alipay"
# 微信特征
if "交易类型" in line and "金额(元)" in line:
return "wechat"
# 数据行特征
if line.startswith("202"):
if "" in line:
return "wechat"
if "@" in line:
return "alipay"
except Exception as e:
print(f"读取文件失败: {e}", file=sys.stderr)
return None
return None
def do_clean(
input_path: str,
output_path: str,
bill_type: str = "auto",
year: str = None,
month: str = None,
start: str = None,
end: str = None,
output_format: str = "csv"
) -> tuple[bool, str, str]:
"""
执行清洗逻辑
Returns:
(success, bill_type, message)
"""
# 检查文件是否存在
if not Path(input_path).exists():
return False, "", f"文件不存在: {input_path}"
# 检测账单类型
if bill_type == "auto":
detected_type = detect_bill_type(input_path)
if detected_type is None:
return False, "", "无法识别账单类型"
bill_type = detected_type
# 计算日期范围
start_date, end_date = compute_date_range_from_values(year, month, start, end)
# 创建对应的清理器
try:
if bill_type == "alipay":
cleaner = AlipayCleaner(input_path, output_path, output_format)
else:
cleaner = WechatCleaner(input_path, output_path, output_format)
cleaner.set_date_range(start_date, end_date)
cleaner.clean()
type_names = {"alipay": "支付宝", "wechat": "微信"}
return True, bill_type, f"{type_names[bill_type]}账单清洗完成"
except Exception as e:
return False, bill_type, f"清洗失败: {str(e)}"
# =============================================================================
# FastAPI 应用
# =============================================================================
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理"""
print("🚀 账单分析服务启动")
yield
print("👋 账单分析服务关闭")
app = FastAPI(
title="BillAI Analyzer",
description="账单分析与清洗服务",
version="1.0.0",
lifespan=lifespan
)
# =============================================================================
# API 路由
# =============================================================================
@app.get("/health", response_model=HealthResponse)
async def health_check():
"""健康检查"""
return HealthResponse(status="ok", version=APP_VERSION)
@app.post("/clean", response_model=CleanResponse)
async def clean_bill(request: CleanRequest):
"""
清洗账单文件
接收账单文件路径,执行清洗后输出到指定路径
"""
success, bill_type, message = do_clean(
input_path=request.input_path,
output_path=request.output_path,
bill_type=request.bill_type or "auto",
year=request.year,
month=request.month,
start=request.start,
end=request.end,
output_format=request.format or "csv"
)
if not success:
raise HTTPException(status_code=400, detail=message)
return CleanResponse(
success=True,
bill_type=bill_type,
message=message,
output_path=request.output_path
)
@app.post("/clean/upload", response_model=CleanResponse)
async def clean_bill_upload(
file: UploadFile = File(...),
year: Optional[str] = Form(None),
month: Optional[str] = Form(None),
start: Optional[str] = Form(None),
end: Optional[str] = Form(None),
format: Optional[str] = Form("csv"),
bill_type: Optional[str] = Form("auto")
):
"""
上传并清洗账单文件
通过 multipart/form-data 上传文件,清洗后返回结果
"""
# 创建临时文件
suffix = Path(file.filename).suffix or ".csv"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp_input:
shutil.copyfileobj(file.file, tmp_input)
input_path = tmp_input.name
# 创建输出临时文件
output_suffix = ".json" if format == "json" else ".csv"
with tempfile.NamedTemporaryFile(delete=False, suffix=output_suffix) as tmp_output:
output_path = tmp_output.name
try:
success, detected_type, message = do_clean(
input_path=input_path,
output_path=output_path,
bill_type=bill_type or "auto",
year=year,
month=month,
start=start,
end=end,
output_format=format or "csv"
)
if not success:
raise HTTPException(status_code=400, detail=message)
return CleanResponse(
success=True,
bill_type=detected_type,
message=message,
output_path=output_path
)
finally:
# 清理输入临时文件
if os.path.exists(input_path):
os.unlink(input_path)
@app.get("/clean/download/{file_path:path}")
async def download_cleaned_file(file_path: str):
"""下载清洗后的文件"""
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="文件不存在")
return FileResponse(
file_path,
filename=Path(file_path).name,
media_type="application/octet-stream"
)
@app.post("/category/infer", response_model=CategoryResponse)
async def infer_category_api(request: CategoryRequest):
"""
推断交易分类
根据商户名称和商品信息推断交易分类
"""
category, is_certain = infer_category(
merchant=request.merchant,
product=request.product,
income_expense=request.income_expense
)
return CategoryResponse(category=category, is_certain=is_certain)
@app.get("/category/list")
async def list_categories():
"""获取所有分类列表"""
return {
"expense": get_all_categories(),
"income": get_all_income_categories()
}
@app.post("/detect")
async def detect_bill_type_api(file: UploadFile = File(...)):
"""
检测账单类型
上传文件后自动检测是支付宝还是微信账单
"""
suffix = Path(file.filename).suffix or ".csv"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
shutil.copyfileobj(file.file, tmp)
tmp_path = tmp.name
try:
bill_type = detect_bill_type(tmp_path)
if bill_type is None:
raise HTTPException(status_code=400, detail="无法识别账单类型")
type_names = {"alipay": "支付宝", "wechat": "微信"}
return {
"bill_type": bill_type,
"display_name": type_names[bill_type]
}
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
# =============================================================================
# 启动入口
# =============================================================================
if __name__ == "__main__":
import uvicorn
port = int(os.environ.get("ANALYZER_PORT", 8001))
host = os.environ.get("ANALYZER_HOST", "0.0.0.0")
print(f"🚀 启动账单分析服务: http://{host}:{port}")
uvicorn.run(app, host=host, port=port)

120
docker-compose.yaml Normal file
View File

@@ -0,0 +1,120 @@
services:
# SvelteKit 前端服务
web:
build:
context: ./web
dockerfile: Dockerfile
container_name: billai-web
restart: unless-stopped
ports:
- "3000:3000"
environment:
NODE_ENV: production
HOST: "0.0.0.0"
PORT: "3000"
# SSR 服务端请求后端的地址Docker 内部网络)
API_URL: "http://server:8080"
depends_on:
server:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
# Go 后端服务
server:
build:
context: ./server
dockerfile: Dockerfile
container_name: billai-server
restart: unless-stopped
ports:
- "8080:8080"
environment:
ANALYZER_URL: "http://analyzer:8001"
ANALYZER_MODE: "http"
MONGO_URI: "mongodb://admin:password@mongodb:27017"
MONGO_DATABASE: "billai"
volumes:
- ./server/uploads:/app/uploads
- ./server/outputs:/app/outputs
depends_on:
analyzer:
condition: service_healthy
mongodb:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
# Python 分析服务 (FastAPI)
analyzer:
build:
context: ./analyzer
dockerfile: Dockerfile
container_name: billai-analyzer
restart: unless-stopped
ports:
- "8001:8001"
environment:
ANALYZER_HOST: "0.0.0.0"
ANALYZER_PORT: "8001"
volumes:
# 共享数据目录,用于访问上传的文件
- ./server/uploads:/app/uploads
- ./server/outputs:/app/outputs
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
mongodb:
image: mongo:8.0
container_name: billai-mongodb
restart: unless-stopped
ports:
- "27017:27017"
environment:
MONGO_INITDB_DATABASE: billai
# 如需认证,取消下面两行注释
MONGO_INITDB_ROOT_USERNAME: admin
MONGO_INITDB_ROOT_PASSWORD: password
volumes:
- ./mongodata/db:/data/db
- ./mongodata/configdb:/data/configdb
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
networks:
default:
aliases:
- mongo
# 可选MongoDB 可视化管理工具
mongo-express:
image: mongo-express:latest
container_name: billai-mongo-express
restart: unless-stopped
ports:
- "8083:8081"
environment:
ME_CONFIG_MONGODB_SERVER: mongodb
ME_CONFIG_MONGODB_PORT: 27017
ME_CONFIG_BASICAUTH: "false"
# 如启用 MongoDB 认证,取消下面两行注释
ME_CONFIG_MONGODB_ADMINUSERNAME: admin
ME_CONFIG_MONGODB_ADMINPASSWORD: password
depends_on:
mongodb:
condition: service_healthy

49
server/Dockerfile Normal file
View File

@@ -0,0 +1,49 @@
# Go 服务 Dockerfile
# 多阶段构建:编译阶段 + 运行阶段
# ===== 编译阶段 =====
FROM golang:1.21-alpine AS builder
WORKDIR /build
# 配置 Go 代理(国内镜像)
ENV GOPROXY=https://goproxy.cn,direct
# 先复制依赖文件,利用 Docker 缓存
COPY go.mod go.sum ./
RUN go mod download
# 复制源代码并编译
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o billai-server .
# ===== 运行阶段 =====
FROM alpine:latest
WORKDIR /app
# 配置 Alpine 镜像源(国内)
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
# 安装必要工具
RUN apk --no-cache add ca-certificates tzdata curl
# 设置时区
ENV TZ=Asia/Shanghai
# 从编译阶段复制二进制文件
COPY --from=builder /build/billai-server .
COPY --from=builder /build/config.yaml .
# 创建必要目录
RUN mkdir -p uploads outputs
# 暴露端口
EXPOSE 8080
# 健康检查
HEALTHCHECK --interval=10s --timeout=5s --retries=5 \
CMD curl -f http://localhost:8080/health || exit 1
# 启动服务
CMD ["./billai-server"]

28
server/adapter/adapter.go Normal file
View File

@@ -0,0 +1,28 @@
// Package adapter 定义与外部系统交互的抽象接口
// 这样可以方便后续更换通信方式(如从子进程调用改为 HTTP/gRPC/消息队列等)
package adapter
// CleanOptions 清洗选项
type CleanOptions struct {
Year string // 年份筛选
Month string // 月份筛选
Start string // 起始日期
End string // 结束日期
Format string // 输出格式: csv/json
}
// CleanResult 清洗结果
type CleanResult struct {
BillType string // 检测到的账单类型: alipay/wechat
Output string // 脚本输出信息
}
// Cleaner 账单清洗器接口
// 负责将原始账单数据清洗为标准格式
type Cleaner interface {
// Clean 执行账单清洗
// inputPath: 输入文件路径
// outputPath: 输出文件路径
// opts: 清洗选项
Clean(inputPath, outputPath string, opts *CleanOptions) (*CleanResult, error)
}

15
server/adapter/global.go Normal file
View File

@@ -0,0 +1,15 @@
// Package adapter 全局适配器实例管理
package adapter
// 全局清洗器实例
var globalCleaner Cleaner
// SetCleaner 设置全局清洗器实例
func SetCleaner(c Cleaner) {
globalCleaner = c
}
// GetCleaner 获取全局清洗器实例
func GetCleaner() Cleaner {
return globalCleaner
}

View File

@@ -0,0 +1,204 @@
// Package http 实现通过 HTTP API 调用 Python 服务的清洗器
package http
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"time"
"billai-server/adapter"
)
// CleanRequest HTTP 清洗请求
type CleanRequest struct {
InputPath string `json:"input_path"`
OutputPath string `json:"output_path"`
Year string `json:"year,omitempty"`
Month string `json:"month,omitempty"`
Start string `json:"start,omitempty"`
End string `json:"end,omitempty"`
Format string `json:"format,omitempty"`
BillType string `json:"bill_type,omitempty"`
}
// CleanResponse HTTP 清洗响应
type CleanResponse struct {
Success bool `json:"success"`
BillType string `json:"bill_type"`
Message string `json:"message"`
OutputPath string `json:"output_path,omitempty"`
}
// ErrorResponse 错误响应
type ErrorResponse struct {
Detail string `json:"detail"`
}
// Cleaner 通过 HTTP API 调用 Python 服务的清洗器实现
type Cleaner struct {
baseURL string // Python 服务基础 URL
httpClient *http.Client // HTTP 客户端
}
// NewCleaner 创建 HTTP 清洗器
func NewCleaner(baseURL string) *Cleaner {
return &Cleaner{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 60 * time.Second, // 清洗可能需要较长时间
},
}
}
// NewCleanerWithClient 使用自定义 HTTP 客户端创建清洗器
func NewCleanerWithClient(baseURL string, client *http.Client) *Cleaner {
return &Cleaner{
baseURL: baseURL,
httpClient: client,
}
}
// Clean 执行账单清洗(使用文件上传方式)
func (c *Cleaner) Clean(inputPath, outputPath string, opts *adapter.CleanOptions) (*adapter.CleanResult, error) {
// 打开输入文件
file, err := os.Open(inputPath)
if err != nil {
return nil, fmt.Errorf("打开文件失败: %w", err)
}
defer file.Close()
// 创建 multipart form
var body bytes.Buffer
writer := multipart.NewWriter(&body)
// 添加文件
part, err := writer.CreateFormFile("file", filepath.Base(inputPath))
if err != nil {
return nil, fmt.Errorf("创建表单文件失败: %w", err)
}
if _, err := io.Copy(part, file); err != nil {
return nil, fmt.Errorf("复制文件内容失败: %w", err)
}
// 添加其他参数
if opts != nil {
if opts.Year != "" {
writer.WriteField("year", opts.Year)
}
if opts.Month != "" {
writer.WriteField("month", opts.Month)
}
if opts.Start != "" {
writer.WriteField("start", opts.Start)
}
if opts.End != "" {
writer.WriteField("end", opts.End)
}
if opts.Format != "" {
writer.WriteField("format", opts.Format)
}
}
writer.WriteField("bill_type", "auto")
writer.Close()
// 发送上传请求
fmt.Printf("🌐 调用清洗服务: %s/clean/upload\n", c.baseURL)
req, err := http.NewRequest("POST", c.baseURL+"/clean/upload", &body)
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("HTTP 请求失败: %w", err)
}
defer resp.Body.Close()
// 读取响应
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
// 处理错误响应
if resp.StatusCode != http.StatusOK {
var errResp ErrorResponse
if err := json.Unmarshal(respBody, &errResp); err == nil {
return nil, fmt.Errorf("清洗失败: %s", errResp.Detail)
}
return nil, fmt.Errorf("清洗失败: HTTP %d - %s", resp.StatusCode, string(respBody))
}
// 解析成功响应
var cleanResp CleanResponse
if err := json.Unmarshal(respBody, &cleanResp); err != nil {
return nil, fmt.Errorf("解析响应失败: %w", err)
}
// 下载清洗后的文件
if cleanResp.OutputPath != "" {
if err := c.downloadFile(cleanResp.OutputPath, outputPath); err != nil {
return nil, fmt.Errorf("下载清洗结果失败: %w", err)
}
}
return &adapter.CleanResult{
BillType: cleanResp.BillType,
Output: cleanResp.Message,
}, nil
}
// downloadFile 下载清洗后的文件
func (c *Cleaner) downloadFile(remotePath, localPath string) error {
// 构建下载 URL
downloadURL := fmt.Sprintf("%s/clean/download/%s", c.baseURL, remotePath)
resp, err := c.httpClient.Get(downloadURL)
if err != nil {
return fmt.Errorf("下载请求失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("下载失败: HTTP %d", resp.StatusCode)
}
// 创建本地文件
out, err := os.Create(localPath)
if err != nil {
return fmt.Errorf("创建文件失败: %w", err)
}
defer out.Close()
// 写入文件内容
if _, err := io.Copy(out, resp.Body); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
return nil
}
// HealthCheck 检查 Python 服务健康状态
func (c *Cleaner) HealthCheck() error {
resp, err := c.httpClient.Get(c.baseURL + "/health")
if err != nil {
return fmt.Errorf("健康检查失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("服务不健康: HTTP %d", resp.StatusCode)
}
return nil
}
// 确保 Cleaner 实现了 adapter.Cleaner 接口
var _ adapter.Cleaner = (*Cleaner)(nil)

View File

@@ -0,0 +1,94 @@
// Package python 实现通过子进程调用 Python 脚本的清洗器
package python
import (
"fmt"
"os/exec"
"strings"
"billai-server/adapter"
"billai-server/config"
)
// Cleaner 通过子进程调用 Python 脚本的清洗器实现
type Cleaner struct {
pythonPath string // Python 解释器路径
scriptPath string // 清洗脚本路径
workDir string // 工作目录
}
// NewCleaner 创建 Python 清洗器
func NewCleaner() *Cleaner {
return &Cleaner{
pythonPath: config.ResolvePath(config.Global.PythonPath),
scriptPath: config.ResolvePath(config.Global.CleanScript),
workDir: config.Global.ProjectRoot,
}
}
// NewCleanerWithConfig 使用自定义配置创建 Python 清洗器
func NewCleanerWithConfig(pythonPath, scriptPath, workDir string) *Cleaner {
return &Cleaner{
pythonPath: pythonPath,
scriptPath: scriptPath,
workDir: workDir,
}
}
// Clean 执行 Python 清洗脚本
func (c *Cleaner) Clean(inputPath, outputPath string, opts *adapter.CleanOptions) (*adapter.CleanResult, error) {
// 构建命令参数
args := []string{c.scriptPath, inputPath, outputPath}
if opts != nil {
if opts.Year != "" {
args = append(args, "--year", opts.Year)
}
if opts.Month != "" {
args = append(args, "--month", opts.Month)
}
if opts.Start != "" {
args = append(args, "--start", opts.Start)
}
if opts.End != "" {
args = append(args, "--end", opts.End)
}
if opts.Format != "" {
args = append(args, "--format", opts.Format)
}
}
// 执行 Python 脚本
fmt.Printf("🐍 执行清洗脚本...\n")
cmd := exec.Command(c.pythonPath, args...)
cmd.Dir = c.workDir
output, err := cmd.CombinedOutput()
outputStr := string(output)
if err != nil {
return nil, fmt.Errorf("清洗脚本执行失败: %w\n输出: %s", err, outputStr)
}
// 从输出中检测账单类型
billType := detectBillTypeFromOutput(outputStr)
return &adapter.CleanResult{
BillType: billType,
Output: outputStr,
}, nil
}
// detectBillTypeFromOutput 从 Python 脚本输出中检测账单类型
func detectBillTypeFromOutput(output string) string {
if strings.Contains(output, "支付宝") {
return "alipay"
}
if strings.Contains(output, "微信") {
return "wechat"
}
return ""
}
// 确保 Cleaner 实现了 adapter.Cleaner 接口
var _ adapter.Cleaner = (*Cleaner)(nil)

BIN
server/billai-server.exe Normal file

Binary file not shown.

View File

@@ -1,18 +1,41 @@
# BillAI 服务器配置文件
# 应用版本
version: "0.0.1"
# 服务配置
server:
port: 8080
# Python 配置
# Python 配置 (subprocess 模式使用)
python:
# Python 解释器路径(相对于项目根目录或绝对路径)
path: analyzer/venv/bin/python
# 分析脚本路径(相对于项目根目录)
script: analyzer/clean_bill.py
# Analyzer 服务配置 (HTTP 模式使用)
analyzer:
# Python 分析服务 URL
url: http://localhost:8001
# 适配器模式: http (推荐) 或 subprocess
mode: http
# 文件目录配置(相对于项目根目录)
directories:
upload: server/uploads
output: server/outputs
# MongoDB 配置
mongodb:
# MongoDB 连接 URI带认证
uri: mongodb://admin:password@localhost:27017
# 数据库名称
database: billai
# 集合名称
collections:
# 原始数据集合
raw: bills_raw
# 清洗后数据集合
cleaned: bills_cleaned

View File

@@ -11,16 +11,28 @@ import (
// Config 服务配置
type Config struct {
Version string // 应用版本
Port string // 服务端口
ProjectRoot string // 项目根目录
PythonPath string // Python 解释器路径
CleanScript string // 清理脚本路径
UploadDir string // 上传文件目录
OutputDir string // 输出文件目录
// Analyzer 服务配置 (HTTP 模式)
AnalyzerURL string // Python 分析服务 URL
AnalyzerMode string // 适配器模式: http 或 subprocess
// MongoDB 配置
MongoURI string // MongoDB 连接 URI
MongoDatabase string // 数据库名称
MongoRawCollection string // 原始数据集合名称
MongoCleanedCollection string // 清洗后数据集合名称
}
// configFile YAML 配置文件结构
type configFile struct {
Version string `yaml:"version"`
Server struct {
Port int `yaml:"port"`
} `yaml:"server"`
@@ -28,10 +40,22 @@ type configFile struct {
Path string `yaml:"path"`
Script string `yaml:"script"`
} `yaml:"python"`
Analyzer struct {
URL string `yaml:"url"`
Mode string `yaml:"mode"` // http 或 subprocess
} `yaml:"analyzer"`
Directories struct {
Upload string `yaml:"upload"`
Output string `yaml:"output"`
} `yaml:"directories"`
MongoDB struct {
URI string `yaml:"uri"`
Database string `yaml:"database"`
Collections struct {
Raw string `yaml:"raw"`
Cleaned string `yaml:"cleaned"`
} `yaml:"collections"`
} `yaml:"mongodb"`
}
// Global 全局配置实例
@@ -95,6 +119,7 @@ func Load() {
flag.Parse()
// 设置默认值
Global.Version = "0.0.1"
Global.Port = getEnvOrDefault("PORT", "8080")
Global.ProjectRoot = getDefaultProjectRoot()
Global.PythonPath = getDefaultPythonPath()
@@ -102,6 +127,16 @@ func Load() {
Global.UploadDir = "server/uploads"
Global.OutputDir = "server/outputs"
// Analyzer 默认值
Global.AnalyzerURL = getEnvOrDefault("ANALYZER_URL", "http://localhost:8001")
Global.AnalyzerMode = getEnvOrDefault("ANALYZER_MODE", "http")
// MongoDB 默认值
Global.MongoURI = getEnvOrDefault("MONGO_URI", "mongodb://localhost:27017")
Global.MongoDatabase = getEnvOrDefault("MONGO_DATABASE", "billai")
Global.MongoRawCollection = getEnvOrDefault("MONGO_RAW_COLLECTION", "bills_raw")
Global.MongoCleanedCollection = getEnvOrDefault("MONGO_CLEANED_COLLECTION", "bills_cleaned")
// 查找配置文件
configPath := configFilePath
if !filepath.IsAbs(configPath) {
@@ -113,6 +148,9 @@ func Load() {
// 加载配置文件
if cfg := loadConfigFile(configPath); cfg != nil {
fmt.Printf("📄 加载配置文件: %s\n", configPath)
if cfg.Version != "" {
Global.Version = cfg.Version
}
if cfg.Server.Port > 0 {
Global.Port = fmt.Sprintf("%d", cfg.Server.Port)
}
@@ -128,6 +166,26 @@ func Load() {
if cfg.Directories.Output != "" {
Global.OutputDir = cfg.Directories.Output
}
// Analyzer 配置
if cfg.Analyzer.URL != "" {
Global.AnalyzerURL = cfg.Analyzer.URL
}
if cfg.Analyzer.Mode != "" {
Global.AnalyzerMode = cfg.Analyzer.Mode
}
// MongoDB 配置
if cfg.MongoDB.URI != "" {
Global.MongoURI = cfg.MongoDB.URI
}
if cfg.MongoDB.Database != "" {
Global.MongoDatabase = cfg.MongoDB.Database
}
if cfg.MongoDB.Collections.Raw != "" {
Global.MongoRawCollection = cfg.MongoDB.Collections.Raw
}
if cfg.MongoDB.Collections.Cleaned != "" {
Global.MongoCleanedCollection = cfg.MongoDB.Collections.Cleaned
}
}
// 环境变量覆盖
@@ -140,6 +198,26 @@ func Load() {
if root := os.Getenv("BILLAI_ROOT"); root != "" {
Global.ProjectRoot = root
}
// Analyzer 环境变量覆盖
if url := os.Getenv("ANALYZER_URL"); url != "" {
Global.AnalyzerURL = url
}
if mode := os.Getenv("ANALYZER_MODE"); mode != "" {
Global.AnalyzerMode = mode
}
// MongoDB 环境变量覆盖
if uri := os.Getenv("MONGO_URI"); uri != "" {
Global.MongoURI = uri
}
if db := os.Getenv("MONGO_DATABASE"); db != "" {
Global.MongoDatabase = db
}
if rawColl := os.Getenv("MONGO_RAW_COLLECTION"); rawColl != "" {
Global.MongoRawCollection = rawColl
}
if cleanedColl := os.Getenv("MONGO_CLEANED_COLLECTION"); cleanedColl != "" {
Global.MongoCleanedCollection = cleanedColl
}
}
// ResolvePath 解析路径(相对路径转为绝对路径)
@@ -149,4 +227,3 @@ func ResolvePath(path string) string {
}
return filepath.Join(Global.ProjectRoot, path)
}

72
server/database/mongo.go Normal file
View File

@@ -0,0 +1,72 @@
package database
import (
"context"
"fmt"
"time"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"billai-server/config"
)
var (
// Client MongoDB 客户端实例
Client *mongo.Client
// DB 数据库实例
DB *mongo.Database
// RawBillCollection 原始账单数据集合
RawBillCollection *mongo.Collection
// CleanedBillCollection 清洗后账单数据集合
CleanedBillCollection *mongo.Collection
)
// Connect 连接 MongoDB
func Connect() error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// 创建客户端选项
clientOptions := options.Client().ApplyURI(config.Global.MongoURI)
// 连接 MongoDB
client, err := mongo.Connect(ctx, clientOptions)
if err != nil {
return fmt.Errorf("连接 MongoDB 失败: %w", err)
}
// 测试连接
if err := client.Ping(ctx, nil); err != nil {
return fmt.Errorf("MongoDB Ping 失败: %w", err)
}
// 设置全局变量
Client = client
DB = client.Database(config.Global.MongoDatabase)
RawBillCollection = DB.Collection(config.Global.MongoRawCollection)
CleanedBillCollection = DB.Collection(config.Global.MongoCleanedCollection)
fmt.Printf("🍃 MongoDB 连接成功: %s\n", config.Global.MongoDatabase)
fmt.Printf(" 📄 原始数据集合: %s\n", config.Global.MongoRawCollection)
fmt.Printf(" 📄 清洗数据集合: %s\n", config.Global.MongoCleanedCollection)
return nil
}
// Disconnect 断开 MongoDB 连接
func Disconnect() error {
if Client == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := Client.Disconnect(ctx); err != nil {
return fmt.Errorf("断开 MongoDB 连接失败: %w", err)
}
fmt.Println("🍃 MongoDB 连接已断开")
return nil
}

View File

@@ -4,6 +4,7 @@ go 1.21
require (
github.com/gin-gonic/gin v1.9.1
go.mongodb.org/mongo-driver v1.13.1
gopkg.in/yaml.v3 v3.0.1
)
@@ -16,18 +17,26 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang/snappy v0.0.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.13.6 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.9.0 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect
golang.org/x/sys v0.8.0 // indirect
golang.org/x/text v0.9.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect

View File

@@ -24,11 +24,16 @@ github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QX
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc=
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
@@ -41,6 +46,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe h1:iruDEfMl2E6fbMZ9s0scYfZQ84/6SPL6zC8ACM2oIL0=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -60,19 +67,59 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA=
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver v1.13.1 h1:YIc7HTYsKndGK4RFzJ3covLz1byri52x0IoMB0Pt/vk=
go.mongodb.org/mongo-driver v1.13.1/go.mod h1:wcDf1JBCXy2mOW0bWHwO/IOYqdca1MPCwDtFu/Z9+eo=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=

200
server/handler/bills.go Normal file
View File

@@ -0,0 +1,200 @@
package handler
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"billai-server/model"
"billai-server/repository"
)
// ListBillsRequest 账单列表请求参数
type ListBillsRequest struct {
Page int `form:"page"` // 页码,从 1 开始
PageSize int `form:"page_size"` // 每页数量,默认 20
StartDate string `form:"start_date"` // 开始日期 YYYY-MM-DD
EndDate string `form:"end_date"` // 结束日期 YYYY-MM-DD
Category string `form:"category"` // 分类筛选
Type string `form:"type"` // 账单类型 alipay/wechat
IncomeExpense string `form:"income_expense"` // 收支类型 收入/支出
}
// ListBillsResponse 账单列表响应
type ListBillsResponse struct {
Result bool `json:"result"`
Message string `json:"message,omitempty"`
Data *ListBillsData `json:"data,omitempty"`
}
// ListBillsData 账单列表数据
type ListBillsData struct {
Total int64 `json:"total"` // 总记录数
TotalExpense float64 `json:"total_expense"` // 筛选条件下的总支出
TotalIncome float64 `json:"total_income"` // 筛选条件下的总收入
Page int `json:"page"` // 当前页码
PageSize int `json:"page_size"` // 每页数量
Pages int `json:"pages"` // 总页数
Bills []model.CleanedBill `json:"bills"` // 账单列表
}
// ListBills 获取清洗后的账单列表
func ListBills(c *gin.Context) {
var req ListBillsRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, ListBillsResponse{
Result: false,
Message: "参数解析失败: " + err.Error(),
})
return
}
// 设置默认值
if req.Page < 1 {
req.Page = 1
}
if req.PageSize < 1 {
req.PageSize = 20
}
if req.PageSize > 100 {
req.PageSize = 100 // 限制最大每页数量
}
// 构建筛选条件
filter := make(map[string]interface{})
// 时间范围筛选
if req.StartDate != "" || req.EndDate != "" {
timeFilter := make(map[string]interface{})
if req.StartDate != "" {
// 使用本地时区解析日期,避免 UTC 时区问题
startTime, err := time.ParseInLocation("2006-01-02", req.StartDate, time.Local)
if err == nil {
timeFilter["$gte"] = startTime
}
}
if req.EndDate != "" {
// 使用本地时区解析日期,避免 UTC 时区问题
endTime, err := time.ParseInLocation("2006-01-02", req.EndDate, time.Local)
if err == nil {
// 结束日期包含当天,所以加一天
endTime = endTime.Add(24 * time.Hour)
timeFilter["$lt"] = endTime
}
}
if len(timeFilter) > 0 {
filter["time"] = timeFilter
}
}
// 分类筛选
if req.Category != "" {
filter["category"] = req.Category
}
// 账单类型筛选
if req.Type != "" {
filter["bill_type"] = req.Type
}
// 收支类型筛选
if req.IncomeExpense != "" {
filter["income_expense"] = req.IncomeExpense
}
// 获取数据
repo := repository.GetRepository()
if repo == nil {
c.JSON(http.StatusInternalServerError, ListBillsResponse{
Result: false,
Message: "数据库未连接",
})
return
}
// 获取账单列表(带分页)
bills, total, err := repo.GetCleanedBillsPaged(filter, req.Page, req.PageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, ListBillsResponse{
Result: false,
Message: "查询失败: " + err.Error(),
})
return
}
// 获取聚合统计
totalExpense, totalIncome, err := repo.GetBillsAggregate(filter)
if err != nil {
c.JSON(http.StatusInternalServerError, ListBillsResponse{
Result: false,
Message: "统计失败: " + err.Error(),
})
return
}
// 计算总页数
pages := int(total) / req.PageSize
if int(total)%req.PageSize > 0 {
pages++
}
c.JSON(http.StatusOK, ListBillsResponse{
Result: true,
Data: &ListBillsData{
Total: total,
TotalExpense: totalExpense,
TotalIncome: totalIncome,
Page: req.Page,
PageSize: req.PageSize,
Pages: pages,
Bills: bills,
},
})
}
// parsePageParam 解析分页参数
func parsePageParam(s string, defaultVal int) int {
if s == "" {
return defaultVal
}
val, err := strconv.Atoi(s)
if err != nil || val < 1 {
return defaultVal
}
return val
}
// MonthlyStatsResponse 月度统计响应
type MonthlyStatsResponse struct {
Result bool `json:"result"`
Message string `json:"message,omitempty"`
Data []model.MonthlyStat `json:"data,omitempty"`
}
// MonthlyStats 获取月度统计数据(全部数据,不受筛选条件影响)
func MonthlyStats(c *gin.Context) {
repo := repository.GetRepository()
if repo == nil {
c.JSON(http.StatusInternalServerError, MonthlyStatsResponse{
Result: false,
Message: "数据库未连接",
})
return
}
stats, err := repo.GetMonthlyStats()
if err != nil {
c.JSON(http.StatusInternalServerError, MonthlyStatsResponse{
Result: false,
Message: "查询失败: " + err.Error(),
})
return
}
c.JSON(http.StatusOK, MonthlyStatsResponse{
Result: true,
Data: stats,
})
}

View File

@@ -5,15 +5,14 @@ import (
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
"billai-server/config"
"billai-server/model"
"billai-server/service"
)
// Upload 处理账单上传和清理请求
@@ -36,6 +35,23 @@ func Upload(c *gin.Context) {
req.Format = "csv"
}
// 验证 type 参数
if req.Type == "" {
c.JSON(http.StatusBadRequest, model.UploadResponse{
Result: false,
Message: "请指定账单类型 (type: alipay 或 wechat)",
})
return
}
if req.Type != "alipay" && req.Type != "wechat" {
c.JSON(http.StatusBadRequest, model.UploadResponse{
Result: false,
Message: "账单类型无效,仅支持 alipay 或 wechat",
})
return
}
billType := req.Type
// 3. 保存上传的文件
timestamp := time.Now().Format("20060102_150405")
inputFileName := fmt.Sprintf("%s_%s", timestamp, header.Filename)
@@ -53,67 +69,116 @@ func Upload(c *gin.Context) {
defer dst.Close()
io.Copy(dst, file)
// 4. 构建输出文件路径
baseName := strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))
outputExt := ".csv"
if req.Format == "json" {
outputExt = ".json"
}
outputFileName := fmt.Sprintf("%s_%s_cleaned%s", timestamp, baseName, outputExt)
outputDirAbs := config.ResolvePath(config.Global.OutputDir)
outputPath := filepath.Join(outputDirAbs, outputFileName)
// 5. 构建命令参数
cleanScriptAbs := config.ResolvePath(config.Global.CleanScript)
args := []string{cleanScriptAbs, inputPath, outputPath}
if req.Year != "" {
args = append(args, "--year", req.Year)
}
if req.Month != "" {
args = append(args, "--month", req.Month)
}
if req.Start != "" {
args = append(args, "--start", req.Start)
}
if req.End != "" {
args = append(args, "--end", req.End)
}
if req.Format != "" {
args = append(args, "--format", req.Format)
}
// 6. 执行 Python 脚本
pythonPathAbs := config.ResolvePath(config.Global.PythonPath)
cmd := exec.Command(pythonPathAbs, args...)
cmd.Dir = config.Global.ProjectRoot
output, err := cmd.CombinedOutput()
outputStr := string(output)
if err != nil {
// 4. 对原始数据进行去重检查
fmt.Printf("📋 开始去重检查...\n")
dedupResult, dedupErr := service.DeduplicateRawFile(inputPath, timestamp)
if dedupErr != nil {
c.JSON(http.StatusInternalServerError, model.UploadResponse{
Result: false,
Message: "处理失败: " + err.Error(),
Message: "去重检查失败: " + dedupErr.Error(),
})
return
}
// 7. 检测账单类型
billType := ""
if strings.Contains(outputStr, "支付宝") {
billType = "alipay"
} else if strings.Contains(outputStr, "微信") {
billType = "wechat"
fmt.Printf(" 原始记录: %d 条\n", dedupResult.OriginalCount)
if dedupResult.DuplicateCount > 0 {
fmt.Printf(" 重复记录: %d 条(已跳过)\n", dedupResult.DuplicateCount)
}
fmt.Printf(" 新增记录: %d 条\n", dedupResult.NewCount)
// 如果全部重复,返回提示
if dedupResult.NewCount == 0 {
c.JSON(http.StatusOK, model.UploadResponse{
Result: true,
Message: fmt.Sprintf("文件中的 %d 条记录全部已存在,无需重复导入", dedupResult.OriginalCount),
Data: &model.UploadData{
BillType: billType,
RawCount: 0,
CleanedCount: 0,
DuplicateCount: dedupResult.DuplicateCount,
},
})
return
}
// 使用去重后的文件路径进行后续处理
processFilePath := dedupResult.DedupFilePath
// 5. 构建输出文件路径时间_type_编号
outputExt := ".csv"
if req.Format == "json" {
outputExt = ".json"
}
outputDirAbs := config.ResolvePath(config.Global.OutputDir)
fileSeq := generateFileSequence(outputDirAbs, timestamp, billType, outputExt)
outputFileName := fmt.Sprintf("%s_%s_%s%s", timestamp, billType, fileSeq, outputExt)
outputPath := filepath.Join(outputDirAbs, outputFileName)
// 6. 执行 Python 清洗脚本
cleanOpts := &service.CleanOptions{
Year: req.Year,
Month: req.Month,
Start: req.Start,
End: req.End,
Format: req.Format,
}
_, cleanErr := service.RunCleanScript(processFilePath, outputPath, cleanOpts)
if cleanErr != nil {
c.JSON(http.StatusInternalServerError, model.UploadResponse{
Result: false,
Message: cleanErr.Error(),
})
return
}
// 7. 将去重后的原始数据存入 MongoDB原始数据集合
rawCount, rawErr := service.SaveRawBillsFromFile(processFilePath, billType, header.Filename, timestamp)
if rawErr != nil {
fmt.Printf("⚠️ 存储原始数据到 MongoDB 失败: %v\n", rawErr)
} else {
fmt.Printf("✅ 已存储 %d 条原始账单记录到 MongoDB\n", rawCount)
}
// 9. 将清洗后的数据存入 MongoDB清洗后数据集合
cleanedCount, _, cleanedErr := service.SaveCleanedBillsFromFile(outputPath, req.Format, billType, header.Filename, timestamp)
if cleanedErr != nil {
fmt.Printf("⚠️ 存储清洗后数据到 MongoDB 失败: %v\n", cleanedErr)
} else {
fmt.Printf("✅ 已存储 %d 条清洗后账单记录到 MongoDB\n", cleanedCount)
}
// 10. 清理临时的去重文件(如果生成了的话)
if dedupResult.DedupFilePath != inputPath && dedupResult.DedupFilePath != "" {
os.Remove(dedupResult.DedupFilePath)
}
// 11. 返回成功响应
message := fmt.Sprintf("处理成功,新增 %d 条记录", cleanedCount)
if dedupResult.DuplicateCount > 0 {
message = fmt.Sprintf("处理成功,新增 %d 条,跳过 %d 条重复记录", cleanedCount, dedupResult.DuplicateCount)
}
// 8. 返回成功响应
c.JSON(http.StatusOK, model.UploadResponse{
Result: true,
Message: "处理成功",
Message: message,
Data: &model.UploadData{
BillType: billType,
FileURL: fmt.Sprintf("/download/%s", outputFileName),
FileName: outputFileName,
BillType: billType,
FileURL: fmt.Sprintf("/download/%s", outputFileName),
FileName: outputFileName,
RawCount: rawCount,
CleanedCount: cleanedCount,
DuplicateCount: dedupResult.DuplicateCount,
},
})
}
// generateFileSequence 生成文件序号
// 根据当前目录下同一时间戳和类型的文件数量生成序号
func generateFileSequence(dir, timestamp, billType, ext string) string {
pattern := fmt.Sprintf("%s_%s_*%s", timestamp, billType, ext)
matches, err := filepath.Glob(filepath.Join(dir, pattern))
if err != nil || len(matches) == 0 {
return "001"
}
return fmt.Sprintf("%03d", len(matches)+1)
}

View File

@@ -2,13 +2,20 @@ package main
import (
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"github.com/gin-gonic/gin"
"billai-server/adapter"
adapterHttp "billai-server/adapter/http"
"billai-server/adapter/python"
"billai-server/config"
"billai-server/handler"
"billai-server/database"
"billai-server/repository"
repoMongo "billai-server/repository/mongo"
"billai-server/router"
)
func main() {
@@ -33,47 +40,65 @@ func main() {
fmt.Println(" 请在配置文件中指定正确的 Python 路径")
}
// 初始化适配器(外部服务交互层)
initAdapters()
// 初始化数据层
if err := initRepository(); err != nil {
fmt.Printf("⚠️ 警告: 数据层初始化失败: %v\n", err)
fmt.Println(" 账单数据将不会存储到数据库")
os.Exit(1)
}
// 连接 MongoDB保持兼容旧代码后续可移除
if err := database.Connect(); err != nil {
fmt.Printf("⚠️ 警告: MongoDB 连接失败: %v\n", err)
fmt.Println(" 账单数据将不会存储到数据库")
os.Exit(1)
} else {
// 优雅关闭时断开连接
defer database.Disconnect()
}
// 创建路由
r := gin.Default()
// 注册路由
setupRoutes(r, outputDirAbs, pythonPathAbs)
router.Setup(r, router.Config{
OutputDir: outputDirAbs,
Version: config.Global.Version,
})
// 监听系统信号
go func() {
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
fmt.Println("\n🛑 正在关闭服务...")
database.Disconnect()
os.Exit(0)
}()
// 启动服务
printAPIInfo()
r.Run(":" + config.Global.Port)
}
// setupRoutes 设置路由
func setupRoutes(r *gin.Engine, outputDirAbs, pythonPathAbs string) {
// 健康检查
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"python_path": pythonPathAbs,
})
})
// API 路由
api := r.Group("/api")
{
api.POST("/upload", handler.Upload)
api.GET("/review", handler.Review)
}
// 静态文件下载
r.Static("/download", outputDirAbs)
}
// printBanner 打印启动横幅
func printBanner(pythonPath, uploadDir, outputDir string) {
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
fmt.Println("📦 BillAI 账单分析服务")
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
fmt.Printf("📁 项目根目录: %s\n", config.Global.ProjectRoot)
fmt.Printf("🐍 Python路径: %s\n", pythonPath)
fmt.Printf("<EFBFBD> 适配器模式: %s\n", config.Global.AnalyzerMode)
if config.Global.AnalyzerMode == "http" {
fmt.Printf("🌐 分析服务: %s\n", config.Global.AnalyzerURL)
} else {
fmt.Printf("🐍 Python路径: %s\n", pythonPath)
}
fmt.Printf("📂 上传目录: %s\n", uploadDir)
fmt.Printf("📂 输出目录: %s\n", outputDir)
fmt.Printf("🍃 MongoDB: %s/%s\n", config.Global.MongoURI, config.Global.MongoDatabase)
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
}
@@ -82,8 +107,60 @@ func printAPIInfo() {
fmt.Printf("\n🚀 服务已启动: http://localhost:%s\n", config.Global.Port)
fmt.Println("📝 API 接口:")
fmt.Println(" POST /api/upload - 上传并分析账单")
fmt.Println(" GET /api/bills - 获取账单列表(支持分页和时间筛选)")
fmt.Println(" GET /api/review - 获取需要复核的记录")
fmt.Println(" GET /download/* - 下载结果文件")
fmt.Println(" GET /health - 健康检查")
fmt.Println()
}
// initAdapters 初始化适配器(外部服务交互层)
// 在这里配置与外部系统的交互方式
// 支持两种模式: http (推荐) 和 subprocess
func initAdapters() {
var cleaner adapter.Cleaner
switch config.Global.AnalyzerMode {
case "http":
// 使用 HTTP API 调用 Python 服务(推荐)
httpCleaner := adapterHttp.NewCleaner(config.Global.AnalyzerURL)
// 检查服务健康状态
if err := httpCleaner.HealthCheck(); err != nil {
fmt.Printf("⚠️ 警告: Python 分析服务不可用 (%s): %v\n", config.Global.AnalyzerURL, err)
fmt.Println(" 请确保分析服务已启动: cd analyzer && python server.py")
} else {
fmt.Printf("🌐 已连接到分析服务: %s\n", config.Global.AnalyzerURL)
}
cleaner = httpCleaner
case "subprocess":
// 使用子进程调用 Python 脚本(传统模式)
pythonCleaner := python.NewCleaner()
fmt.Println("🐍 使用子进程模式调用 Python")
cleaner = pythonCleaner
default:
// 默认使用 HTTP 模式
cleaner = adapterHttp.NewCleaner(config.Global.AnalyzerURL)
fmt.Printf("🌐 使用 HTTP 模式 (默认): %s\n", config.Global.AnalyzerURL)
}
adapter.SetCleaner(cleaner)
fmt.Println("🔌 适配器初始化完成")
}
// initRepository 初始化数据存储层
// 在这里配置数据持久化方式
// 后续可以通过修改这里来切换不同的存储实现(如 PostgreSQL、MySQL 等)
func initRepository() error {
// 初始化 MongoDB 存储
mongoRepo := repoMongo.NewRepository()
if err := mongoRepo.Connect(); err != nil {
return err
}
repository.SetRepository(mongoRepo)
fmt.Println("💾 数据层初始化完成")
return nil
}

47
server/model/bill.go Normal file
View File

@@ -0,0 +1,47 @@
package model
import (
"time"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// RawBill 原始账单记录(存储上传的原始数据)
type RawBill struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id,omitempty"`
BillType string `bson:"bill_type" json:"bill_type"` // 账单类型: alipay/wechat
SourceFile string `bson:"source_file" json:"source_file"` // 来源文件名
UploadBatch string `bson:"upload_batch" json:"upload_batch"` // 上传批次(时间戳)
RowIndex int `bson:"row_index" json:"row_index"` // 原始行号
RawData map[string]interface{} `bson:"raw_data" json:"raw_data"` // 原始字段数据
CreatedAt time.Time `bson:"created_at" json:"created_at"` // 创建时间
}
// CleanedBill 清洗后账单记录(标准化后的数据)
type CleanedBill struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id,omitempty"`
BillType string `bson:"bill_type" json:"bill_type"` // 账单类型: alipay/wechat
TransactionID string `bson:"transaction_id" json:"transaction_id"` // 交易订单号(用于去重)
MerchantOrderNo string `bson:"merchant_order_no" json:"merchant_order_no"` // 商家订单号(用于去重)
Time time.Time `bson:"time" json:"time"` // 交易时间
Category string `bson:"category" json:"category"` // 交易分类
Merchant string `bson:"merchant" json:"merchant"` // 交易对方
Description string `bson:"description" json:"description"` // 商品说明
IncomeExpense string `bson:"income_expense" json:"income_expense"` // 收/支
Amount float64 `bson:"amount" json:"amount"` // 金额
PayMethod string `bson:"pay_method" json:"pay_method"` // 支付方式
Status string `bson:"status" json:"status"` // 交易状态
Remark string `bson:"remark" json:"remark"` // 备注
ReviewLevel string `bson:"review_level" json:"review_level"` // 复核等级: HIGH/LOW/空
CreatedAt time.Time `bson:"created_at" json:"created_at"` // 创建时间
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` // 更新时间
SourceFile string `bson:"source_file" json:"source_file"` // 来源文件名
UploadBatch string `bson:"upload_batch" json:"upload_batch"` // 上传批次(时间戳)
}
// MonthlyStat 月度统计数据
type MonthlyStat struct {
Month string `bson:"month" json:"month"` // 月份 YYYY-MM
Expense float64 `bson:"expense" json:"expense"` // 月支出总额
Income float64 `bson:"income" json:"income"` // 月收入总额
}

View File

@@ -2,10 +2,10 @@ package model
// UploadRequest 上传请求参数
type UploadRequest struct {
Type string `form:"type"` // 账单类型: alipay/wechat必填
Year string `form:"year"` // 年份筛选
Month string `form:"month"` // 月份筛选
Start string `form:"start"` // 起始日期
End string `form:"end"` // 结束日期
Format string `form:"format"` // 输出格式: csv/json
}

View File

@@ -2,9 +2,12 @@ package model
// UploadData 上传响应数据
type UploadData struct {
BillType string `json:"bill_type,omitempty"` // alipay/wechat
FileURL string `json:"file_url,omitempty"` // 下载链接
FileName string `json:"file_name,omitempty"` // 文件名
BillType string `json:"bill_type,omitempty"` // alipay/wechat
FileURL string `json:"file_url,omitempty"` // 下载链接
FileName string `json:"file_name,omitempty"` // 文件名
RawCount int `json:"raw_count,omitempty"` // 存储到原始数据集合的记录数
CleanedCount int `json:"cleaned_count,omitempty"` // 存储到清洗后数据集合的记录数
DuplicateCount int `json:"duplicate_count,omitempty"` // 重复跳过的记录数
}
// UploadResponse 上传响应

View File

@@ -0,0 +1,14 @@
// Package repository 全局存储实例管理
package repository
var globalRepo BillRepository
// SetRepository 设置全局存储实例
func SetRepository(r BillRepository) {
globalRepo = r
}
// GetRepository 获取全局存储实例
func GetRepository() BillRepository {
return globalRepo
}

View File

@@ -0,0 +1,407 @@
// Package mongo 实现基于 MongoDB 的账单存储
package mongo
import (
"context"
"fmt"
"time"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"billai-server/config"
"billai-server/model"
"billai-server/repository"
)
// Repository MongoDB 账单存储实现
type Repository struct {
client *mongo.Client
db *mongo.Database
rawCollection *mongo.Collection
cleanedCollection *mongo.Collection
}
// NewRepository 创建 MongoDB 存储实例
func NewRepository() *Repository {
return &Repository{}
}
// Connect 连接 MongoDB
func (r *Repository) Connect() error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// 创建客户端选项
clientOptions := options.Client().ApplyURI(config.Global.MongoURI)
// 连接 MongoDB
client, err := mongo.Connect(ctx, clientOptions)
if err != nil {
return fmt.Errorf("连接 MongoDB 失败: %w", err)
}
// 测试连接
if err := client.Ping(ctx, nil); err != nil {
return fmt.Errorf("MongoDB Ping 失败: %w", err)
}
// 设置实例变量
r.client = client
r.db = client.Database(config.Global.MongoDatabase)
r.rawCollection = r.db.Collection(config.Global.MongoRawCollection)
r.cleanedCollection = r.db.Collection(config.Global.MongoCleanedCollection)
fmt.Printf("🍃 MongoDB 连接成功: %s\n", config.Global.MongoDatabase)
fmt.Printf(" 📄 原始数据集合: %s\n", config.Global.MongoRawCollection)
fmt.Printf(" 📄 清洗数据集合: %s\n", config.Global.MongoCleanedCollection)
return nil
}
// Disconnect 断开 MongoDB 连接
func (r *Repository) Disconnect() error {
if r.client == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := r.client.Disconnect(ctx); err != nil {
return fmt.Errorf("断开 MongoDB 连接失败: %w", err)
}
fmt.Println("🍃 MongoDB 连接已断开")
return nil
}
// SaveRawBills 保存原始账单数据
func (r *Repository) SaveRawBills(bills []model.RawBill) (int, error) {
if len(bills) == 0 {
return 0, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// 转换为 interface{} 切片
docs := make([]interface{}, len(bills))
for i, bill := range bills {
docs[i] = bill
}
result, err := r.rawCollection.InsertMany(ctx, docs)
if err != nil {
return 0, fmt.Errorf("插入原始账单失败: %w", err)
}
return len(result.InsertedIDs), nil
}
// SaveCleanedBills 保存清洗后的账单数据(带去重)
func (r *Repository) SaveCleanedBills(bills []model.CleanedBill) (saved int, duplicates int, err error) {
if len(bills) == 0 {
return 0, 0, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
for _, bill := range bills {
// 检查是否重复
isDup, err := r.CheckCleanedDuplicate(&bill)
if err != nil {
return saved, duplicates, err
}
if isDup {
duplicates++
continue
}
// 插入新记录
_, err = r.cleanedCollection.InsertOne(ctx, bill)
if err != nil {
return saved, duplicates, fmt.Errorf("插入清洗后账单失败: %w", err)
}
saved++
}
return saved, duplicates, nil
}
// CheckRawDuplicate 检查原始数据是否重复
func (r *Repository) CheckRawDuplicate(fieldName, value string) (bool, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
filter := bson.M{"raw_data." + fieldName: value}
count, err := r.rawCollection.CountDocuments(ctx, filter)
if err != nil {
return false, err
}
return count > 0, nil
}
// CheckCleanedDuplicate 检查清洗后数据是否重复
func (r *Repository) CheckCleanedDuplicate(bill *model.CleanedBill) (bool, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var filter bson.M
if bill.TransactionID != "" {
// 优先用交易订单号判断
filter = bson.M{"transaction_id": bill.TransactionID}
} else {
// 回退到 时间+金额+商户 组合判断
filter = bson.M{
"time": bill.Time,
"amount": bill.Amount,
"merchant": bill.Merchant,
}
}
count, err := r.cleanedCollection.CountDocuments(ctx, filter)
if err != nil {
return false, err
}
return count > 0, nil
}
// CountRawByField 按字段统计原始数据数量
func (r *Repository) CountRawByField(fieldName, value string) (int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
filter := bson.M{"raw_data." + fieldName: value}
return r.rawCollection.CountDocuments(ctx, filter)
}
// GetCleanedBills 获取清洗后的账单列表
func (r *Repository) GetCleanedBills(filter map[string]interface{}) ([]model.CleanedBill, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// 转换 filter
bsonFilter := bson.M{}
for k, v := range filter {
bsonFilter[k] = v
}
// 按时间倒序排列
opts := options.Find().SetSort(bson.D{{Key: "time", Value: -1}})
cursor, err := r.cleanedCollection.Find(ctx, bsonFilter, opts)
if err != nil {
return nil, fmt.Errorf("查询账单失败: %w", err)
}
defer cursor.Close(ctx)
var bills []model.CleanedBill
if err := cursor.All(ctx, &bills); err != nil {
return nil, fmt.Errorf("解析账单数据失败: %w", err)
}
return bills, nil
}
// GetCleanedBillsPaged 获取清洗后的账单列表(带分页)
func (r *Repository) GetCleanedBillsPaged(filter map[string]interface{}, page, pageSize int) ([]model.CleanedBill, int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// 转换 filter
bsonFilter := bson.M{}
for k, v := range filter {
bsonFilter[k] = v
}
// 计算总数
total, err := r.cleanedCollection.CountDocuments(ctx, bsonFilter)
if err != nil {
return nil, 0, fmt.Errorf("统计账单数量失败: %w", err)
}
// 计算跳过数量
skip := int64((page - 1) * pageSize)
// 查询选项:分页 + 按时间倒序
opts := options.Find().
SetSort(bson.D{{Key: "time", Value: -1}}).
SetSkip(skip).
SetLimit(int64(pageSize))
cursor, err := r.cleanedCollection.Find(ctx, bsonFilter, opts)
if err != nil {
return nil, 0, fmt.Errorf("查询账单失败: %w", err)
}
defer cursor.Close(ctx)
var bills []model.CleanedBill
if err := cursor.All(ctx, &bills); err != nil {
return nil, 0, fmt.Errorf("解析账单数据失败: %w", err)
}
return bills, total, nil
}
// GetBillsAggregate 获取账单聚合统计(总收入、总支出)
func (r *Repository) GetBillsAggregate(filter map[string]interface{}) (totalExpense float64, totalIncome float64, err error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// 转换 filter
bsonFilter := bson.M{}
for k, v := range filter {
bsonFilter[k] = v
}
// 使用聚合管道按 income_expense 分组统计金额
pipeline := mongo.Pipeline{
{{Key: "$match", Value: bsonFilter}},
{{Key: "$group", Value: bson.D{
{Key: "_id", Value: "$income_expense"},
{Key: "total", Value: bson.D{{Key: "$sum", Value: "$amount"}}},
}}},
}
cursor, err := r.cleanedCollection.Aggregate(ctx, pipeline)
if err != nil {
return 0, 0, fmt.Errorf("聚合统计失败: %w", err)
}
defer cursor.Close(ctx)
// 解析结果
var results []struct {
ID string `bson:"_id"`
Total float64 `bson:"total"`
}
if err := cursor.All(ctx, &results); err != nil {
return 0, 0, fmt.Errorf("解析聚合结果失败: %w", err)
}
for _, result := range results {
switch result.ID {
case "支出":
totalExpense = result.Total
case "收入":
totalIncome = result.Total
}
}
return totalExpense, totalIncome, nil
}
// GetBillsNeedReview 获取需要复核的账单
func (r *Repository) GetBillsNeedReview() ([]model.CleanedBill, error) {
filter := map[string]interface{}{
"review_level": bson.M{"$in": []string{"HIGH", "LOW"}},
}
return r.GetCleanedBills(filter)
}
// GetMonthlyStats 获取月度统计(全部数据,不受筛选条件影响)
func (r *Repository) GetMonthlyStats() ([]model.MonthlyStat, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// 使用聚合管道按月份分组统计
// 先按月份和收支类型分组,再汇总
pipeline := mongo.Pipeline{
// 添加月份字段
{{Key: "$addFields", Value: bson.D{
{Key: "month", Value: bson.D{
{Key: "$dateToString", Value: bson.D{
{Key: "format", Value: "%Y-%m"},
{Key: "date", Value: "$time"},
}},
}},
}}},
// 按月份和收支类型分组
{{Key: "$group", Value: bson.D{
{Key: "_id", Value: bson.D{
{Key: "month", Value: "$month"},
{Key: "income_expense", Value: "$income_expense"},
}},
{Key: "total", Value: bson.D{{Key: "$sum", Value: "$amount"}}},
}}},
// 按月份重新分组,汇总收入和支出
{{Key: "$group", Value: bson.D{
{Key: "_id", Value: "$_id.month"},
{Key: "expense", Value: bson.D{
{Key: "$sum", Value: bson.D{
{Key: "$cond", Value: bson.A{
bson.D{{Key: "$eq", Value: bson.A{"$_id.income_expense", "支出"}}},
"$total",
0,
}},
}},
}},
{Key: "income", Value: bson.D{
{Key: "$sum", Value: bson.D{
{Key: "$cond", Value: bson.A{
bson.D{{Key: "$eq", Value: bson.A{"$_id.income_expense", "收入"}}},
"$total",
0,
}},
}},
}},
}}},
// 按月份排序
{{Key: "$sort", Value: bson.D{{Key: "_id", Value: 1}}}},
}
cursor, err := r.cleanedCollection.Aggregate(ctx, pipeline)
if err != nil {
return nil, fmt.Errorf("月度统计聚合失败: %w", err)
}
defer cursor.Close(ctx)
// 解析结果
var results []struct {
Month string `bson:"_id"`
Expense float64 `bson:"expense"`
Income float64 `bson:"income"`
}
if err := cursor.All(ctx, &results); err != nil {
return nil, fmt.Errorf("解析月度统计结果失败: %w", err)
}
// 转换为 MonthlyStat
stats := make([]model.MonthlyStat, len(results))
for i, r := range results {
stats[i] = model.MonthlyStat{
Month: r.Month,
Expense: r.Expense,
Income: r.Income,
}
}
return stats, nil
}
// GetClient 获取 MongoDB 客户端(用于兼容旧代码)
func (r *Repository) GetClient() *mongo.Client {
return r.client
}
// GetDB 获取数据库实例(用于兼容旧代码)
func (r *Repository) GetDB() *mongo.Database {
return r.db
}
// GetRawCollection 获取原始数据集合(用于兼容旧代码)
func (r *Repository) GetRawCollection() *mongo.Collection {
return r.rawCollection
}
// GetCleanedCollection 获取清洗后数据集合(用于兼容旧代码)
func (r *Repository) GetCleanedCollection() *mongo.Collection {
return r.cleanedCollection
}
// 确保 Repository 实现了 repository.BillRepository 接口
var _ repository.BillRepository = (*Repository)(nil)

View File

@@ -0,0 +1,48 @@
// Package repository 定义数据存储层接口
// 负责所有数据持久化操作的抽象
package repository
import "billai-server/model"
// BillRepository 账单存储接口
type BillRepository interface {
// Connect 建立连接
Connect() error
// Disconnect 断开连接
Disconnect() error
// SaveRawBills 保存原始账单数据
SaveRawBills(bills []model.RawBill) (int, error)
// SaveCleanedBills 保存清洗后的账单数据
// 返回: 保存数量、重复数量、错误
SaveCleanedBills(bills []model.CleanedBill) (saved int, duplicates int, err error)
// CheckRawDuplicate 检查原始数据是否重复
CheckRawDuplicate(fieldName, value string) (bool, error)
// CheckCleanedDuplicate 检查清洗后数据是否重复
CheckCleanedDuplicate(bill *model.CleanedBill) (bool, error)
// GetCleanedBills 获取清洗后的账单列表
GetCleanedBills(filter map[string]interface{}) ([]model.CleanedBill, error)
// GetCleanedBillsPaged 获取清洗后的账单列表(带分页)
// 返回: 账单列表、总数、错误
GetCleanedBillsPaged(filter map[string]interface{}, page, pageSize int) ([]model.CleanedBill, int64, error)
// GetBillsAggregate 获取账单聚合统计(总收入、总支出)
// 返回: 总支出、总收入、错误
GetBillsAggregate(filter map[string]interface{}) (totalExpense float64, totalIncome float64, err error)
// GetMonthlyStats 获取月度统计(全部数据,不受筛选条件影响)
// 返回: 月度统计列表、错误
GetMonthlyStats() ([]model.MonthlyStat, error)
// GetBillsNeedReview 获取需要复核的账单
GetBillsNeedReview() ([]model.CleanedBill, error)
// CountRawByField 按字段统计原始数据数量
CountRawByField(fieldName, value string) (int64, error)
}

56
server/router/router.go Normal file
View File

@@ -0,0 +1,56 @@
// Package router 路由配置
package router
import (
"net/http"
"github.com/gin-gonic/gin"
"billai-server/handler"
)
// Config 路由配置参数
type Config struct {
OutputDir string // 输出目录(用于静态文件服务)
Version string // 应用版本
}
// Setup 设置所有路由
func Setup(r *gin.Engine, cfg Config) {
// 健康检查
r.GET("/health", healthCheck(cfg.Version))
// API 路由组
setupAPIRoutes(r)
// 静态文件下载
r.Static("/download", cfg.OutputDir)
}
// healthCheck 健康检查处理器
func healthCheck(version string) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"version": version,
})
}
}
// setupAPIRoutes 设置 API 路由
func setupAPIRoutes(r *gin.Engine) {
api := r.Group("/api")
{
// 账单上传
api.POST("/upload", handler.Upload)
// 复核相关
api.GET("/review", handler.Review)
// 账单查询
api.GET("/bills", handler.ListBills)
// 月度统计(全部数据)
api.GET("/monthly-stats", handler.MonthlyStats)
}
}

632
server/service/bill.go Normal file
View File

@@ -0,0 +1,632 @@
package service
import (
"context"
"encoding/csv"
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"time"
"go.mongodb.org/mongo-driver/bson"
"billai-server/database"
"billai-server/model"
)
// SaveResult 存储结果
type SaveResult struct {
RawCount int // 原始数据存储数量
CleanedCount int // 清洗后数据存储数量
DuplicateCount int // 重复数据跳过数量
}
// checkDuplicate 检查记录是否重复
// 优先使用 transaction_id 判断,如果为空则使用 时间+金额+商户 组合判断
func checkDuplicate(ctx context.Context, bill *model.CleanedBill) bool {
var filter bson.M
if bill.TransactionID != "" {
// 优先用交易订单号判断
filter = bson.M{"transaction_id": bill.TransactionID}
} else {
// 回退到 时间+金额+商户 组合判断
filter = bson.M{
"time": bill.Time,
"amount": bill.Amount,
"merchant": bill.Merchant,
}
}
count, err := database.CleanedBillCollection.CountDocuments(ctx, filter)
if err != nil {
return false // 查询出错时不认为是重复
}
return count > 0
}
// DeduplicateResult 去重结果
type DeduplicateResult struct {
OriginalCount int // 原始记录数
DuplicateCount int // 重复记录数
NewCount int // 新记录数
DedupFilePath string // 去重后的文件路径(如果有去重则生成新文件)
BillType string // 检测到的账单类型
}
// DeduplicateRawFile 对原始文件进行去重检查,返回去重后的文件路径
// 如果全部重复,返回错误
func DeduplicateRawFile(filePath, uploadBatch string) (*DeduplicateResult, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("打开文件失败: %w", err)
}
defer file.Close()
reader := csv.NewReader(file)
rows, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("读取 CSV 失败: %w", err)
}
if len(rows) < 2 {
return nil, fmt.Errorf("文件没有数据行")
}
header := rows[0]
dataRows := rows[1:]
// 检测账单类型和去重字段
billType, idFieldIdx := detectBillTypeAndIdField(header)
result := &DeduplicateResult{
OriginalCount: len(dataRows),
BillType: billType,
}
// 如果找不到去重字段,不进行去重,直接返回原文件
if idFieldIdx < 0 {
result.NewCount = len(dataRows)
result.DedupFilePath = filePath
return result, nil
}
// 创建上下文
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// 检查每行是否重复
var newRows [][]string
for _, row := range dataRows {
if len(row) <= idFieldIdx {
continue
}
transactionID := strings.TrimSpace(row[idFieldIdx])
if transactionID == "" {
// 没有交易号的行,保留
newRows = append(newRows, row)
continue
}
// 检查是否已存在
count, err := database.RawBillCollection.CountDocuments(ctx, bson.M{
"raw_data." + header[idFieldIdx]: transactionID,
})
if err != nil {
// 查询出错,保留该行
newRows = append(newRows, row)
continue
}
if count == 0 {
// 不重复,保留
newRows = append(newRows, row)
} else {
result.DuplicateCount++
}
}
result.NewCount = len(newRows)
// 如果没有新数据
if len(newRows) == 0 {
result.DedupFilePath = ""
return result, nil
}
// 如果没有重复,直接返回原文件
if result.DuplicateCount == 0 {
result.DedupFilePath = filePath
return result, nil
}
// 有重复,生成去重后的新文件
dedupFilePath := strings.TrimSuffix(filePath, ".csv") + "_dedup.csv"
dedupFile, err := os.Create(dedupFilePath)
if err != nil {
return nil, fmt.Errorf("创建去重文件失败: %w", err)
}
defer dedupFile.Close()
writer := csv.NewWriter(dedupFile)
writer.Write(header) // 写入表头
for _, row := range newRows {
writer.Write(row)
}
writer.Flush()
result.DedupFilePath = dedupFilePath
return result, nil
}
// detectBillTypeAndIdField 检测账单类型和用于去重的字段索引
func detectBillTypeAndIdField(header []string) (billType string, idFieldIdx int) {
idFieldIdx = -1
for i, col := range header {
// 支付宝特征
if col == "交易分类" || col == "对方账号" {
billType = "alipay"
}
// 微信特征
if col == "交易类型" || col == "金额(元)" {
billType = "wechat"
}
// 查找去重字段(优先使用交易订单号/交易号)
if col == "交易订单号" || col == "交易号" || col == "交易单号" {
idFieldIdx = i
}
}
// 如果没找到主要去重字段,尝试商户订单号
if idFieldIdx < 0 {
for i, col := range header {
if col == "商家订单号" || col == "商户单号" || col == "商户订单号" {
idFieldIdx = i
break
}
}
}
return billType, idFieldIdx
}
// SaveRawBillsFromFile 从原始上传文件读取数据并存入原始数据集合
func SaveRawBillsFromFile(filePath, billType, sourceFile, uploadBatch string) (int, error) {
file, err := os.Open(filePath)
if err != nil {
return 0, fmt.Errorf("打开文件失败: %w", err)
}
defer file.Close()
reader := csv.NewReader(file)
rows, err := reader.ReadAll()
if err != nil {
return 0, fmt.Errorf("读取 CSV 失败: %w", err)
}
if len(rows) < 2 {
return 0, nil // 没有数据行
}
// 获取表头
header := rows[0]
now := time.Now()
// 构建原始数据文档
var rawBills []interface{}
for rowIdx, row := range rows[1:] {
rawData := make(map[string]interface{})
for colIdx, col := range header {
if colIdx < len(row) {
// 清理空白字符,确保去重查询能匹配
rawData[col] = strings.TrimSpace(row[colIdx])
}
}
rawBill := model.RawBill{
BillType: billType,
SourceFile: sourceFile,
UploadBatch: uploadBatch,
RowIndex: rowIdx + 1, // 从1开始计数
RawData: rawData,
CreatedAt: now,
}
rawBills = append(rawBills, rawBill)
}
if len(rawBills) == 0 {
return 0, nil
}
// 批量插入原始数据集合
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result, err := database.RawBillCollection.InsertMany(ctx, rawBills)
if err != nil {
return 0, fmt.Errorf("插入原始数据失败: %w", err)
}
return len(result.InsertedIDs), nil
}
// SaveCleanedBillsFromFile 从清洗后的文件读取数据并存入清洗后数据集合
// 返回: (插入数量, 重复跳过数量, 错误)
func SaveCleanedBillsFromFile(filePath, format, billType, sourceFile, uploadBatch string) (int, int, error) {
if format == "json" {
return saveCleanedBillsFromJSON(filePath, billType, sourceFile, uploadBatch)
}
return saveCleanedBillsFromCSV(filePath, billType, sourceFile, uploadBatch)
}
// saveCleanedBillsFromCSV 从 CSV 文件读取并存储清洗后账单
// 返回: (插入数量, 重复跳过数量, 错误)
func saveCleanedBillsFromCSV(filePath, billType, sourceFile, uploadBatch string) (int, int, error) {
file, err := os.Open(filePath)
if err != nil {
return 0, 0, fmt.Errorf("打开文件失败: %w", err)
}
defer file.Close()
reader := csv.NewReader(file)
rows, err := reader.ReadAll()
if err != nil {
return 0, 0, fmt.Errorf("读取 CSV 失败: %w", err)
}
if len(rows) < 2 {
return 0, 0, nil // 没有数据行
}
// 构建列索引映射
header := rows[0]
colIdx := make(map[string]int)
for i, col := range header {
colIdx[col] = i
}
// 创建上下文用于去重检查
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
// 解析数据行
var bills []interface{}
duplicateCount := 0
now := time.Now()
for _, row := range rows[1:] {
bill := model.CleanedBill{
BillType: billType,
SourceFile: sourceFile,
UploadBatch: uploadBatch,
CreatedAt: now,
UpdatedAt: now,
}
// 提取字段 - 订单号(用于去重判断)
if idx, ok := colIdx["交易订单号"]; ok && len(row) > idx {
bill.TransactionID = strings.TrimSpace(row[idx])
} else if idx, ok := colIdx["交易单号"]; ok && len(row) > idx {
bill.TransactionID = strings.TrimSpace(row[idx])
}
if idx, ok := colIdx["商家订单号"]; ok && len(row) > idx {
bill.MerchantOrderNo = strings.TrimSpace(row[idx])
} else if idx, ok := colIdx["商户单号"]; ok && len(row) > idx {
bill.MerchantOrderNo = strings.TrimSpace(row[idx])
}
if idx, ok := colIdx["交易时间"]; ok && len(row) > idx {
bill.Time = parseTime(row[idx])
}
if idx, ok := colIdx["交易分类"]; ok && len(row) > idx {
bill.Category = row[idx]
} else if idx, ok := colIdx["交易类型"]; ok && len(row) > idx {
bill.Category = row[idx]
}
if idx, ok := colIdx["交易对方"]; ok && len(row) > idx {
bill.Merchant = row[idx]
}
if idx, ok := colIdx["商品说明"]; ok && len(row) > idx {
bill.Description = row[idx]
} else if idx, ok := colIdx["商品"]; ok && len(row) > idx {
bill.Description = row[idx]
}
if idx, ok := colIdx["收/支"]; ok && len(row) > idx {
bill.IncomeExpense = row[idx]
}
if idx, ok := colIdx["金额"]; ok && len(row) > idx {
bill.Amount = parseAmount(row[idx])
} else if idx, ok := colIdx["金额(元)"]; ok && len(row) > idx {
bill.Amount = parseAmount(row[idx])
}
if idx, ok := colIdx["收/付款方式"]; ok && len(row) > idx {
bill.PayMethod = row[idx]
} else if idx, ok := colIdx["支付方式"]; ok && len(row) > idx {
bill.PayMethod = row[idx]
}
if idx, ok := colIdx["交易状态"]; ok && len(row) > idx {
bill.Status = row[idx]
} else if idx, ok := colIdx["当前状态"]; ok && len(row) > idx {
bill.Status = row[idx]
}
if idx, ok := colIdx["备注"]; ok && len(row) > idx {
bill.Remark = row[idx]
}
if idx, ok := colIdx["复核等级"]; ok && len(row) > idx {
bill.ReviewLevel = row[idx]
}
// 检查是否重复
if checkDuplicate(ctx, &bill) {
duplicateCount++
continue // 跳过重复记录
}
bills = append(bills, bill)
}
if len(bills) == 0 {
return 0, duplicateCount, nil
}
// 批量插入清洗后数据集合
result, err := database.CleanedBillCollection.InsertMany(ctx, bills)
if err != nil {
return 0, duplicateCount, fmt.Errorf("插入清洗后数据失败: %w", err)
}
return len(result.InsertedIDs), duplicateCount, nil
}
// saveCleanedBillsFromJSON 从 JSON 文件读取并存储清洗后账单
// 返回: (插入数量, 重复跳过数量, 错误)
func saveCleanedBillsFromJSON(filePath, billType, sourceFile, uploadBatch string) (int, int, error) {
file, err := os.Open(filePath)
if err != nil {
return 0, 0, fmt.Errorf("打开文件失败: %w", err)
}
defer file.Close()
var data []map[string]interface{}
decoder := json.NewDecoder(file)
if err := decoder.Decode(&data); err != nil {
return 0, 0, fmt.Errorf("解析 JSON 失败: %w", err)
}
if len(data) == 0 {
return 0, 0, nil
}
// 创建上下文用于去重检查
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
// 解析数据
var bills []interface{}
duplicateCount := 0
now := time.Now()
for _, item := range data {
bill := model.CleanedBill{
BillType: billType,
SourceFile: sourceFile,
UploadBatch: uploadBatch,
CreatedAt: now,
UpdatedAt: now,
}
// 订单号(用于去重判断)
if v, ok := item["交易订单号"].(string); ok {
bill.TransactionID = strings.TrimSpace(v)
} else if v, ok := item["交易号"].(string); ok {
bill.TransactionID = strings.TrimSpace(v)
}
if v, ok := item["商家订单号"].(string); ok {
bill.MerchantOrderNo = strings.TrimSpace(v)
} else if v, ok := item["商户单号"].(string); ok {
bill.MerchantOrderNo = strings.TrimSpace(v)
}
if v, ok := item["交易时间"].(string); ok {
bill.Time = parseTime(v)
}
if v, ok := item["交易分类"].(string); ok {
bill.Category = v
}
if v, ok := item["交易对方"].(string); ok {
bill.Merchant = v
}
if v, ok := item["商品说明"].(string); ok {
bill.Description = v
}
if v, ok := item["收/支"].(string); ok {
bill.IncomeExpense = v
}
if v, ok := item["金额"]; ok {
switch val := v.(type) {
case string:
bill.Amount = parseAmount(val)
case float64:
bill.Amount = val
}
}
if v, ok := item["支付方式"].(string); ok {
bill.PayMethod = v
}
if v, ok := item["交易状态"].(string); ok {
bill.Status = v
}
if v, ok := item["备注"].(string); ok {
bill.Remark = v
}
if v, ok := item["复核等级"].(string); ok {
bill.ReviewLevel = v
}
// 检查是否重复
if checkDuplicate(ctx, &bill) {
duplicateCount++
continue // 跳过重复记录
}
bills = append(bills, bill)
}
if len(bills) == 0 {
return 0, duplicateCount, nil
}
result, err := database.CleanedBillCollection.InsertMany(ctx, bills)
if err != nil {
return 0, duplicateCount, fmt.Errorf("插入清洗后数据失败: %w", err)
}
return len(result.InsertedIDs), duplicateCount, nil
}
// parseTime 解析时间字符串
// 使用本地时区解析,避免 UTC 时区问题
func parseTime(s string) time.Time {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}
}
// 尝试多种时间格式(使用本地时区)
formats := []string{
"2006-01-02 15:04:05",
"2006/01/02 15:04:05",
"2006-01-02 15:04",
"2006/01/02 15:04",
"2006-01-02",
"2006/01/02",
}
for _, format := range formats {
if t, err := time.ParseInLocation(format, s, time.Local); err == nil {
return t
}
}
return time.Time{}
}
// parseAmount 解析金额字符串
func parseAmount(s string) float64 {
s = strings.TrimSpace(s)
s = strings.ReplaceAll(s, ",", "")
s = strings.ReplaceAll(s, "¥", "")
s = strings.ReplaceAll(s, "¥", "")
if amount, err := strconv.ParseFloat(s, 64); err == nil {
return amount
}
return 0
}
// GetCleanedBillsByBatch 根据批次获取清洗后账单
func GetCleanedBillsByBatch(uploadBatch string) ([]model.CleanedBill, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cursor, err := database.CleanedBillCollection.Find(ctx, bson.M{"upload_batch": uploadBatch})
if err != nil {
return nil, fmt.Errorf("查询失败: %w", err)
}
defer cursor.Close(ctx)
var bills []model.CleanedBill
if err := cursor.All(ctx, &bills); err != nil {
return nil, fmt.Errorf("解析结果失败: %w", err)
}
return bills, nil
}
// GetRawBillsByBatch 根据批次获取原始账单
func GetRawBillsByBatch(uploadBatch string) ([]model.RawBill, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cursor, err := database.RawBillCollection.Find(ctx, bson.M{"upload_batch": uploadBatch})
if err != nil {
return nil, fmt.Errorf("查询失败: %w", err)
}
defer cursor.Close(ctx)
var bills []model.RawBill
if err := cursor.All(ctx, &bills); err != nil {
return nil, fmt.Errorf("解析结果失败: %w", err)
}
return bills, nil
}
// GetBillStats 获取账单统计信息
func GetBillStats() (map[string]interface{}, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// 原始数据总数
rawTotal, err := database.RawBillCollection.CountDocuments(ctx, bson.M{})
if err != nil {
return nil, err
}
// 清洗后数据总数
cleanedTotal, err := database.CleanedBillCollection.CountDocuments(ctx, bson.M{})
if err != nil {
return nil, err
}
// 支出总额(从清洗后数据统计)
expensePipeline := []bson.M{
{"$match": bson.M{"income_expense": "支出"}},
{"$group": bson.M{"_id": nil, "total": bson.M{"$sum": "$amount"}}},
}
expenseCursor, err := database.CleanedBillCollection.Aggregate(ctx, expensePipeline)
if err != nil {
return nil, err
}
defer expenseCursor.Close(ctx)
var expenseResult []bson.M
expenseCursor.All(ctx, &expenseResult)
totalExpense := 0.0
if len(expenseResult) > 0 {
if v, ok := expenseResult[0]["total"].(float64); ok {
totalExpense = v
}
}
// 收入总额(从清洗后数据统计)
incomePipeline := []bson.M{
{"$match": bson.M{"income_expense": "收入"}},
{"$group": bson.M{"_id": nil, "total": bson.M{"$sum": "$amount"}}},
}
incomeCursor, err := database.CleanedBillCollection.Aggregate(ctx, incomePipeline)
if err != nil {
return nil, err
}
defer incomeCursor.Close(ctx)
var incomeResult []bson.M
incomeCursor.All(ctx, &incomeResult)
totalIncome := 0.0
if len(incomeResult) > 0 {
if v, ok := incomeResult[0]["total"].(float64); ok {
totalIncome = v
}
}
return map[string]interface{}{
"raw_records": rawTotal,
"cleaned_records": cleanedTotal,
"total_expense": totalExpense,
"total_income": totalIncome,
}, nil
}

48
server/service/cleaner.go Normal file
View File

@@ -0,0 +1,48 @@
// Package service 业务逻辑层
package service
import (
"billai-server/adapter"
)
// CleanOptions 清洗选项(保持向后兼容)
type CleanOptions = adapter.CleanOptions
// CleanResult 清洗结果(保持向后兼容)
type CleanResult = adapter.CleanResult
// RunCleanScript 执行清洗脚本(使用适配器)
// inputPath: 输入文件路径
// outputPath: 输出文件路径
// opts: 清洗选项
func RunCleanScript(inputPath, outputPath string, opts *CleanOptions) (*CleanResult, error) {
cleaner := adapter.GetCleaner()
return cleaner.Clean(inputPath, outputPath, opts)
}
// DetectBillTypeFromOutput 从脚本输出中检测账单类型
// 保留此函数以兼容其他调用
func DetectBillTypeFromOutput(output string) string {
if containsSubstring(output, "支付宝") {
return "alipay"
}
if containsSubstring(output, "微信") {
return "wechat"
}
return ""
}
// containsSubstring 检查字符串是否包含子串
func containsSubstring(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
(len(s) > 0 && len(substr) > 0 && findSubstring(s, substr)))
}
func findSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

23
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

2
web/.npmrc Normal file
View File

@@ -0,0 +1,2 @@
engine-strict=true
registry=https://registry.npmmirror.com

9
web/.prettierignore Normal file
View File

@@ -0,0 +1,9 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/

17
web/.prettierrc Normal file
View File

@@ -0,0 +1,17 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": [
"prettier-plugin-svelte"
],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}

56
web/Dockerfile Normal file
View File

@@ -0,0 +1,56 @@
# SvelteKit Web 前端 Dockerfile
# 多阶段构建:构建阶段 + 运行阶段
# ===== 构建阶段 =====
FROM node:20-alpine AS builder
WORKDIR /app
# 配置 yarn 镜像源(国内)
RUN yarn config set registry https://registry.npmmirror.com
# 先复制依赖文件,利用 Docker 缓存
COPY package.json yarn.lock* ./
# 安装依赖
RUN yarn install --frozen-lockfile || yarn install
# 复制源代码
COPY . .
# 构建生产版本
RUN yarn build
# ===== 运行阶段 =====
FROM node:20-alpine
WORKDIR /app
# 配置 Alpine 镜像源(国内)
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
# 安装必要工具
RUN apk --no-cache add curl
# 设置时区
ENV TZ=Asia/Shanghai
# 设置为生产环境
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
# 从构建阶段复制构建产物和依赖
COPY --from=builder /app/build ./build
COPY --from=builder /app/package.json ./
COPY --from=builder /app/node_modules ./node_modules
# 暴露端口
EXPOSE 3000
# 健康检查
HEALTHCHECK --interval=10s --timeout=5s --retries=5 \
CMD curl -f http://localhost:3000 || exit 1
# 启动服务
CMD ["node", "build"]

38
web/README.md Normal file
View File

@@ -0,0 +1,38 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

16
web/components.json Normal file
View File

@@ -0,0 +1,16 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/app.css",
"baseColor": "neutral"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry"
}

41
web/eslint.config.js Normal file
View File

@@ -0,0 +1,41 @@
import prettier from 'eslint-config-prettier';
import { fileURLToPath } from 'node:url';
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import { defineConfig } from 'eslint/config';
import globals from 'globals';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default defineConfig(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: { globals: { ...globals.browser, ...globals.node } },
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
"no-undef": 'off'
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig
}
}
}
);

53
web/package.json Normal file
View File

@@ -0,0 +1,53 @@
{
"name": "web",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"test:unit": "vitest",
"test": "npm run test:unit -- --run"
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.39.1",
"@internationalized/date": "^3.10.0",
"@lucide/svelte": "^0.561.0",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.49.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.18",
"@types/d3-scale": "^4.0.9",
"@types/d3-shape": "^3.1.7",
"@types/node": "^18",
"@vitest/browser-playwright": "^4.0.15",
"bits-ui": "^2.14.4",
"clsx": "^2.1.1",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.13.1",
"globals": "^16.5.0",
"layerchart": "2.0.0-next.43",
"playwright": "^1.57.0",
"prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.0",
"svelte": "^5.45.6",
"svelte-check": "^4.3.4",
"tailwind-merge": "^3.4.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.48.1",
"vite": "^7.2.6",
"vitest": "^4.0.15",
"vitest-browser-svelte": "^2.0.1"
}
}

128
web/src/app.css Normal file
View File

@@ -0,0 +1,128 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
/* 移除图表元素的 focus outline */
svg *:focus,
svg *:focus-visible,
[data-layerchart] *:focus,
[data-layerchart] *:focus-visible {
outline: none !important;
}
}

13
web/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

11
web/src/app.html Normal file
View File

@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

7
web/src/demo.spec.ts Normal file
View File

@@ -0,0 +1,7 @@
import { describe, it, expect } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});

277
web/src/lib/api.ts Normal file
View File

@@ -0,0 +1,277 @@
// API 配置 - 使用相对路径,由 SvelteKit 代理到后端
const API_BASE = '';
// 健康检查
export async function checkHealth(): Promise<boolean> {
try {
const response = await fetch(`${API_BASE}/health`, {
method: 'GET',
signal: AbortSignal.timeout(3000) // 3秒超时
});
return response.ok;
} catch {
return false;
}
}
// 类型定义
export type BillType = 'alipay' | 'wechat';
export interface UploadData {
bill_type: BillType;
file_url: string;
file_name: string;
raw_count: number;
cleaned_count: number;
duplicate_count?: number;
}
export interface UploadResponse {
result: boolean;
message: string;
data?: UploadData;
}
export interface ReviewRecord {
time: string;
category: string;
merchant: string;
description: string;
income_expense: string;
amount: string;
remark: string;
review_level: 'HIGH' | 'LOW';
}
export interface ReviewData {
total: number;
high: number;
low: number;
records: ReviewRecord[];
}
export interface ReviewResponse {
result: boolean;
message: string;
data?: ReviewData;
}
// 月度统计数据
export interface MonthlyStat {
month: string; // YYYY-MM
expense: number;
income: number;
}
export interface MonthlyStatsResponse {
result: boolean;
message?: string;
data?: MonthlyStat[];
}
export interface BillRecord {
time: string;
category: string;
merchant: string;
description: string;
income_expense: string;
amount: string;
payment_method: string;
status: string;
remark: string;
needs_review: string;
}
// 上传账单
export async function uploadBill(
file: File,
type: BillType,
options?: { year?: number; month?: number }
): Promise<UploadResponse> {
const formData = new FormData();
formData.append('file', file);
formData.append('type', type);
if (options?.year) {
formData.append('year', options.year.toString());
}
if (options?.month) {
formData.append('month', options.month.toString());
}
const response = await fetch(`${API_BASE}/api/upload`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}
// 获取复核记录
export async function getReviewRecords(fileName: string): Promise<ReviewResponse> {
const response = await fetch(`${API_BASE}/api/review?file=${encodeURIComponent(fileName)}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}
// 获取月度统计(全部数据,不受筛选条件影响)
export async function fetchMonthlyStats(): Promise<MonthlyStatsResponse> {
const response = await fetch(`${API_BASE}/api/monthly-stats`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}
// 下载文件 URL
export function getDownloadUrl(fileUrl: string): string {
return `${API_BASE}${fileUrl}`;
}
// 解析账单内容(用于前端展示全部记录)
export async function fetchBillContent(fileName: string): Promise<BillRecord[]> {
const response = await fetch(`${API_BASE}/download/${fileName}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const text = await response.text();
return parseCSV(text);
}
// 解析 CSV
function parseCSV(text: string): BillRecord[] {
const lines = text.trim().split('\n');
if (lines.length < 2) return [];
const records: BillRecord[] = [];
// CSV 格式:交易时间,交易分类,交易对方,对方账号,商品说明,收/支,金额,收/付款方式,交易状态,交易订单号,商家订单号,备注,,复核等级
for (let i = 1; i < lines.length; i++) {
const values = parseCSVLine(lines[i]);
if (values.length >= 7) {
records.push({
time: values[0] || '',
category: values[1] || '',
merchant: values[2] || '',
description: values[4] || '', // 跳过 values[3] (对方账号)
income_expense: values[5] || '',
amount: values[6] || '',
payment_method: values[7] || '',
status: values[8] || '',
remark: values[11] || '',
needs_review: values[13] || '', // 复核等级在第14列
});
}
}
return records;
}
// 解析 CSV 行(处理引号)
function parseCSVLine(line: string): string[] {
const result: string[] = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
if (inQuotes && line[i + 1] === '"') {
current += '"';
i++;
} else {
inQuotes = !inQuotes;
}
} else if (char === ',' && !inQuotes) {
result.push(current);
current = '';
} else {
current += char;
}
}
result.push(current);
return result;
}
// 清洗后的账单记录
export interface CleanedBill {
id: string;
bill_type: string;
time: string;
category: string;
merchant: string;
description: string;
income_expense: string;
amount: number;
pay_method: string;
status: string;
remark: string;
review_level: string;
}
// 账单列表请求参数
export interface FetchBillsParams {
page?: number;
page_size?: number;
start_date?: string;
end_date?: string;
category?: string;
type?: string; // 账单来源 alipay/wechat
income_expense?: string; // 收支类型 收入/支出
}
// 账单列表响应
export interface BillsResponse {
result: boolean;
message?: string;
data?: {
total: number;
total_expense: number; // 筛选条件下的总支出
total_income: number; // 筛选条件下的总收入
page: number;
page_size: number;
pages: number;
bills: CleanedBill[];
};
}
// 获取账单列表(支持分页和筛选)
export async function fetchBills(params: FetchBillsParams = {}): Promise<BillsResponse> {
const searchParams = new URLSearchParams();
if (params.page) searchParams.set('page', params.page.toString());
if (params.page_size) searchParams.set('page_size', params.page_size.toString());
if (params.start_date) searchParams.set('start_date', params.start_date);
if (params.end_date) searchParams.set('end_date', params.end_date);
if (params.category) searchParams.set('category', params.category);
if (params.type) searchParams.set('type', params.type);
if (params.income_expense) searchParams.set('income_expense', params.income_expense);
const queryString = searchParams.toString();
const url = `${API_BASE}/api/bills${queryString ? '?' + queryString : ''}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,483 @@
<script lang="ts">
import * as Table from '$lib/components/ui/table';
import * as Drawer from '$lib/components/ui/drawer';
import * as Select from '$lib/components/ui/select';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import ArrowUpDown from '@lucide/svelte/icons/arrow-up-down';
import ArrowUp from '@lucide/svelte/icons/arrow-up';
import ArrowDown from '@lucide/svelte/icons/arrow-down';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import Receipt from '@lucide/svelte/icons/receipt';
import Pencil from '@lucide/svelte/icons/pencil';
import Save from '@lucide/svelte/icons/save';
import X from '@lucide/svelte/icons/x';
import Calendar from '@lucide/svelte/icons/calendar';
import Store from '@lucide/svelte/icons/store';
import Tag from '@lucide/svelte/icons/tag';
import FileText from '@lucide/svelte/icons/file-text';
import CreditCard from '@lucide/svelte/icons/credit-card';
import type { BillRecord } from '$lib/api';
interface Props {
records: BillRecord[];
showCategory?: boolean;
showDescription?: boolean;
pageSize?: number;
categories?: string[];
onUpdate?: (updated: BillRecord, original: BillRecord) => void;
}
let {
records = $bindable(),
showCategory = false,
showDescription = true,
pageSize = 10,
categories = [],
onUpdate
}: Props = $props();
// 排序状态
type SortField = 'time' | 'category' | 'merchant' | 'description' | 'amount';
type SortOrder = 'asc' | 'desc';
let sortField = $state<SortField>('time');
let sortOrder = $state<SortOrder>('desc');
// 分页状态
let currentPage = $state(1);
// 详情弹窗状态
let detailDialogOpen = $state(false);
let selectedRecord = $state<BillRecord | null>(null);
let selectedIndex = $state(-1);
let isEditing = $state(false);
let editForm = $state({
amount: '',
merchant: '',
category: '',
description: '',
payment_method: ''
});
// 排序后的记录
let sortedRecords = $derived.by(() => {
return records.toSorted((a, b) => {
let cmp = 0;
switch (sortField) {
case 'time':
cmp = new Date(a.time).getTime() - new Date(b.time).getTime();
break;
case 'category':
cmp = a.category.localeCompare(b.category);
break;
case 'merchant':
cmp = a.merchant.localeCompare(b.merchant);
break;
case 'description':
cmp = (a.description || '').localeCompare(b.description || '');
break;
case 'amount':
cmp = parseFloat(a.amount) - parseFloat(b.amount);
break;
}
return sortOrder === 'asc' ? cmp : -cmp;
});
});
// 分页计算
let totalPages = $derived(Math.ceil(sortedRecords.length / pageSize));
let paginatedRecords = $derived(
sortedRecords.slice((currentPage - 1) * pageSize, currentPage * pageSize)
);
function toggleSort(field: SortField) {
if (sortField === field) {
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
} else {
sortField = field;
sortOrder = field === 'amount' ? 'desc' : 'asc';
}
currentPage = 1;
}
function goToPage(page: number) {
if (page >= 1 && page <= totalPages) {
currentPage = page;
}
}
// 打开详情弹窗
function openDetail(record: BillRecord, index: number) {
selectedRecord = record;
selectedIndex = index;
isEditing = false;
detailDialogOpen = true;
}
// 进入编辑模式
function startEdit() {
if (!selectedRecord) return;
editForm = {
amount: selectedRecord.amount,
merchant: selectedRecord.merchant,
category: selectedRecord.category,
description: selectedRecord.description || '',
payment_method: selectedRecord.payment_method || ''
};
isEditing = true;
}
// 取消编辑
function cancelEdit() {
isEditing = false;
}
// 保存编辑
function saveEdit() {
if (!selectedRecord) return;
const original = { ...selectedRecord };
const updated: BillRecord = {
...selectedRecord,
amount: editForm.amount,
merchant: editForm.merchant,
category: editForm.category,
description: editForm.description,
payment_method: editForm.payment_method
};
// 更新本地数据
const idx = records.findIndex(r =>
r.time === selectedRecord!.time &&
r.merchant === selectedRecord!.merchant &&
r.amount === selectedRecord!.amount
);
if (idx !== -1) {
records[idx] = updated;
records = [...records]; // 触发响应式更新
}
selectedRecord = updated;
isEditing = false;
// 通知父组件
onUpdate?.(updated, original);
}
// 处理分类选择
function handleCategoryChange(value: string | undefined) {
if (value) {
editForm.category = value;
}
}
// 重置分页(当记录变化时)
$effect(() => {
records;
currentPage = 1;
sortField = 'time';
sortOrder = 'desc';
});
</script>
{#if records.length > 0}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-[140px]">
<button
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
onclick={() => toggleSort('time')}
>
时间
{#if sortField === 'time'}
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
{:else}
<ArrowUpDown class="h-3 w-3 opacity-30" />
{/if}
</button>
</Table.Head>
{#if showCategory}
<Table.Head class="w-[100px]">
<button
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
onclick={() => toggleSort('category')}
>
分类
{#if sortField === 'category'}
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
{:else}
<ArrowUpDown class="h-3 w-3 opacity-30" />
{/if}
</button>
</Table.Head>
{/if}
<Table.Head>
<button
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
onclick={() => toggleSort('merchant')}
>
商家
{#if sortField === 'merchant'}
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
{:else}
<ArrowUpDown class="h-3 w-3 opacity-30" />
{/if}
</button>
</Table.Head>
{#if showDescription}
<Table.Head>
<button
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
onclick={() => toggleSort('description')}
>
描述
{#if sortField === 'description'}
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
{:else}
<ArrowUpDown class="h-3 w-3 opacity-30" />
{/if}
</button>
</Table.Head>
{/if}
<Table.Head class="text-right w-[100px]">
<button
class="flex items-center gap-1 ml-auto hover:text-foreground transition-colors outline-none"
onclick={() => toggleSort('amount')}
>
金额
{#if sortField === 'amount'}
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
{:else}
<ArrowUpDown class="h-3 w-3 opacity-30" />
{/if}
</button>
</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each paginatedRecords as record, i}
<Table.Row
class="hover:bg-muted/50 transition-colors cursor-pointer"
onclick={() => openDetail(record, (currentPage - 1) * pageSize + i)}
>
<Table.Cell class="text-muted-foreground text-xs">
{record.time.substring(0, 16)}
</Table.Cell>
{#if showCategory}
<Table.Cell class="text-sm">{record.category}</Table.Cell>
{/if}
<Table.Cell class="font-medium">{record.merchant}</Table.Cell>
{#if showDescription}
<Table.Cell class="text-muted-foreground truncate max-w-[200px]">
{record.description || '-'}
</Table.Cell>
{/if}
<Table.Cell class="text-right font-mono text-red-600 dark:text-red-400">
¥{record.amount}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
<!-- 分页控件 -->
{#if totalPages > 1}
<div class="flex items-center justify-between mt-4 pt-4 border-t">
<div class="text-sm text-muted-foreground">
显示 {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, sortedRecords.length)} 条,共 {sortedRecords.length}
</div>
<div class="flex items-center gap-1">
<Button
variant="outline"
size="icon"
class="h-8 w-8"
disabled={currentPage === 1}
onclick={() => goToPage(currentPage - 1)}
>
<ChevronLeft class="h-4 w-4" />
</Button>
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as page}
{#if page === 1 || page === totalPages || (page >= currentPage - 1 && page <= currentPage + 1)}
<Button
variant={page === currentPage ? 'default' : 'outline'}
size="icon"
class="h-8 w-8"
onclick={() => goToPage(page)}
>
{page}
</Button>
{:else if page === currentPage - 2 || page === currentPage + 2}
<span class="px-1 text-muted-foreground">...</span>
{/if}
{/each}
<Button
variant="outline"
size="icon"
class="h-8 w-8"
disabled={currentPage === totalPages}
onclick={() => goToPage(currentPage + 1)}
>
<ChevronRight class="h-4 w-4" />
</Button>
</div>
</div>
{/if}
{:else}
<div class="text-center py-8 text-muted-foreground">
暂无记录
</div>
{/if}
<!-- 详情/编辑弹窗 -->
<Drawer.Root bind:open={detailDialogOpen}>
<Drawer.Content class="sm:max-w-md">
<Drawer.Header>
<Drawer.Title class="flex items-center gap-2">
<Receipt class="h-5 w-5" />
{isEditing ? '编辑账单' : '账单详情'}
</Drawer.Title>
<Drawer.Description>
{isEditing ? '修改这笔支出的信息' : '查看这笔支出的详细信息'}
</Drawer.Description>
</Drawer.Header>
{#if selectedRecord}
{#if isEditing}
<!-- 编辑表单 -->
<div class="space-y-4 py-4 px-4 md:px-0">
<div class="space-y-2">
<Label>金额</Label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">¥</span>
<Input
type="number"
bind:value={editForm.amount}
class="pl-8"
step="0.01"
/>
</div>
</div>
<div class="space-y-2">
<Label>商家</Label>
<Input bind:value={editForm.merchant} />
</div>
<div class="space-y-2">
<Label>分类</Label>
{#if categories.length > 0}
<Select.Root type="single" value={editForm.category} onValueChange={handleCategoryChange}>
<Select.Trigger class="w-full">
<span>{editForm.category || '选择分类'}</span>
</Select.Trigger>
<Select.Portal>
<Select.Content>
{#each categories as category}
<Select.Item value={category}>{category}</Select.Item>
{/each}
</Select.Content>
</Select.Portal>
</Select.Root>
{:else}
<Input bind:value={editForm.category} />
{/if}
</div>
<div class="space-y-2">
<Label>描述</Label>
<Input bind:value={editForm.description} />
</div>
<div class="space-y-2">
<Label>支付方式</Label>
<Input bind:value={editForm.payment_method} />
</div>
</div>
{:else}
<!-- 详情展示 -->
<div class="py-4 px-4 md:px-0">
<div class="text-center mb-6">
<div class="text-3xl font-bold text-red-600 dark:text-red-400 font-mono">
¥{selectedRecord.amount}
</div>
<div class="text-sm text-muted-foreground mt-1">
支出金额
</div>
</div>
<div class="space-y-3">
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
<Store class="h-4 w-4 text-muted-foreground shrink-0" />
<div class="min-w-0">
<div class="text-xs text-muted-foreground">商家</div>
<div class="font-medium truncate">{selectedRecord.merchant}</div>
</div>
</div>
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
<Tag class="h-4 w-4 text-muted-foreground shrink-0" />
<div class="min-w-0">
<div class="text-xs text-muted-foreground">分类</div>
<div class="font-medium">{selectedRecord.category}</div>
</div>
</div>
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
<Calendar class="h-4 w-4 text-muted-foreground shrink-0" />
<div class="min-w-0">
<div class="text-xs text-muted-foreground">时间</div>
<div class="font-medium">{selectedRecord.time}</div>
</div>
</div>
{#if selectedRecord.description}
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
<FileText class="h-4 w-4 text-muted-foreground shrink-0" />
<div class="min-w-0">
<div class="text-xs text-muted-foreground">描述</div>
<div class="font-medium">{selectedRecord.description}</div>
</div>
</div>
{/if}
{#if selectedRecord.payment_method}
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
<CreditCard class="h-4 w-4 text-muted-foreground shrink-0" />
<div class="min-w-0">
<div class="text-xs text-muted-foreground">支付方式</div>
<div class="font-medium">{selectedRecord.payment_method}</div>
</div>
</div>
{/if}
</div>
</div>
{/if}
{/if}
<Drawer.Footer>
{#if isEditing}
<Button variant="outline" onclick={cancelEdit}>
<X class="h-4 w-4 mr-2" />
取消
</Button>
<Button onclick={saveEdit}>
<Save class="h-4 w-4 mr-2" />
保存
</Button>
{:else}
<Button variant="outline" onclick={() => detailDialogOpen = false}>
关闭
</Button>
<Button onclick={startEdit}>
<Pencil class="h-4 w-4 mr-2" />
编辑
</Button>
{/if}
</Drawer.Footer>
</Drawer.Content>
</Drawer.Root>

View File

@@ -0,0 +1,225 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import * as Drawer from '$lib/components/ui/drawer';
import { Button } from '$lib/components/ui/button';
import PieChartIcon from '@lucide/svelte/icons/pie-chart';
import ListIcon from '@lucide/svelte/icons/list';
import type { CategoryStat, PieChartDataItem } from '$lib/types/analysis';
import type { BillRecord } from '$lib/api';
import { getPercentage } from '$lib/services/analysis';
import { barColors } from '$lib/constants/chart';
import BillRecordsTable from './BillRecordsTable.svelte';
interface Props {
categoryStats: CategoryStat[];
pieChartData: PieChartDataItem[];
totalExpense: number;
records: BillRecord[];
categories?: string[];
}
let { categoryStats, pieChartData, totalExpense, records = $bindable(), categories = [] }: Props = $props();
let mode = $state<'bar' | 'pie'>('bar');
let dialogOpen = $state(false);
let selectedCategory = $state<string | null>(null);
let hoveredPieIndex = $state<number | null>(null);
// 隐藏的类别
let hiddenCategories = $state<Set<string>>(new Set());
function toggleCategory(category: string) {
const newSet = new Set(hiddenCategories);
if (newSet.has(category)) {
newSet.delete(category);
} else {
newSet.add(category);
}
hiddenCategories = newSet;
}
// 过滤后的饼图数据(排除隐藏的类别)
let filteredPieChartData = $derived.by(() => {
const visible = pieChartData.filter(item => !hiddenCategories.has(item.category));
const total = visible.reduce((sum, item) => sum + item.value, 0);
// 重新计算百分比
return visible.map(item => ({
...item,
percentage: total > 0 ? (item.value / total) * 100 : 0
}));
});
// 过滤后的总支出
let filteredTotalExpense = $derived(
filteredPieChartData.reduce((sum, item) => sum + item.value, 0)
);
let expenseStats = $derived(categoryStats.filter(s => s.expense > 0));
// 获取选中分类的账单记录
let selectedRecords = $derived.by(() => {
if (!selectedCategory) return [];
return records.filter(r => r.category === selectedCategory && r.income_expense === '支出');
});
// 选中分类的统计
let selectedStat = $derived(
selectedCategory ? categoryStats.find(s => s.category === selectedCategory) : null
);
function openCategoryDetail(category: string) {
selectedCategory = category;
dialogOpen = true;
}
</script>
<Card.Root class="transition-all duration-200 hover:shadow-lg hover:-translate-y-1">
<Card.Header class="flex flex-row items-center justify-between pb-2">
<div class="space-y-1.5">
<Card.Title class="flex items-center gap-2">
<PieChartIcon class="h-5 w-5" />
分类支出排行
</Card.Title>
<Card.Description>各分类支出金额占比(点击查看详情)</Card.Description>
</div>
<div class="flex gap-1">
<Button
variant={mode === 'bar' ? 'default' : 'outline'}
size="icon"
class="h-8 w-8"
onclick={() => mode = 'bar'}
>
<ListIcon class="h-4 w-4" />
</Button>
<Button
variant={mode === 'pie' ? 'default' : 'outline'}
size="icon"
class="h-8 w-8"
onclick={() => mode = 'pie'}
>
<PieChartIcon class="h-4 w-4" />
</Button>
</div>
</Card.Header>
<Card.Content>
{#if mode === 'bar'}
<!-- 条形图视图 -->
<div class="space-y-1">
{#each expenseStats as stat, i}
<button
class="w-full text-left space-y-1 p-2 rounded-lg bg-muted/30 hover:bg-muted hover:shadow-sm hover:scale-[1.02] transition-all duration-150 cursor-pointer outline-none focus:outline-none"
onclick={() => openCategoryDetail(stat.category)}
>
<div class="flex items-center justify-between text-sm">
<span class="font-medium">{stat.category}</span>
<span class="font-mono">¥{stat.expense.toFixed(2)}</span>
</div>
<div class="h-2 rounded-full bg-muted overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500 {barColors[i % barColors.length]}"
style="width: {getPercentage(stat.expense, totalExpense)}%"
></div>
</div>
<div class="flex justify-between text-xs text-muted-foreground">
<span>{stat.count}</span>
<span>{getPercentage(stat.expense, totalExpense).toFixed(1)}%</span>
</div>
</button>
{/each}
</div>
{:else}
<!-- 饼状图视图 -->
<div class="flex flex-col items-center">
<div class="relative">
<svg width="220" height="220" viewBox="-110 -110 220 220">
{#each filteredPieChartData as item, i}
{@const total = filteredPieChartData.reduce((sum, d) => sum + d.value, 0)}
{@const startAngle = filteredPieChartData.slice(0, i).reduce((sum, d) => sum + (d.value / total) * Math.PI * 2, 0) - Math.PI / 2}
{@const endAngle = startAngle + (item.value / total) * Math.PI * 2}
{@const innerRadius = 45}
{@const outerRadius = hoveredPieIndex === i ? 95 : 90}
{@const x1 = Math.cos(startAngle) * outerRadius}
{@const y1 = Math.sin(startAngle) * outerRadius}
{@const x2 = Math.cos(endAngle) * outerRadius}
{@const y2 = Math.sin(endAngle) * outerRadius}
{@const x3 = Math.cos(endAngle) * innerRadius}
{@const y3 = Math.sin(endAngle) * innerRadius}
{@const x4 = Math.cos(startAngle) * innerRadius}
{@const y4 = Math.sin(startAngle) * innerRadius}
{@const largeArc = (endAngle - startAngle) > Math.PI ? 1 : 0}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<path
d="M {x1} {y1} A {outerRadius} {outerRadius} 0 {largeArc} 1 {x2} {y2} L {x3} {y3} A {innerRadius} {innerRadius} 0 {largeArc} 0 {x4} {y4} Z"
fill={item.color}
class="transition-all duration-200 cursor-pointer outline-none focus:outline-none"
style="transform-origin: center; filter: drop-shadow(0 2px 4px rgba(0,0,0,{hoveredPieIndex === i ? '0.3' : '0.15'})); opacity: {hoveredPieIndex !== null && hoveredPieIndex !== i ? '0.6' : '1'};"
onclick={() => openCategoryDetail(item.category)}
onmouseenter={() => hoveredPieIndex = i}
onmouseleave={() => hoveredPieIndex = null}
role="button"
tabindex="-1"
/>
{/each}
<!-- 中心文字 -->
{#if hoveredPieIndex !== null && filteredPieChartData[hoveredPieIndex]}
{@const hovered = filteredPieChartData[hoveredPieIndex]}
<text x="0" y="-12" text-anchor="middle" class="fill-foreground text-xs font-medium">{hovered.category}</text>
<text x="0" y="6" text-anchor="middle" class="fill-foreground text-sm font-bold font-mono">¥{hovered.value.toFixed(0)}</text>
<text x="0" y="22" text-anchor="middle" class="fill-muted-foreground text-xs">{hovered.percentage.toFixed(1)}%</text>
{:else}
<text x="0" y="-8" text-anchor="middle" class="fill-foreground text-sm font-medium">总支出</text>
<text x="0" y="12" text-anchor="middle" class="fill-foreground text-lg font-bold font-mono">¥{filteredTotalExpense.toFixed(0)}</text>
{/if}
</svg>
</div>
<!-- 图例 (点击可切换显示) -->
<div class="mt-4 grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
{#each pieChartData as item}
{@const isHidden = hiddenCategories.has(item.category)}
{@const filteredItem = filteredPieChartData.find(f => f.category === item.category)}
{@const displayPercentage = isHidden ? item.percentage : (filteredItem?.percentage ?? 0)}
<button
class="flex items-center gap-2 px-2 py-1 -mx-1 cursor-pointer hover:opacity-80 transition-opacity outline-none"
onclick={() => toggleCategory(item.category)}
title={isHidden ? '点击显示' : '点击隐藏'}
>
<span
class="h-3 w-3 rounded-sm shrink-0 transition-opacity {isHidden ? 'opacity-30' : ''}"
style="background-color: {item.color}"
></span>
<span class="truncate transition-colors {isHidden ? 'text-muted-foreground/40 line-through' : 'text-muted-foreground'}">{item.category}</span>
<span class="font-mono font-medium ml-auto transition-colors {isHidden ? 'text-muted-foreground/40' : ''}">{displayPercentage.toFixed(1)}%</span>
</button>
{/each}
</div>
</div>
{/if}
</Card.Content>
</Card.Root>
<!-- 分类详情弹窗 -->
<Drawer.Root bind:open={dialogOpen}>
<Drawer.Content class="sm:max-w-4xl">
<Drawer.Header>
<Drawer.Title class="flex items-center gap-2">
<PieChartIcon class="h-5 w-5" />
{selectedCategory} - 账单明细
</Drawer.Title>
<Drawer.Description>
{#if selectedStat}
{selectedStat.count} 笔,合计 ¥{selectedStat.expense.toFixed(2)}
{/if}
</Drawer.Description>
</Drawer.Header>
<div class="flex-1 overflow-auto px-4 md:px-0">
<BillRecordsTable records={selectedRecords} showDescription={true} {categories} />
</div>
<Drawer.Footer>
<Button variant="outline" onclick={() => dialogOpen = false}>
关闭
</Button>
</Drawer.Footer>
</Drawer.Content>
</Drawer.Root>

View File

@@ -0,0 +1,897 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import * as Select from '$lib/components/ui/select';
import * as Drawer from '$lib/components/ui/drawer';
import Activity from '@lucide/svelte/icons/activity';
import TrendingUp from '@lucide/svelte/icons/trending-up';
import TrendingDown from '@lucide/svelte/icons/trending-down';
import Calendar from '@lucide/svelte/icons/calendar';
import AreaChart from '@lucide/svelte/icons/area-chart';
import LineChart from '@lucide/svelte/icons/line-chart';
import { Button } from '$lib/components/ui/button';
import type { BillRecord } from '$lib/api';
import { pieColors } from '$lib/constants/chart';
import { formatLocalDate } from '$lib/utils';
import BillRecordsTable from './BillRecordsTable.svelte';
interface Props {
records: BillRecord[];
categories?: string[];
}
let { records = $bindable(), categories = [] }: Props = $props();
// Dialog 状态
let dialogOpen = $state(false);
let selectedDate = $state<Date | null>(null);
let selectedDateRecords = $state<BillRecord[]>([]);
// 时间范围选项
type TimeRange = '7d' | 'week' | '30d' | 'month' | '3m' | 'year';
let timeRange = $state<TimeRange>('month');
// 图表类型
type ChartType = 'area' | 'line';
let chartType = $state<ChartType>('area');
// 隐藏的类别
let hiddenCategories = $state<Set<string>>(new Set());
function toggleCategory(category: string) {
const newSet = new Set(hiddenCategories);
if (newSet.has(category)) {
newSet.delete(category);
} else {
newSet.add(category);
}
hiddenCategories = newSet;
}
// 提取日期字符串 (YYYY-MM-DD) - 兼容多种格式
function extractDateStr(timeStr: string): string {
// 处理 ISO 格式 (2025-12-29T10:30:00Z) 或空格格式 (2025-12-29 10:30:00)
return timeStr.split('T')[0].split(' ')[0];
}
const timeRangeOptions = [
{ value: '7d', label: '最近 7 天' },
{ value: 'week', label: '本周' },
{ value: '30d', label: '最近 30 天' },
{ value: 'month', label: '本月' },
{ value: '3m', label: '最近 3 个月' },
{ value: 'year', label: '本年' }
];
// 获取截止日期
function getCutoffDate(range: TimeRange): Date {
const now = new Date();
switch (range) {
case '7d':
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
case 'week':
// 本周一
const day = now.getDay();
const diff = now.getDate() - day + (day === 0 ? -6 : 1);
return new Date(now.getFullYear(), now.getMonth(), diff);
case '30d':
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
case 'month':
// 本月第一天
return new Date(now.getFullYear(), now.getMonth(), 1);
case '3m':
return new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
case 'year':
// 本年第一天
return new Date(now.getFullYear(), 0, 1);
default:
return new Date(now.getFullYear(), now.getMonth(), 1);
}
}
// 聚合粒度类型
type AggregationType = 'day' | 'week' | 'month';
// 获取周的起始日期(周一)
function getWeekStart(date: Date): string {
const d = new Date(date);
const day = d.getDay();
const diff = d.getDate() - day + (day === 0 ? -6 : 1); // 调整到周一
d.setDate(diff);
return formatLocalDate(d);
}
// 获取月份标识
function getMonthKey(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
}
// 格式化聚合后的标签
function formatAggregationLabel(key: string, type: AggregationType): string {
if (type === 'day') {
const d = new Date(key);
return `${d.getMonth() + 1}/${d.getDate()}`;
} else if (type === 'week') {
const d = new Date(key);
return `${d.getMonth() + 1}/${d.getDate()}周`;
} else {
const [year, month] = key.split('-');
return `${month}月`;
}
}
// 按日期和分类分组数据(支持智能聚合)
let processedData = $derived(() => {
const cutoffDate = getCutoffDate(timeRange);
// 过滤支出记录
const expenseRecords = records.filter(r => {
if (r.income_expense !== '支出') return false;
const recordDate = new Date(extractDateStr(r.time));
return recordDate >= cutoffDate;
});
if (expenseRecords.length === 0) return { data: [], categories: [], maxValue: 0, aggregationType: 'day' as AggregationType };
// 先按天分组,计算天数
const dailyMap = new Map<string, Map<string, number>>();
const categoryTotals: Record<string, number> = {};
expenseRecords.forEach(record => {
const dateStr = extractDateStr(record.time);
const category = record.category || '其他';
const amount = parseFloat(record.amount) || 0;
categoryTotals[category] = (categoryTotals[category] || 0) + amount;
if (!dailyMap.has(dateStr)) {
dailyMap.set(dateStr, new Map());
}
const dayData = dailyMap.get(dateStr)!;
dayData.set(category, (dayData.get(category) || 0) + amount);
});
const dayCount = dailyMap.size;
// 根据天数决定聚合粒度
let aggregationType: AggregationType = 'day';
if (dayCount > 90) {
aggregationType = 'month';
} else if (dayCount > 30) {
aggregationType = 'week';
}
// 按聚合粒度重新分组
const aggregatedMap = new Map<string, Map<string, number>>();
dailyMap.forEach((dayData, dateStr) => {
const date = new Date(dateStr);
let key: string;
if (aggregationType === 'day') {
key = dateStr;
} else if (aggregationType === 'week') {
key = getWeekStart(date);
} else {
key = getMonthKey(date);
}
if (!aggregatedMap.has(key)) {
aggregatedMap.set(key, new Map());
}
const aggData = aggregatedMap.get(key)!;
dayData.forEach((amount, cat) => {
aggData.set(cat, (aggData.get(cat) || 0) + amount);
});
});
// 获取前5大分类
const sortedCategories = Object.entries(categoryTotals)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([cat]) => cat);
// 转换为数组格式并计算堆叠值
const data = Array.from(aggregatedMap.entries())
.map(([key, aggData]) => {
// 为不同聚合类型创建正确的日期对象
let date: Date;
if (aggregationType === 'month') {
const [year, month] = key.split('-');
date = new Date(parseInt(year), parseInt(month) - 1, 15); // 月中
} else {
date = new Date(key);
}
const result: Record<string, any> = {
date,
dateStr: key,
label: formatAggregationLabel(key, aggregationType)
};
let cumulative = 0;
sortedCategories.forEach(cat => {
const value = aggData.get(cat) || 0;
result[cat] = value;
// 只有未隐藏的类别参与堆叠
if (!hiddenCategories.has(cat)) {
result[`${cat}_y0`] = cumulative;
result[`${cat}_y1`] = cumulative + value;
cumulative += value;
} else {
result[`${cat}_y0`] = 0;
result[`${cat}_y1`] = 0;
}
});
// 其他分类汇总
let otherSum = 0;
aggData.forEach((amount, cat) => {
if (!sortedCategories.includes(cat)) {
otherSum += amount;
}
});
if (otherSum > 0) {
result['其他'] = otherSum;
if (!hiddenCategories.has('其他')) {
result['其他_y0'] = cumulative;
result['其他_y1'] = cumulative + otherSum;
cumulative += otherSum;
} else {
result['其他_y0'] = 0;
result['其他_y1'] = 0;
}
}
result.total = cumulative;
return result;
})
.sort((a, b) => a.date.getTime() - b.date.getTime());
const finalCategories = [...sortedCategories];
if (data.some(d => d['其他'] > 0)) {
finalCategories.push('其他');
}
const maxValue = Math.max(...data.map(d => d.total || 0), 1);
return { data, categories: finalCategories, maxValue, aggregationType, dayCount };
});
// 获取颜色
function getColor(index: number): string {
return pieColors[index % pieColors.length];
}
// 趋势计算
let trendInfo = $derived(() => {
const { data } = processedData();
if (data.length < 2) return null;
const lastTotal = data[data.length - 1].total || 0;
const prevTotal = data[data.length - 2].total || 0;
const change = lastTotal - prevTotal;
const changePercent = prevTotal > 0 ? (change / prevTotal) * 100 : 0;
return { change, changePercent, lastTotal };
});
// 获取描述文本
let descriptionText = $derived(() => {
const { data, aggregationType, dayCount } = processedData();
const label = timeRangeOptions.find(o => o.value === timeRange)?.label || '最近 3 个月';
let aggregationHint = '';
if (aggregationType === 'week') {
aggregationHint = `,按周聚合 (${data.length} 周)`;
} else if (aggregationType === 'month') {
aggregationHint = `,按月聚合 (${data.length} 月)`;
} else {
aggregationHint = ` (${dayCount || data.length} 天)`;
}
return `${label}各分类支出趋势${aggregationHint}`;
});
function handleTimeRangeChange(value: string | undefined) {
if (value && ['7d', 'week', '30d', 'month', '3m', 'year'].includes(value)) {
timeRange = value as TimeRange;
}
}
// SVG 尺寸
const chartWidth = 800;
const chartHeight = 250;
const padding = { top: 20, right: 20, bottom: 40, left: 60 };
const innerWidth = chartWidth - padding.left - padding.right;
const innerHeight = chartHeight - padding.top - padding.bottom;
// 坐标转换
function xScale(date: Date, data: any[]): number {
if (data.length <= 1) return padding.left;
const minDate = data[0].date.getTime();
const maxDate = data[data.length - 1].date.getTime();
const range = maxDate - minDate || 1;
return padding.left + ((date.getTime() - minDate) / range) * innerWidth;
}
function yScale(value: number, maxValue: number): number {
if (maxValue === 0) return padding.top + innerHeight;
return padding.top + innerHeight - (value / maxValue) * innerHeight;
}
// 生成平滑曲线的控制点 (Catmull-Rom to Bezier)
function getCurveControlPoints(
p0: { x: number; y: number },
p1: { x: number; y: number },
p2: { x: number; y: number },
p3: { x: number; y: number },
tension: number = 0.3
): { cp1: { x: number; y: number }; cp2: { x: number; y: number } } {
const d1 = Math.sqrt(Math.pow(p1.x - p0.x, 2) + Math.pow(p1.y - p0.y, 2));
const d2 = Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
const d3 = Math.sqrt(Math.pow(p3.x - p2.x, 2) + Math.pow(p3.y - p2.y, 2));
const d1a = Math.pow(d1, tension);
const d2a = Math.pow(d2, tension);
const d3a = Math.pow(d3, tension);
const cp1 = {
x: p1.x + (d1a !== 0 ? (p2.x - p0.x) * d2a / (d1a + d2a) / 6 * tension * 6 : 0),
y: p1.y + (d1a !== 0 ? (p2.y - p0.y) * d2a / (d1a + d2a) / 6 * tension * 6 : 0)
};
const cp2 = {
x: p2.x - (d3a !== 0 ? (p3.x - p1.x) * d2a / (d2a + d3a) / 6 * tension * 6 : 0),
y: p2.y - (d3a !== 0 ? (p3.y - p1.y) * d2a / (d2a + d3a) / 6 * tension * 6 : 0)
};
return { cp1, cp2 };
}
// 生成平滑曲线路径
function generateSmoothPath(points: { x: number; y: number }[]): string {
if (points.length < 2) return '';
if (points.length === 2) {
return `L ${points[1].x},${points[1].y}`;
}
let path = '';
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[Math.max(0, i - 1)];
const p1 = points[i];
const p2 = points[i + 1];
const p3 = points[Math.min(points.length - 1, i + 2)];
const { cp1, cp2 } = getCurveControlPoints(p0, p1, p2, p3);
path += ` C ${cp1.x},${cp1.y} ${cp2.x},${cp2.y} ${p2.x},${p2.y}`;
}
return path;
}
// 生成面积路径(平滑曲线版本)
function generateAreaPath(category: string, data: any[], maxValue: number): string {
if (data.length === 0) return '';
const topPoints: { x: number; y: number }[] = [];
const bottomPoints: { x: number; y: number }[] = [];
data.forEach((d) => {
const x = xScale(d.date, data);
const y1 = yScale(d[`${category}_y1`] || 0, maxValue);
const y0 = yScale(d[`${category}_y0`] || 0, maxValue);
topPoints.push({ x, y: y1 });
bottomPoints.unshift({ x, y: y0 });
});
// 起始点
const startPoint = topPoints[0];
let path = `M ${startPoint.x},${startPoint.y}`;
// 顶部曲线
path += generateSmoothPath(topPoints);
// 连接到底部起点
const bottomStart = bottomPoints[0];
path += ` L ${bottomStart.x},${bottomStart.y}`;
// 底部曲线
path += generateSmoothPath(bottomPoints);
// 闭合路径
path += ' Z';
return path;
}
// 生成线性图路径(总金额曲线)
function generateTotalLinePath(data: any[], maxValue: number): string {
if (data.length === 0) return '';
// 计算每天的总支出(所有可见分类的总和)
const points: { x: number; y: number }[] = data.map((d) => {
// 使用 total 字段,这是所有可见分类的累计值
const total = d.total || 0;
return {
x: xScale(d.date, data),
y: yScale(total, maxValue)
};
});
// 起始点
const startPoint = points[0];
let path = `M ${startPoint.x},${startPoint.y}`;
// 平滑曲线
path += generateSmoothPath(points);
return path;
}
// 生成线性图的数据点坐标(总金额)
function getTotalLinePoints(data: any[], maxValue: number): { x: number; y: number; value: number }[] {
return data.map((d) => ({
x: xScale(d.date, data),
y: yScale(d.total || 0, maxValue),
value: d.total || 0
}));
}
// 生成 X 轴刻度
function getXTicks(data: any[]): { x: number; label: string }[] {
if (data.length === 0) return [];
const step = Math.max(1, Math.floor(data.length / 6));
return data.filter((_, i) => i % step === 0 || i === data.length - 1).map(d => ({
x: xScale(d.date, data),
label: d.label || `${d.date.getMonth() + 1}/${d.date.getDate()}`
}));
}
// 生成 Y 轴刻度
function getYTicks(maxValue: number): { y: number; label: string }[] {
if (maxValue === 0) return [];
const ticks = [];
const step = Math.ceil(maxValue / 4 / 100) * 100;
for (let v = 0; v <= maxValue; v += step) {
ticks.push({
y: yScale(v, maxValue),
label: ${v}`
});
}
return ticks;
}
// Tooltip 状态
let tooltipData = $state<any>(null);
let tooltipX = $state(0);
let tooltipY = $state(0);
function handleMouseMove(event: MouseEvent, data: any[], maxValue: number) {
const svg = event.currentTarget as SVGSVGElement;
const rect = svg.getBoundingClientRect();
// 将像素坐标转换为 viewBox 坐标
const pixelX = event.clientX - rect.left;
const scaleRatio = chartWidth / rect.width;
const viewBoxX = pixelX * scaleRatio;
// 找到最近的数据点
let closestIdx = 0;
let closestDist = Infinity;
data.forEach((d, i) => {
const dx = Math.abs(xScale(d.date, data) - viewBoxX);
if (dx < closestDist) {
closestDist = dx;
closestIdx = i;
}
});
// 阈值也需要按比例调整
if (closestDist < 50 * scaleRatio) {
tooltipData = data[closestIdx];
tooltipX = xScale(data[closestIdx].date, data);
tooltipY = event.clientY - rect.top;
} else {
tooltipData = null;
}
}
function handleMouseLeave() {
tooltipData = null;
}
// 点击打开 Dialog
function handleClick(event: MouseEvent, data: any[], maxValue: number) {
if (data.length === 0) return;
const svg = event.currentTarget as SVGSVGElement;
const rect = svg.getBoundingClientRect();
// 将像素坐标转换为 viewBox 坐标
const pixelX = event.clientX - rect.left;
const scaleRatio = chartWidth / rect.width;
const viewBoxX = pixelX * scaleRatio;
// 找到最近的数据点
let closestIdx = 0;
let closestDist = Infinity;
data.forEach((d, i) => {
const dx = Math.abs(xScale(d.date, data) - viewBoxX);
if (dx < closestDist) {
closestDist = dx;
closestIdx = i;
}
});
// 点击图表任意位置都触发,选择最近的日期
const clickedDate = data[closestIdx].date;
const dateStr = formatLocalDate(clickedDate);
// 找出当天的所有支出记录
selectedDate = clickedDate;
selectedDateRecords = records.filter(r => {
if (r.income_expense !== '支出') return false;
const recordDateStr = extractDateStr(r.time);
return recordDateStr === dateStr;
});
dialogOpen = true;
}
// 计算选中日期的统计
let selectedDateStats = $derived.by(() => {
if (!selectedDate || selectedDateRecords.length === 0) return null;
const categoryMap = new Map<string, { amount: number; count: number }>();
let total = 0;
selectedDateRecords.forEach(r => {
const cat = r.category || '其他';
const amount = parseFloat(r.amount) || 0;
total += amount;
if (!categoryMap.has(cat)) {
categoryMap.set(cat, { amount: 0, count: 0 });
}
const stat = categoryMap.get(cat)!;
stat.amount += amount;
stat.count += 1;
});
const categories = Array.from(categoryMap.entries())
.map(([category, stat]) => ({ category, ...stat }))
.sort((a, b) => b.amount - a.amount);
return { total, categories, count: selectedDateRecords.length };
});
</script>
{#if processedData().data.length > 1}
{@const { data, categories, maxValue } = processedData()}
<Card.Root class="transition-all duration-200 hover:shadow-lg hover:-translate-y-1">
<Card.Header class="flex flex-row items-center justify-between pb-2">
<div class="space-y-1.5">
<Card.Title class="flex items-center gap-2">
<Activity class="h-5 w-5" />
每日支出趋势
</Card.Title>
<Card.Description>
{descriptionText()}
</Card.Description>
</div>
<div class="flex items-center gap-2">
<!-- 图表类型切换 -->
<div class="flex items-center rounded-md border bg-muted/50 p-0.5">
<button
class="flex items-center justify-center h-7 w-7 rounded transition-colors {chartType === 'area' ? 'bg-background shadow-sm' : 'hover:bg-background/50'}"
onclick={() => chartType = 'area'}
title="堆叠面积图"
>
<AreaChart class="h-4 w-4 {chartType === 'area' ? 'text-primary' : 'text-muted-foreground'}" />
</button>
<button
class="flex items-center justify-center h-7 w-7 rounded transition-colors {chartType === 'line' ? 'bg-background shadow-sm' : 'hover:bg-background/50'}"
onclick={() => chartType = 'line'}
title="线性图"
>
<LineChart class="h-4 w-4 {chartType === 'line' ? 'text-primary' : 'text-muted-foreground'}" />
</button>
</div>
<!-- 时间范围选择 -->
<Select.Root type="single" value={timeRange} onValueChange={handleTimeRangeChange}>
<Select.Trigger class="w-[140px] h-8 text-xs">
{timeRangeOptions.find(o => o.value === timeRange)?.label}
</Select.Trigger>
<Select.Content>
{#each timeRangeOptions as option}
<Select.Item value={option.value}>{option.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</Card.Header>
<Card.Content>
<!-- 图例 -->
<div class="flex flex-wrap gap-4 mb-4">
{#if chartType === 'area'}
<!-- 堆叠面积图:分类图例 (点击可切换显示) -->
{#each categories as category, i}
{@const isHidden = hiddenCategories.has(category)}
<button
class="flex items-center gap-2 cursor-pointer hover:opacity-80 transition-opacity outline-none"
onclick={() => toggleCategory(category)}
title={isHidden ? '点击显示' : '点击隐藏'}
>
<div
class="w-3 h-3 rounded-sm transition-opacity {isHidden ? 'opacity-30' : ''}"
style="background-color: {getColor(i)}"
></div>
<span
class="text-xs transition-colors {isHidden ? 'text-muted-foreground/40 line-through' : 'text-muted-foreground'}"
>{category}</span>
</button>
{/each}
{:else}
<!-- 线性图:总支出图例 -->
<div class="flex items-center gap-2">
<div
class="w-3 h-3 rounded-full"
style="background-color: oklch(0.65 0.2 25)"
></div>
<span class="text-xs text-muted-foreground">总支出</span>
</div>
{/if}
</div>
<!-- 趋势图 (自定义 SVG) -->
<div class="relative w-full" style="aspect-ratio: {chartWidth}/{chartHeight};">
<!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions a11y_click_events_have_key_events -->
<svg
viewBox="0 0 {chartWidth} {chartHeight}"
class="w-full h-full cursor-pointer outline-none focus:outline-none"
role="application"
aria-label="每日支出趋势图表,点击可查看当日详情"
tabindex="-1"
onmousemove={(e) => handleMouseMove(e, data, maxValue)}
onmouseleave={handleMouseLeave}
onclick={(e) => handleClick(e, data, maxValue)}
>
<!-- Y 轴 -->
<line
x1={padding.left}
y1={padding.top}
x2={padding.left}
y2={padding.top + innerHeight}
stroke="currentColor"
stroke-opacity="0.2"
/>
<!-- Y 轴刻度 -->
{#each getYTicks(maxValue) as tick}
<line
x1={padding.left}
y1={tick.y}
x2={padding.left + innerWidth}
y2={tick.y}
stroke="currentColor"
stroke-opacity="0.1"
stroke-dasharray="4"
/>
<text
x={padding.left - 8}
y={tick.y + 4}
text-anchor="end"
class="fill-muted-foreground text-[10px]"
>
{tick.label}
</text>
{/each}
<!-- X 轴 -->
<line
x1={padding.left}
y1={padding.top + innerHeight}
x2={padding.left + innerWidth}
y2={padding.top + innerHeight}
stroke="currentColor"
stroke-opacity="0.2"
/>
<!-- X 轴刻度 -->
{#each getXTicks(data) as tick}
<text
x={tick.x}
y={padding.top + innerHeight + 20}
text-anchor="middle"
class="fill-muted-foreground text-[10px]"
>
{tick.label}
</text>
{/each}
{#if chartType === 'area'}
<!-- 堆叠面积图 (从后向前渲染) -->
{#each [...categories].reverse() as category, i}
{@const colorIdx = categories.length - 1 - i}
<path
d={generateAreaPath(category, data, maxValue)}
fill={getColor(colorIdx)}
fill-opacity="0.7"
class="transition-opacity hover:opacity-90"
/>
{/each}
{:else}
<!-- 线性图(总金额) -->
<!-- 曲线 -->
<path
d={generateTotalLinePath(data, maxValue)}
fill="none"
stroke="oklch(0.65 0.2 25)"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
class="transition-opacity"
/>
<!-- 数据点 -->
{#each getTotalLinePoints(data, maxValue) as point}
<circle
cx={point.x}
cy={point.y}
r="4"
fill="oklch(0.65 0.2 25)"
stroke="white"
stroke-width="2"
class="transition-all"
/>
{/each}
{/if}
<!-- Tooltip 辅助线 -->
{#if tooltipData}
<line
x1={tooltipX}
y1={padding.top}
x2={tooltipX}
y2={padding.top + innerHeight}
stroke="currentColor"
stroke-opacity="0.3"
stroke-dasharray="4"
/>
<!-- 数据点 -->
{#each categories as category, i}
{#if tooltipData[category] > 0}
<circle
cx={tooltipX}
cy={yScale(tooltipData[`${category}_y1`] || 0, maxValue)}
r="4"
fill={getColor(i)}
stroke="white"
stroke-width="2"
/>
{/if}
{/each}
{/if}
</svg>
<!-- Tooltip -->
{#if tooltipData}
{@const tooltipLeftPercent = (tooltipX / chartWidth) * 100}
{@const adjustedLeft = tooltipLeftPercent > 75 ? tooltipLeftPercent - 25 : tooltipLeftPercent + 2}
<div
class="absolute pointer-events-none z-10 border-border/50 bg-background rounded-lg border px-3 py-2 text-xs shadow-xl min-w-[160px]"
style="left: {adjustedLeft}%; top: 15%;"
>
<div class="font-medium text-foreground mb-2">
{tooltipData.label || tooltipData.date.toLocaleDateString('zh-CN')}
</div>
<div class="space-y-1">
{#each categories as category, i}
{#if tooltipData[category] > 0}
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-2">
<div
class="w-2 h-2 rounded-full"
style="background-color: {getColor(i)}"
></div>
<span class="text-muted-foreground">{category}</span>
</div>
<span class="font-mono font-medium">¥{tooltipData[category].toFixed(2)}</span>
</div>
{/if}
{/each}
</div>
<div class="border-t border-border mt-2 pt-2 flex justify-between">
<span class="text-muted-foreground">合计</span>
<span class="font-mono font-bold">¥{tooltipData.total.toFixed(2)}</span>
</div>
</div>
{/if}
</div>
<!-- 趋势指标 -->
<div class="flex items-center justify-center gap-2 mt-4 text-sm">
{#if trendInfo()}
{@const info = trendInfo()}
{#if info!.change >= 0}
<TrendingUp class="h-4 w-4 text-red-500" />
<span class="text-red-500">+{Math.abs(info!.changePercent).toFixed(1)}%</span>
{:else}
<TrendingDown class="h-4 w-4 text-green-500" />
<span class="text-green-500">-{Math.abs(info!.changePercent).toFixed(1)}%</span>
{/if}
<span class="text-muted-foreground">较前一天</span>
{/if}
</div>
<!-- 提示文字 -->
<p class="text-center text-xs text-muted-foreground mt-2">
点击图表查看当日详情
</p>
</Card.Content>
</Card.Root>
{/if}
<!-- 当日详情 Drawer -->
<Drawer.Root bind:open={dialogOpen}>
<Drawer.Content class="sm:max-w-4xl">
<Drawer.Header>
<Drawer.Title class="flex items-center gap-2">
<Calendar class="h-5 w-5" />
{#if selectedDate}
{selectedDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' })}
{/if}
</Drawer.Title>
<Drawer.Description>
{#if selectedDateStats}
{@const stats = selectedDateStats}
{stats!.count} 笔支出,合计 ¥{stats!.total.toFixed(2)}
{/if}
</Drawer.Description>
</Drawer.Header>
<div class="flex-1 overflow-auto py-4 px-4 md:px-0">
{#if selectedDateStats}
{@const stats = selectedDateStats}
<!-- 分类汇总 -->
<div class="mb-4 space-y-2">
<h4 class="text-sm font-medium text-muted-foreground">分类汇总</h4>
<div class="grid grid-cols-2 gap-2">
{#each stats!.categories as cat, i}
<div class="flex items-center justify-between p-2 rounded-lg bg-muted/50">
<div class="flex items-center gap-2">
<div
class="w-2.5 h-2.5 rounded-full"
style="background-color: {getColor(i)}"
></div>
<span class="text-sm">{cat.category}</span>
<span class="text-xs text-muted-foreground">({cat.count}笔)</span>
</div>
<span class="font-mono text-sm font-medium text-red-600 dark:text-red-400">
¥{cat.amount.toFixed(2)}
</span>
</div>
{/each}
</div>
</div>
<!-- 详细记录 -->
<div>
<h4 class="text-sm font-medium text-muted-foreground mb-2">详细记录</h4>
<BillRecordsTable
bind:records={selectedDateRecords}
showCategory={true}
showDescription={false}
pageSize={8}
{categories}
/>
</div>
{:else}
<p class="text-center text-muted-foreground py-8">暂无数据</p>
{/if}
</div>
</Drawer.Content>
</Drawer.Root>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import BarChart3 from '@lucide/svelte/icons/bar-chart-3';
import Activity from '@lucide/svelte/icons/activity';
interface Props {
onLoadDemo: () => void;
}
let { onLoadDemo }: Props = $props();
</script>
<Card.Root>
<Card.Content class="flex flex-col items-center justify-center py-16">
<BarChart3 class="h-16 w-16 text-muted-foreground mb-4" />
<p class="text-lg font-medium">输入文件名开始分析</p>
<p class="text-sm text-muted-foreground mb-4">上传账单后可在此进行数据分析</p>
<Button variant="outline" onclick={onLoadDemo}>
<Activity class="mr-2 h-4 w-4" />
查看示例数据
</Button>
</Card.Content>
</Card.Root>

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import BarChart3 from '@lucide/svelte/icons/bar-chart-3';
import type { MonthlyStat } from '$lib/types/analysis';
import { getPercentage } from '$lib/services/analysis';
interface Props {
monthlyStats: MonthlyStat[];
}
let { monthlyStats }: Props = $props();
let maxValue = $derived(Math.max(...monthlyStats.map(s => Math.max(s.expense, s.income))));
</script>
<Card.Root class="transition-all duration-200 hover:shadow-lg hover:-translate-y-1">
<Card.Header>
<Card.Title class="flex items-center gap-2">
<BarChart3 class="h-5 w-5" />
月度趋势
</Card.Title>
<Card.Description>收支变化趋势</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
{#each monthlyStats as stat}
<div class="space-y-2">
<div class="text-sm font-medium">{stat.month}</div>
<div class="space-y-1">
<div class="flex items-center gap-2">
<div class="h-5 rounded bg-red-500/20 overflow-hidden flex-1 relative">
<div
class="h-full bg-red-500 transition-all duration-500"
style="width: {getPercentage(stat.expense, maxValue)}%"
></div>
<span class="absolute right-2 top-1/2 -translate-y-1/2 text-xs font-mono">
{stat.expense.toFixed(0)}
</span>
</div>
</div>
<div class="flex items-center gap-2">
<div class="h-5 rounded bg-green-500/20 overflow-hidden flex-1 relative">
<div
class="h-full bg-green-500 transition-all duration-500"
style="width: {getPercentage(stat.income, maxValue)}%"
></div>
<span class="absolute right-2 top-1/2 -translate-y-1/2 text-xs font-mono">
{stat.income.toFixed(0)}
</span>
</div>
</div>
</div>
</div>
{/each}
<!-- 图例 -->
<div class="flex justify-center gap-6 pt-2">
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<span class="w-3 h-3 rounded bg-red-500"></span>
支出
</div>
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<span class="w-3 h-3 rounded bg-green-500"></span>
收入
</div>
</div>
</Card.Content>
</Card.Root>

View File

@@ -0,0 +1,65 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import TrendingDown from '@lucide/svelte/icons/trending-down';
import TrendingUp from '@lucide/svelte/icons/trending-up';
import Wallet from '@lucide/svelte/icons/wallet';
import type { TotalStats } from '$lib/types/analysis';
import { countByType } from '$lib/services/analysis';
import type { BillRecord } from '$lib/api';
interface Props {
totalStats: TotalStats;
records: BillRecord[];
}
let { totalStats, records }: Props = $props();
let balance = $derived(totalStats.income - totalStats.expense);
let expenseCount = $derived(countByType(records, '支出'));
let incomeCount = $derived(countByType(records, '收入'));
</script>
<div class="grid gap-4 md:grid-cols-3">
<Card.Root class="border-red-200 dark:border-red-900 transition-all duration-200 hover:shadow-lg hover:-translate-y-1 hover:border-red-300 dark:hover:border-red-800 cursor-default">
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
<Card.Title class="text-sm font-medium">总支出</Card.Title>
<TrendingDown class="h-4 w-4 text-red-500" />
</Card.Header>
<Card.Content>
<div class="text-2xl font-bold font-mono text-red-600 dark:text-red-400">
¥{totalStats.expense.toFixed(2)}
</div>
<p class="text-xs text-muted-foreground">{expenseCount} 笔支出</p>
</Card.Content>
</Card.Root>
<Card.Root class="border-green-200 dark:border-green-900 transition-all duration-200 hover:shadow-lg hover:-translate-y-1 hover:border-green-300 dark:hover:border-green-800 cursor-default">
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
<Card.Title class="text-sm font-medium">总收入</Card.Title>
<TrendingUp class="h-4 w-4 text-green-500" />
</Card.Header>
<Card.Content>
<div class="text-2xl font-bold font-mono text-green-600 dark:text-green-400">
¥{totalStats.income.toFixed(2)}
</div>
<p class="text-xs text-muted-foreground">{incomeCount} 笔收入</p>
</Card.Content>
</Card.Root>
<Card.Root class="{balance >= 0 ? 'border-blue-200 dark:border-blue-900 hover:border-blue-300 dark:hover:border-blue-800' : 'border-orange-200 dark:border-orange-900 hover:border-orange-300 dark:hover:border-orange-800'} transition-all duration-200 hover:shadow-lg hover:-translate-y-1 cursor-default">
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
<Card.Title class="text-sm font-medium">结余</Card.Title>
<Wallet class="h-4 w-4 {balance >= 0 ? 'text-blue-500' : 'text-orange-500'}" />
</Card.Header>
<Card.Content>
<div class="text-2xl font-bold font-mono {balance >= 0 ? 'text-blue-600 dark:text-blue-400' : 'text-orange-600 dark:text-orange-400'}">
¥{balance.toFixed(2)}
</div>
<p class="text-xs text-muted-foreground">
{balance >= 0 ? '本期盈余' : '本期亏空'}
</p>
</Card.Content>
</Card.Root>
</div>

View File

@@ -0,0 +1,289 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import * as Drawer from '$lib/components/ui/drawer';
import * as Select from '$lib/components/ui/select';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import Flame from '@lucide/svelte/icons/flame';
import Receipt from '@lucide/svelte/icons/receipt';
import Pencil from '@lucide/svelte/icons/pencil';
import Save from '@lucide/svelte/icons/save';
import X from '@lucide/svelte/icons/x';
import Calendar from '@lucide/svelte/icons/calendar';
import Store from '@lucide/svelte/icons/store';
import Tag from '@lucide/svelte/icons/tag';
import FileText from '@lucide/svelte/icons/file-text';
import CreditCard from '@lucide/svelte/icons/credit-card';
import type { BillRecord } from '$lib/api';
interface Props {
records: BillRecord[];
categories: string[]; // 可用的分类列表
onUpdate?: (record: BillRecord) => void;
}
let { records, categories, onUpdate }: Props = $props();
let dialogOpen = $state(false);
let selectedRecord = $state<BillRecord | null>(null);
let selectedRank = $state(0);
let isEditing = $state(false);
// 编辑表单数据
let editForm = $state({
merchant: '',
category: '',
amount: '',
description: '',
payment_method: ''
});
function openDetail(record: BillRecord, rank: number) {
selectedRecord = record;
selectedRank = rank;
isEditing = false;
dialogOpen = true;
}
function startEdit() {
if (!selectedRecord) return;
editForm = {
merchant: selectedRecord.merchant,
category: selectedRecord.category,
amount: selectedRecord.amount,
description: selectedRecord.description || '',
payment_method: selectedRecord.payment_method || ''
};
isEditing = true;
}
function cancelEdit() {
isEditing = false;
}
function saveEdit() {
if (!selectedRecord) return;
// 更新记录
const updatedRecord: BillRecord = {
...selectedRecord,
merchant: editForm.merchant,
category: editForm.category,
amount: editForm.amount,
description: editForm.description,
payment_method: editForm.payment_method
};
// 更新本地数据
const index = records.findIndex(r => r === selectedRecord);
if (index !== -1) {
records[index] = updatedRecord;
}
selectedRecord = updatedRecord;
isEditing = false;
// 通知父组件
onUpdate?.(updatedRecord);
}
function handleCategoryChange(value: string | undefined) {
if (value) {
editForm.category = value;
}
}
</script>
<Card.Root class="transition-all duration-200 hover:shadow-lg hover:-translate-y-1">
<Card.Header>
<Card.Title class="flex items-center gap-2">
<Flame class="h-5 w-5 text-orange-500" />
Top 10 单笔支出
</Card.Title>
<Card.Description>最大的单笔支出记录(点击查看详情)</Card.Description>
</Card.Header>
<Card.Content>
<div class="space-y-3">
{#each records as record, i}
<button
class="w-full flex items-center gap-4 p-3 rounded-lg bg-muted/50 transition-all duration-150 hover:bg-muted hover:scale-[1.02] hover:shadow-sm cursor-pointer text-left outline-none focus:outline-none"
onclick={() => openDetail(record, i + 1)}
>
<div class="flex h-8 w-8 items-center justify-center rounded-full font-bold text-sm {
i === 0 ? 'bg-gradient-to-br from-yellow-400 to-amber-500 text-white shadow-md' :
i === 1 ? 'bg-gradient-to-br from-slate-300 to-slate-400 text-white shadow-md' :
i === 2 ? 'bg-gradient-to-br from-orange-400 to-amber-600 text-white shadow-md' :
'bg-primary/10 text-primary'
}">
{i + 1}
</div>
<div class="flex-1 min-w-0">
<p class="font-medium truncate">{record.merchant}</p>
<p class="text-sm text-muted-foreground truncate">
{record.description || record.category}
</p>
</div>
<div class="font-mono font-bold text-red-600 dark:text-red-400">
¥{record.amount}
</div>
</button>
{/each}
</div>
</Card.Content>
</Card.Root>
<!-- 账单详情弹窗 -->
<Drawer.Root bind:open={dialogOpen}>
<Drawer.Content class="sm:max-w-[450px]">
<Drawer.Header>
<Drawer.Title class="flex items-center gap-2">
<Receipt class="h-5 w-5" />
{isEditing ? '编辑账单' : '账单详情'}
{#if selectedRank <= 3 && !isEditing}
<span class="ml-2 px-2 py-0.5 text-xs rounded-full {
selectedRank === 1 ? 'bg-gradient-to-r from-yellow-400 to-amber-500 text-white' :
selectedRank === 2 ? 'bg-gradient-to-r from-slate-300 to-slate-400 text-white' :
'bg-gradient-to-r from-orange-400 to-amber-600 text-white'
}">
Top {selectedRank}
</span>
{/if}
</Drawer.Title>
<Drawer.Description>
{isEditing ? '修改这笔支出的信息' : '查看这笔支出的完整信息'}
</Drawer.Description>
</Drawer.Header>
{#if selectedRecord}
{#if isEditing}
<!-- 编辑模式 -->
<div class="py-4 space-y-4 px-4 md:px-0">
<div class="space-y-2">
<Label for="amount">金额</Label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">¥</span>
<Input
id="amount"
type="number"
step="0.01"
bind:value={editForm.amount}
class="pl-7 font-mono"
/>
</div>
</div>
<div class="space-y-2">
<Label for="merchant">商家</Label>
<Input id="merchant" bind:value={editForm.merchant} />
</div>
<div class="space-y-2">
<Label>分类</Label>
<Select.Root type="single" value={editForm.category} onValueChange={handleCategoryChange}>
<Select.Trigger class="w-full">
<span>{editForm.category || '选择分类'}</span>
</Select.Trigger>
<Select.Portal>
<Select.Content>
{#each categories as category}
<Select.Item value={category}>{category}</Select.Item>
{/each}
</Select.Content>
</Select.Portal>
</Select.Root>
</div>
<div class="space-y-2">
<Label for="description">描述</Label>
<Input id="description" bind:value={editForm.description} placeholder="可选" />
</div>
<div class="space-y-2">
<Label for="payment_method">支付方式</Label>
<Input id="payment_method" bind:value={editForm.payment_method} placeholder="可选" />
</div>
</div>
{:else}
<!-- 查看模式 -->
<div class="py-4 space-y-4 px-4 md:px-0">
<!-- 金额 -->
<div class="text-center py-4 bg-red-50 dark:bg-red-950/30 rounded-lg">
<p class="text-sm text-muted-foreground mb-1">支出金额</p>
<p class="text-3xl font-bold font-mono text-red-600 dark:text-red-400">
¥{selectedRecord.amount}
</p>
</div>
<!-- 详情列表 -->
<div class="space-y-3">
<div class="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
<Store class="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
<div class="min-w-0">
<p class="text-xs text-muted-foreground">商家</p>
<p class="font-medium">{selectedRecord.merchant}</p>
</div>
</div>
<div class="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
<Tag class="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
<div class="min-w-0">
<p class="text-xs text-muted-foreground">分类</p>
<p class="font-medium">{selectedRecord.category}</p>
</div>
</div>
<div class="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
<Calendar class="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
<div class="min-w-0">
<p class="text-xs text-muted-foreground">时间</p>
<p class="font-medium">{selectedRecord.time}</p>
</div>
</div>
{#if selectedRecord.description}
<div class="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
<FileText class="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
<div class="min-w-0">
<p class="text-xs text-muted-foreground">描述</p>
<p class="font-medium">{selectedRecord.description}</p>
</div>
</div>
{/if}
{#if selectedRecord.payment_method}
<div class="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
<CreditCard class="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
<div class="min-w-0">
<p class="text-xs text-muted-foreground">支付方式</p>
<p class="font-medium">{selectedRecord.payment_method}</p>
</div>
</div>
{/if}
</div>
</div>
{/if}
{/if}
<Drawer.Footer class="flex gap-2">
{#if isEditing}
<Button variant="outline" onclick={cancelEdit}>
<X class="h-4 w-4 mr-2" />
取消
</Button>
<Button onclick={saveEdit}>
<Save class="h-4 w-4 mr-2" />
保存
</Button>
{:else}
<Button variant="outline" onclick={() => dialogOpen = false}>
关闭
</Button>
<Button onclick={startEdit}>
<Pencil class="h-4 w-4 mr-2" />
编辑
</Button>
{/if}
</Drawer.Footer>
</Drawer.Content>
</Drawer.Root>

View File

@@ -0,0 +1,9 @@
export { default as OverviewCards } from './OverviewCards.svelte';
export { default as DailyTrendChart } from './DailyTrendChart.svelte';
export { default as CategoryRanking } from './CategoryRanking.svelte';
export { default as MonthlyTrend } from './MonthlyTrend.svelte';
export { default as TopExpenses } from './TopExpenses.svelte';
export { default as EmptyState } from './EmptyState.svelte';
export { default as BillRecordsTable } from './BillRecordsTable.svelte';

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AvatarPrimitive.FallbackProps = $props();
</script>
<AvatarPrimitive.Fallback
bind:ref
data-slot="avatar-fallback"
class={cn("bg-muted flex size-full items-center justify-center rounded-full", className)}
{...restProps}
/>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AvatarPrimitive.ImageProps = $props();
</script>
<AvatarPrimitive.Image
bind:ref
data-slot="avatar-image"
class={cn("aspect-square size-full", className)}
{...restProps}
/>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
loadingStatus = $bindable("loading"),
class: className,
...restProps
}: AvatarPrimitive.RootProps = $props();
</script>
<AvatarPrimitive.Root
bind:ref
bind:loadingStatus
data-slot="avatar"
class={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
{...restProps}
/>

View File

@@ -0,0 +1,13 @@
import Root from "./avatar.svelte";
import Image from "./avatar-image.svelte";
import Fallback from "./avatar-fallback.svelte";
export {
Root,
Image,
Fallback,
//
Root as Avatar,
Image as AvatarImage,
Fallback as AvatarFallback,
};

View File

@@ -0,0 +1,50 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const badgeVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
variants: {
variant: {
default:
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
destructive:
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
});
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAnchorAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
href,
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
variant?: BadgeVariant;
} = $props();
</script>
<svelte:element
this={href ? "a" : "span"}
bind:this={ref}
data-slot="badge"
{href}
class={cn(badgeVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</svelte:element>

View File

@@ -0,0 +1,2 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";

View File

@@ -0,0 +1,82 @@
<script lang="ts" module>
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs",
destructive:
"bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white shadow-xs",
outline:
"bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
</script>
<script lang="ts">
let {
class: className,
variant = "default",
size = "default",
ref = $bindable(null),
href = undefined,
type = "button",
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
{/if}

View File

@@ -0,0 +1,17 @@
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
} from "./button.svelte";
export {
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
};

View File

@@ -0,0 +1,76 @@
<script lang="ts">
import type { ComponentProps } from "svelte";
import type Calendar from "./calendar.svelte";
import CalendarMonthSelect from "./calendar-month-select.svelte";
import CalendarYearSelect from "./calendar-year-select.svelte";
import { DateFormatter, getLocalTimeZone, type DateValue } from "@internationalized/date";
let {
captionLayout,
months,
monthFormat,
years,
yearFormat,
month,
locale,
placeholder = $bindable(),
monthIndex = 0,
}: {
captionLayout: ComponentProps<typeof Calendar>["captionLayout"];
months: ComponentProps<typeof CalendarMonthSelect>["months"];
monthFormat: ComponentProps<typeof CalendarMonthSelect>["monthFormat"];
years: ComponentProps<typeof CalendarYearSelect>["years"];
yearFormat: ComponentProps<typeof CalendarYearSelect>["yearFormat"];
month: DateValue;
placeholder: DateValue | undefined;
locale: string;
monthIndex: number;
} = $props();
function formatYear(date: DateValue) {
const dateObj = date.toDate(getLocalTimeZone());
if (typeof yearFormat === "function") return yearFormat(dateObj.getFullYear());
return new DateFormatter(locale, { year: yearFormat }).format(dateObj);
}
function formatMonth(date: DateValue) {
const dateObj = date.toDate(getLocalTimeZone());
if (typeof monthFormat === "function") return monthFormat(dateObj.getMonth() + 1);
return new DateFormatter(locale, { month: monthFormat }).format(dateObj);
}
</script>
{#snippet MonthSelect()}
<CalendarMonthSelect
{months}
{monthFormat}
value={month.month}
onchange={(e) => {
if (!placeholder) return;
const v = Number.parseInt(e.currentTarget.value);
const newPlaceholder = placeholder.set({ month: v });
placeholder = newPlaceholder.subtract({ months: monthIndex });
}}
/>
{/snippet}
{#snippet YearSelect()}
<CalendarYearSelect {years} {yearFormat} value={month.year} />
{/snippet}
{#if captionLayout === "dropdown"}
{@render MonthSelect()}
{@render YearSelect()}
{:else if captionLayout === "dropdown-months"}
{@render MonthSelect()}
{#if placeholder}
{formatYear(placeholder)}
{/if}
{:else if captionLayout === "dropdown-years"}
{#if placeholder}
{formatMonth(placeholder)}
{/if}
{@render YearSelect()}
{:else}
{formatMonth(month)} {formatYear(month)}
{/if}

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.CellProps = $props();
</script>
<CalendarPrimitive.Cell
bind:ref
class={cn(
"relative size-(--cell-size) p-0 text-center text-sm focus-within:z-20 [&:first-child[data-selected]_[data-bits-day]]:rounded-s-md [&:last-child[data-selected]_[data-bits-day]]:rounded-e-md",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
import { Calendar as CalendarPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.DayProps = $props();
</script>
<CalendarPrimitive.Day
bind:ref
class={cn(
buttonVariants({ variant: "ghost" }),
"flex size-(--cell-size) flex-col items-center justify-center gap-1 p-0 leading-none font-normal whitespace-nowrap select-none",
"[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground",
"data-[selected]:bg-primary dark:data-[selected]:hover:bg-accent/50 data-[selected]:text-primary-foreground",
// Outside months
"[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground",
// Disabled
"data-[disabled]:text-muted-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
// Unavailable
"data-[unavailable]:text-muted-foreground data-[unavailable]:line-through",
// hover
"dark:hover:text-accent-foreground",
// focus
"focus:border-ring focus:ring-ring/50 focus:relative",
// inner spans
"[&>span]:text-xs [&>span]:opacity-70",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.GridBodyProps = $props();
</script>
<CalendarPrimitive.GridBody bind:ref class={cn(className)} {...restProps} />

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.GridHeadProps = $props();
</script>
<CalendarPrimitive.GridHead bind:ref class={cn(className)} {...restProps} />

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.GridRowProps = $props();
</script>
<CalendarPrimitive.GridRow bind:ref class={cn("flex", className)} {...restProps} />

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.GridProps = $props();
</script>
<CalendarPrimitive.Grid
bind:ref
class={cn("mt-4 flex w-full border-collapse flex-col gap-1", className)}
{...restProps}
/>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.HeadCellProps = $props();
</script>
<CalendarPrimitive.HeadCell
bind:ref
class={cn(
"text-muted-foreground w-(--cell-size) rounded-md text-[0.8rem] font-normal",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.HeaderProps = $props();
</script>
<CalendarPrimitive.Header
bind:ref
class={cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.HeadingProps = $props();
</script>
<CalendarPrimitive.Heading
bind:ref
class={cn("px-(--cell-size) text-sm font-medium", className)}
{...restProps}
/>

View File

@@ -0,0 +1,44 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
let {
ref = $bindable(null),
class: className,
value,
onchange,
...restProps
}: WithoutChildrenOrChild<CalendarPrimitive.MonthSelectProps> = $props();
</script>
<span
class={cn(
"has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]",
className
)}
>
<CalendarPrimitive.MonthSelect bind:ref class="absolute inset-0 opacity-0" {...restProps}>
{#snippet child({ props, monthItems, selectedMonthItem })}
<select {...props} {value} {onchange}>
{#each monthItems as monthItem (monthItem.value)}
<option
value={monthItem.value}
selected={value !== undefined
? monthItem.value === value
: monthItem.value === selectedMonthItem.value}
>
{monthItem.label}
</option>
{/each}
</select>
<span
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
aria-hidden="true"
>
{monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label}
<ChevronDownIcon class="size-4" />
</span>
{/snippet}
</CalendarPrimitive.MonthSelect>
</span>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import { type WithElementRef, cn } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div {...restProps} bind:this={ref} class={cn("flex flex-col", className)}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
class={cn("relative flex flex-col gap-4 md:flex-row", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<nav
{...restProps}
bind:this={ref}
class={cn("absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", className)}
>
{@render children?.()}
</nav>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
variant = "ghost",
...restProps
}: CalendarPrimitive.NextButtonProps & {
variant?: ButtonVariant;
} = $props();
</script>
{#snippet Fallback()}
<ChevronRightIcon class="size-4" />
{/snippet}
<CalendarPrimitive.NextButton
bind:ref
class={cn(
buttonVariants({ variant }),
"size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180",
className
)}
children={children || Fallback}
{...restProps}
/>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
variant = "ghost",
...restProps
}: CalendarPrimitive.PrevButtonProps & {
variant?: ButtonVariant;
} = $props();
</script>
{#snippet Fallback()}
<ChevronLeftIcon class="size-4" />
{/snippet}
<CalendarPrimitive.PrevButton
bind:ref
class={cn(
buttonVariants({ variant }),
"size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180",
className
)}
children={children || Fallback}
{...restProps}
/>

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
let {
ref = $bindable(null),
class: className,
value,
...restProps
}: WithoutChildrenOrChild<CalendarPrimitive.YearSelectProps> = $props();
</script>
<span
class={cn(
"has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]",
className
)}
>
<CalendarPrimitive.YearSelect bind:ref class="absolute inset-0 opacity-0" {...restProps}>
{#snippet child({ props, yearItems, selectedYearItem })}
<select {...props} {value}>
{#each yearItems as yearItem (yearItem.value)}
<option
value={yearItem.value}
selected={value !== undefined
? yearItem.value === value
: yearItem.value === selectedYearItem.value}
>
{yearItem.label}
</option>
{/each}
</select>
<span
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
aria-hidden="true"
>
{yearItems.find((item) => item.value === value)?.label || selectedYearItem.label}
<ChevronDownIcon class="size-4" />
</span>
{/snippet}
</CalendarPrimitive.YearSelect>
</span>

View File

@@ -0,0 +1,115 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import * as Calendar from "./index.js";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ButtonVariant } from "../button/button.svelte";
import { isEqualMonth, type DateValue } from "@internationalized/date";
import type { Snippet } from "svelte";
let {
ref = $bindable(null),
value = $bindable(),
placeholder = $bindable(),
class: className,
weekdayFormat = "short",
buttonVariant = "ghost",
captionLayout = "label",
locale = "en-US",
months: monthsProp,
years,
monthFormat: monthFormatProp,
yearFormat = "numeric",
day,
disableDaysOutsideMonth = false,
...restProps
}: WithoutChildrenOrChild<CalendarPrimitive.RootProps> & {
buttonVariant?: ButtonVariant;
captionLayout?: "dropdown" | "dropdown-months" | "dropdown-years" | "label";
months?: CalendarPrimitive.MonthSelectProps["months"];
years?: CalendarPrimitive.YearSelectProps["years"];
monthFormat?: CalendarPrimitive.MonthSelectProps["monthFormat"];
yearFormat?: CalendarPrimitive.YearSelectProps["yearFormat"];
day?: Snippet<[{ day: DateValue; outsideMonth: boolean }]>;
} = $props();
const monthFormat = $derived.by(() => {
if (monthFormatProp) return monthFormatProp;
if (captionLayout.startsWith("dropdown")) return "short";
return "long";
});
</script>
<!--
Discriminated Unions + Destructing (required for bindable) do not
get along, so we shut typescript up by casting `value` to `never`.
-->
<CalendarPrimitive.Root
bind:value={value as never}
bind:ref
bind:placeholder
{weekdayFormat}
{disableDaysOutsideMonth}
class={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
className
)}
{locale}
{monthFormat}
{yearFormat}
{...restProps}
>
{#snippet children({ months, weekdays })}
<Calendar.Months>
<Calendar.Nav>
<Calendar.PrevButton variant={buttonVariant} />
<Calendar.NextButton variant={buttonVariant} />
</Calendar.Nav>
{#each months as month, monthIndex (month)}
<Calendar.Month>
<Calendar.Header>
<Calendar.Caption
{captionLayout}
months={monthsProp}
{monthFormat}
{years}
{yearFormat}
month={month.value}
bind:placeholder
{locale}
{monthIndex}
/>
</Calendar.Header>
<Calendar.Grid>
<Calendar.GridHead>
<Calendar.GridRow class="select-none">
{#each weekdays as weekday (weekday)}
<Calendar.HeadCell>
{weekday.slice(0, 2)}
</Calendar.HeadCell>
{/each}
</Calendar.GridRow>
</Calendar.GridHead>
<Calendar.GridBody>
{#each month.weeks as weekDates (weekDates)}
<Calendar.GridRow class="mt-2 w-full">
{#each weekDates as date (date)}
<Calendar.Cell {date} month={month.value}>
{#if day}
{@render day({
day: date,
outsideMonth: !isEqualMonth(date, month.value),
})}
{:else}
<Calendar.Day />
{/if}
</Calendar.Cell>
{/each}
</Calendar.GridRow>
{/each}
</Calendar.GridBody>
</Calendar.Grid>
</Calendar.Month>
{/each}
</Calendar.Months>
{/snippet}
</CalendarPrimitive.Root>

View File

@@ -0,0 +1,40 @@
import Root from "./calendar.svelte";
import Cell from "./calendar-cell.svelte";
import Day from "./calendar-day.svelte";
import Grid from "./calendar-grid.svelte";
import Header from "./calendar-header.svelte";
import Months from "./calendar-months.svelte";
import GridRow from "./calendar-grid-row.svelte";
import Heading from "./calendar-heading.svelte";
import GridBody from "./calendar-grid-body.svelte";
import GridHead from "./calendar-grid-head.svelte";
import HeadCell from "./calendar-head-cell.svelte";
import NextButton from "./calendar-next-button.svelte";
import PrevButton from "./calendar-prev-button.svelte";
import MonthSelect from "./calendar-month-select.svelte";
import YearSelect from "./calendar-year-select.svelte";
import Month from "./calendar-month.svelte";
import Nav from "./calendar-nav.svelte";
import Caption from "./calendar-caption.svelte";
export {
Day,
Cell,
Grid,
Header,
Months,
GridRow,
Heading,
GridBody,
GridHead,
HeadCell,
NextButton,
PrevButton,
Nav,
Month,
YearSelect,
MonthSelect,
Caption,
//
Root as Calendar,
};

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-action"
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="card-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
>
{@render children?.()}
</p>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-footer"
class={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-header"
class={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-title"
class={cn("leading-none font-semibold", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card"
class={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,25 @@
import Root from "./card.svelte";
import Content from "./card-content.svelte";
import Description from "./card-description.svelte";
import Footer from "./card-footer.svelte";
import Header from "./card-header.svelte";
import Title from "./card-title.svelte";
import Action from "./card-action.svelte";
export {
Root,
Content,
Description,
Footer,
Header,
Title,
Action,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction,
};

View File

@@ -0,0 +1,80 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import ChartStyle from "./chart-style.svelte";
import { setChartContext, type ChartConfig } from "./chart-utils.js";
const uid = $props.id();
let {
ref = $bindable(null),
id = uid,
class: className,
children,
config,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> & {
config: ChartConfig;
} = $props();
const chartId = `chart-${id || uid.replace(/:/g, "")}`;
setChartContext({
get config() {
return config;
},
});
</script>
<div
bind:this={ref}
data-chart={chartId}
data-slot="chart"
class={cn(
"flex aspect-video justify-center overflow-visible text-xs",
// Overrides
//
// Stroke around dots/marks when hovering
"[&_.lc-highlight-point]:stroke-transparent",
// override the default stroke color of lines
"[&_.lc-line]:stroke-border/50",
// by default, layerchart shows a line intersecting the point when hovering, this hides that
"[&_.lc-highlight-line]:stroke-0",
// by default, when you hover a point on a stacked series chart, it will drop the opacity
// of the other series, this overrides that
"[&_.lc-area-path]:opacity-100 [&_.lc-highlight-line]:opacity-100 [&_.lc-highlight-point]:opacity-100 [&_.lc-spline-path]:opacity-100 [&_.lc-text]:text-xs [&_.lc-text-svg]:overflow-visible",
// We don't want the little tick lines between the axis labels and the chart, so we remove
// the stroke. The alternative is to manually disable `tickMarks` on the x/y axis of every
// chart.
"[&_.lc-axis-tick]:stroke-0",
// We don't want to display the rule on the x/y axis, as there is already going to be
// a grid line there and rule ends up overlapping the marks because it is rendered after
// the marks
"[&_.lc-rule-x-line:not(.lc-grid-x-rule)]:stroke-0 [&_.lc-rule-y-line:not(.lc-grid-y-rule)]:stroke-0",
"[&_.lc-grid-x-radial-line]:stroke-border [&_.lc-grid-x-radial-circle]:stroke-border",
"[&_.lc-grid-y-radial-line]:stroke-border [&_.lc-grid-y-radial-circle]:stroke-border",
// Legend adjustments
"[&_.lc-legend-swatch-button]:items-center [&_.lc-legend-swatch-button]:gap-1.5",
"[&_.lc-legend-swatch-group]:items-center [&_.lc-legend-swatch-group]:gap-4",
"[&_.lc-legend-swatch]:size-2.5 [&_.lc-legend-swatch]:rounded-[2px]",
// Labels
"[&_.lc-labels-text:not([fill])]:fill-foreground [&_text]:stroke-transparent",
// Tick labels on th x/y axes
"[&_.lc-axis-tick-label]:fill-muted-foreground [&_.lc-axis-tick-label]:font-normal",
"[&_.lc-tooltip-rects-g]:fill-transparent",
"[&_.lc-layout-svg-g]:fill-transparent",
"[&_.lc-root-container]:w-full",
className
)}
{...restProps}
>
<ChartStyle id={chartId} {config} />
{@render children?.()}
</div>

View File

@@ -0,0 +1,37 @@
<script lang="ts">
import { THEMES, type ChartConfig } from "./chart-utils.js";
let { id, config }: { id: string; config: ChartConfig } = $props();
const colorConfig = $derived(
config ? Object.entries(config).filter(([, config]) => config.theme || config.color) : null
);
const themeContents = $derived.by(() => {
if (!colorConfig || !colorConfig.length) return;
const themeContents = [];
for (let [_theme, prefix] of Object.entries(THEMES)) {
let content = `${prefix} [data-chart=${id}] {\n`;
const color = colorConfig.map(([key, itemConfig]) => {
const theme = _theme as keyof typeof itemConfig.theme;
const color = itemConfig.theme?.[theme] || itemConfig.color;
return color ? `\t--color-${key}: ${color};` : null;
});
content += color.join("\n") + "\n}";
themeContents.push(content);
}
return themeContents.join("\n");
});
</script>
{#if themeContents}
{#key id}
<svelte:element this={"style"}>
{themeContents}
</svelte:element>
{/key}
{/if}

View File

@@ -0,0 +1,159 @@
<script lang="ts">
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { getPayloadConfigFromPayload, useChart, type TooltipPayload } from "./chart-utils.js";
import { getTooltipContext, Tooltip as TooltipPrimitive } from "layerchart";
import type { Snippet } from "svelte";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function defaultFormatter(value: any, _payload: TooltipPayload[]) {
return `${value}`;
}
let {
ref = $bindable(null),
class: className,
hideLabel = false,
indicator = "dot",
hideIndicator = false,
labelKey,
label,
labelFormatter = defaultFormatter,
labelClassName,
formatter,
nameKey,
color,
...restProps
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> & {
hideLabel?: boolean;
label?: string;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
hideIndicator?: boolean;
labelClassName?: string;
labelFormatter?: // eslint-disable-next-line @typescript-eslint/no-explicit-any
((value: any, payload: TooltipPayload[]) => string | number | Snippet) | null;
formatter?: Snippet<
[
{
value: unknown;
name: string;
item: TooltipPayload;
index: number;
payload: TooltipPayload[];
},
]
>;
} = $props();
const chart = useChart();
const tooltipCtx = getTooltipContext();
const formattedLabel = $derived.by(() => {
if (hideLabel || !tooltipCtx.payload?.length) return null;
const [item] = tooltipCtx.payload;
const key = labelKey ?? item?.label ?? item?.name ?? "value";
const itemConfig = getPayloadConfigFromPayload(chart.config, item, key);
const value =
!labelKey && typeof label === "string"
? (chart.config[label as keyof typeof chart.config]?.label ?? label)
: (itemConfig?.label ?? item.label);
if (value === undefined) return null;
if (!labelFormatter) return value;
return labelFormatter(value, tooltipCtx.payload);
});
const nestLabel = $derived(tooltipCtx.payload.length === 1 && indicator !== "dot");
</script>
{#snippet TooltipLabel()}
{#if formattedLabel}
<div class={cn("font-medium", labelClassName)}>
{#if typeof formattedLabel === "function"}
{@render formattedLabel()}
{:else}
{formattedLabel}
{/if}
</div>
{/if}
{/snippet}
<TooltipPrimitive.Root variant="none">
<div
class={cn(
"border-border/50 bg-background grid min-w-[9rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}
{...restProps}
>
{#if !nestLabel}
{@render TooltipLabel()}
{/if}
<div class="grid gap-1.5">
{#each tooltipCtx.payload as item, i (item.key + i)}
{@const key = `${nameKey || item.key || item.name || "value"}`}
{@const itemConfig = getPayloadConfigFromPayload(chart.config, item, key)}
{@const indicatorColor = color || item.payload?.color || item.color}
<div
class={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:size-2.5",
indicator === "dot" && "items-center"
)}
>
{#if formatter && item.value !== undefined && item.name}
{@render formatter({
value: item.value,
name: item.name,
item,
index: i,
payload: tooltipCtx.payload,
})}
{:else}
{#if itemConfig?.icon}
<itemConfig.icon />
{:else if !hideIndicator}
<div
style="--color-bg: {indicatorColor}; --color-border: {indicatorColor};"
class={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"size-2.5": indicator === "dot",
"h-full w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
></div>
{/if}
<div
class={cn(
"flex flex-1 shrink-0 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div class="grid gap-1.5">
{#if nestLabel}
{@render TooltipLabel()}
{/if}
<span class="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{#if item.value !== undefined}
<span class="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
{/if}
</div>
{/if}
</div>
{/each}
</div>
</div>
</TooltipPrimitive.Root>

View File

@@ -0,0 +1,66 @@
import type { Tooltip } from "layerchart";
import { getContext, setContext, type Component, type ComponentProps, type Snippet } from "svelte";
export const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: string;
icon?: Component;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
export type ExtractSnippetParams<T> = T extends Snippet<[infer P]> ? P : never;
export type TooltipPayload = ExtractSnippetParams<
ComponentProps<typeof Tooltip.Root>["children"]
>["payload"][number];
// Helper to extract item config from a payload.
export function getPayloadConfigFromPayload(
config: ChartConfig,
payload: TooltipPayload,
key: string
) {
if (typeof payload !== "object" || payload === null) return undefined;
const payloadPayload =
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (payload.key === key) {
configLabelKey = payload.key;
} else if (payload.name === key) {
configLabelKey = payload.name;
} else if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload !== undefined &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
}
type ChartContextValue = {
config: ChartConfig;
};
const chartContextKey = Symbol("chart-context");
export function setChartContext(value: ChartContextValue) {
return setContext(chartContextKey, value);
}
export function useChart() {
return getContext<ChartContextValue>(chartContextKey);
}

View File

@@ -0,0 +1,6 @@
import ChartContainer from "./chart-container.svelte";
import ChartTooltip from "./chart-tooltip.svelte";
export { getPayloadConfigFromPayload, type ChartConfig } from "./chart-utils.js";
export { ChartContainer, ChartTooltip, ChartContainer as Container, ChartTooltip as Tooltip };

Some files were not shown because too many files have changed in this diff Show More