Compare commits
10 Commits
c40a118a3d
...
eb76c3a8dc
| Author | SHA1 | Date | |
|---|---|---|---|
| eb76c3a8dc | |||
| 9247e1ec7f | |||
| 6d33132a4a | |||
| 48332efce4 | |||
| 087ae027cc | |||
|
|
94f8ea12e6 | ||
|
|
c1ffe2e822 | ||
|
|
ccd2d0386a | ||
|
|
b226c85fa7 | ||
| 9d409d6a93 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -21,3 +21,4 @@ server/uploads/
|
||||
server/outputs/
|
||||
*.log
|
||||
|
||||
mongodata/
|
||||
265
README.md
Normal file
265
README.md
Normal file
@@ -0,0 +1,265 @@
|
||||
# 💰 BillAI - 智能账单分析系统
|
||||
|
||||
一个基于微服务架构的个人账单分析工具,支持微信和支付宝账单的自动解析、智能分类和可视化分析。
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
- 📊 **账单分析** - 自动解析微信/支付宝账单,生成可视化报表
|
||||
- 🏷️ **智能分类** - 基于关键词匹配的交易分类推断
|
||||
- 📈 **趋势图表** - 日/月消费趋势、分类排行、收支对比
|
||||
- 🔍 **复核修正** - 对不确定的分类进行人工复核
|
||||
- 🐳 **一键部署** - 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
101
TODO.md
Normal 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
25
analyzer/Dockerfile
Normal 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"]
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -218,6 +218,7 @@
|
||||
- 煲仔饭
|
||||
- 蛙来哒 # 牛蛙餐厅
|
||||
- 粒上皇 # 炒货零食店
|
||||
- 盒马
|
||||
|
||||
# 转账红包
|
||||
转账红包:
|
||||
|
||||
@@ -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
351
analyzer/server.py
Normal 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
120
docker-compose.yaml
Normal 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
49
server/Dockerfile
Normal 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
28
server/adapter/adapter.go
Normal 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
15
server/adapter/global.go
Normal 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
|
||||
}
|
||||
204
server/adapter/http/cleaner.go
Normal file
204
server/adapter/http/cleaner.go
Normal 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)
|
||||
94
server/adapter/python/cleaner.go
Normal file
94
server/adapter/python/cleaner.go
Normal 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
BIN
server/billai-server.exe
Normal file
Binary file not shown.
@@ -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
|
||||
|
||||
|
||||
@@ -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
72
server/database/mongo.go
Normal 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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
200
server/handler/bills.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
127
server/main.go
127
server/main.go
@@ -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
47
server/model/bill.go
Normal 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"` // 月收入总额
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 上传响应
|
||||
|
||||
14
server/repository/global.go
Normal file
14
server/repository/global.go
Normal 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
|
||||
}
|
||||
407
server/repository/mongo/repository.go
Normal file
407
server/repository/mongo/repository.go
Normal 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)
|
||||
48
server/repository/repository.go
Normal file
48
server/repository/repository.go
Normal 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
56
server/router/router.go
Normal 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
632
server/service/bill.go
Normal 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
48
server/service/cleaner.go
Normal 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
23
web/.gitignore
vendored
Normal 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
2
web/.npmrc
Normal file
@@ -0,0 +1,2 @@
|
||||
engine-strict=true
|
||||
registry=https://registry.npmmirror.com
|
||||
9
web/.prettierignore
Normal file
9
web/.prettierignore
Normal 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
17
web/.prettierrc
Normal 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
56
web/Dockerfile
Normal 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
38
web/README.md
Normal 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
16
web/components.json
Normal 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
41
web/eslint.config.js
Normal 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
53
web/package.json
Normal 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
128
web/src/app.css
Normal 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
13
web/src/app.d.ts
vendored
Normal 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
11
web/src/app.html
Normal 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
7
web/src/demo.spec.ts
Normal 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
277
web/src/lib/api.ts
Normal 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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
1
web/src/lib/assets/favicon.svg
Normal file
1
web/src/lib/assets/favicon.svg
Normal 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 |
483
web/src/lib/components/analysis/BillRecordsTable.svelte
Normal file
483
web/src/lib/components/analysis/BillRecordsTable.svelte
Normal 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>
|
||||
225
web/src/lib/components/analysis/CategoryRanking.svelte
Normal file
225
web/src/lib/components/analysis/CategoryRanking.svelte
Normal 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>
|
||||
897
web/src/lib/components/analysis/DailyTrendChart.svelte
Normal file
897
web/src/lib/components/analysis/DailyTrendChart.svelte
Normal 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>
|
||||
26
web/src/lib/components/analysis/EmptyState.svelte
Normal file
26
web/src/lib/components/analysis/EmptyState.svelte
Normal 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>
|
||||
|
||||
|
||||
69
web/src/lib/components/analysis/MonthlyTrend.svelte
Normal file
69
web/src/lib/components/analysis/MonthlyTrend.svelte
Normal 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>
|
||||
|
||||
|
||||
65
web/src/lib/components/analysis/OverviewCards.svelte
Normal file
65
web/src/lib/components/analysis/OverviewCards.svelte
Normal 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>
|
||||
|
||||
|
||||
289
web/src/lib/components/analysis/TopExpenses.svelte
Normal file
289
web/src/lib/components/analysis/TopExpenses.svelte
Normal 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>
|
||||
9
web/src/lib/components/analysis/index.ts
Normal file
9
web/src/lib/components/analysis/index.ts
Normal 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';
|
||||
|
||||
|
||||
17
web/src/lib/components/ui/avatar/avatar-fallback.svelte
Normal file
17
web/src/lib/components/ui/avatar/avatar-fallback.svelte
Normal 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}
|
||||
/>
|
||||
17
web/src/lib/components/ui/avatar/avatar-image.svelte
Normal file
17
web/src/lib/components/ui/avatar/avatar-image.svelte
Normal 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}
|
||||
/>
|
||||
19
web/src/lib/components/ui/avatar/avatar.svelte
Normal file
19
web/src/lib/components/ui/avatar/avatar.svelte
Normal 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}
|
||||
/>
|
||||
13
web/src/lib/components/ui/avatar/index.ts
Normal file
13
web/src/lib/components/ui/avatar/index.ts
Normal 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,
|
||||
};
|
||||
50
web/src/lib/components/ui/badge/badge.svelte
Normal file
50
web/src/lib/components/ui/badge/badge.svelte
Normal 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>
|
||||
2
web/src/lib/components/ui/badge/index.ts
Normal file
2
web/src/lib/components/ui/badge/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Badge } from "./badge.svelte";
|
||||
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
|
||||
82
web/src/lib/components/ui/button/button.svelte
Normal file
82
web/src/lib/components/ui/button/button.svelte
Normal 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}
|
||||
17
web/src/lib/components/ui/button/index.ts
Normal file
17
web/src/lib/components/ui/button/index.ts
Normal 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,
|
||||
};
|
||||
76
web/src/lib/components/ui/calendar/calendar-caption.svelte
Normal file
76
web/src/lib/components/ui/calendar/calendar-caption.svelte
Normal 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}
|
||||
19
web/src/lib/components/ui/calendar/calendar-cell.svelte
Normal file
19
web/src/lib/components/ui/calendar/calendar-cell.svelte
Normal 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}
|
||||
/>
|
||||
35
web/src/lib/components/ui/calendar/calendar-day.svelte
Normal file
35
web/src/lib/components/ui/calendar/calendar-day.svelte
Normal 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}
|
||||
/>
|
||||
12
web/src/lib/components/ui/calendar/calendar-grid-body.svelte
Normal file
12
web/src/lib/components/ui/calendar/calendar-grid-body.svelte
Normal 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} />
|
||||
12
web/src/lib/components/ui/calendar/calendar-grid-head.svelte
Normal file
12
web/src/lib/components/ui/calendar/calendar-grid-head.svelte
Normal 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} />
|
||||
12
web/src/lib/components/ui/calendar/calendar-grid-row.svelte
Normal file
12
web/src/lib/components/ui/calendar/calendar-grid-row.svelte
Normal 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} />
|
||||
16
web/src/lib/components/ui/calendar/calendar-grid.svelte
Normal file
16
web/src/lib/components/ui/calendar/calendar-grid.svelte
Normal 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}
|
||||
/>
|
||||
19
web/src/lib/components/ui/calendar/calendar-head-cell.svelte
Normal file
19
web/src/lib/components/ui/calendar/calendar-head-cell.svelte
Normal 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}
|
||||
/>
|
||||
19
web/src/lib/components/ui/calendar/calendar-header.svelte
Normal file
19
web/src/lib/components/ui/calendar/calendar-header.svelte
Normal 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}
|
||||
/>
|
||||
16
web/src/lib/components/ui/calendar/calendar-heading.svelte
Normal file
16
web/src/lib/components/ui/calendar/calendar-heading.svelte
Normal 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}
|
||||
/>
|
||||
@@ -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>
|
||||
15
web/src/lib/components/ui/calendar/calendar-month.svelte
Normal file
15
web/src/lib/components/ui/calendar/calendar-month.svelte
Normal 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>
|
||||
19
web/src/lib/components/ui/calendar/calendar-months.svelte
Normal file
19
web/src/lib/components/ui/calendar/calendar-months.svelte
Normal 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>
|
||||
19
web/src/lib/components/ui/calendar/calendar-nav.svelte
Normal file
19
web/src/lib/components/ui/calendar/calendar-nav.svelte
Normal 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>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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>
|
||||
115
web/src/lib/components/ui/calendar/calendar.svelte
Normal file
115
web/src/lib/components/ui/calendar/calendar.svelte
Normal 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>
|
||||
40
web/src/lib/components/ui/calendar/index.ts
Normal file
40
web/src/lib/components/ui/calendar/index.ts
Normal 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,
|
||||
};
|
||||
20
web/src/lib/components/ui/card/card-action.svelte
Normal file
20
web/src/lib/components/ui/card/card-action.svelte
Normal 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>
|
||||
15
web/src/lib/components/ui/card/card-content.svelte
Normal file
15
web/src/lib/components/ui/card/card-content.svelte
Normal 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>
|
||||
20
web/src/lib/components/ui/card/card-description.svelte
Normal file
20
web/src/lib/components/ui/card/card-description.svelte
Normal 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>
|
||||
20
web/src/lib/components/ui/card/card-footer.svelte
Normal file
20
web/src/lib/components/ui/card/card-footer.svelte
Normal 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>
|
||||
23
web/src/lib/components/ui/card/card-header.svelte
Normal file
23
web/src/lib/components/ui/card/card-header.svelte
Normal 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>
|
||||
20
web/src/lib/components/ui/card/card-title.svelte
Normal file
20
web/src/lib/components/ui/card/card-title.svelte
Normal 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>
|
||||
23
web/src/lib/components/ui/card/card.svelte
Normal file
23
web/src/lib/components/ui/card/card.svelte
Normal 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>
|
||||
25
web/src/lib/components/ui/card/index.ts
Normal file
25
web/src/lib/components/ui/card/index.ts
Normal 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,
|
||||
};
|
||||
80
web/src/lib/components/ui/chart/chart-container.svelte
Normal file
80
web/src/lib/components/ui/chart/chart-container.svelte
Normal 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>
|
||||
37
web/src/lib/components/ui/chart/chart-style.svelte
Normal file
37
web/src/lib/components/ui/chart/chart-style.svelte
Normal 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}
|
||||
159
web/src/lib/components/ui/chart/chart-tooltip.svelte
Normal file
159
web/src/lib/components/ui/chart/chart-tooltip.svelte
Normal 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>
|
||||
66
web/src/lib/components/ui/chart/chart-utils.ts
Normal file
66
web/src/lib/components/ui/chart/chart-utils.ts
Normal 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);
|
||||
}
|
||||
6
web/src/lib/components/ui/chart/index.ts
Normal file
6
web/src/lib/components/ui/chart/index.ts
Normal 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
Reference in New Issue
Block a user