feat: 完善项目架构并增强分析页面功能

- 新增项目文档和 Docker 配置
  - 添加 README.md 和 TODO.md 项目文档
  - 为各服务添加 Dockerfile 和 docker-compose 配置

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

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

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

- 完善分析器服务
  - 新增 FastAPI 服务接口
  - 改进账单清理器实现
This commit is contained in:
2026-01-10 01:15:52 +08:00
parent 94f8ea12e6
commit 087ae027cc
96 changed files with 4301 additions and 482 deletions

265
README.md Normal file
View File

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

101
TODO.md Normal file
View File

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

25
analyzer/Dockerfile Normal file
View File

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

View File

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

View File

@@ -85,6 +85,58 @@ def compute_date_range(args) -> tuple[date | None, date | None]:
return start_date, end_date
def compute_date_range_from_values(
year: str = None,
month: str = None,
start: str = None,
end: str = None
) -> tuple[date | None, date | None]:
"""
根据参数值计算日期范围(不依赖 argparse
供 HTTP API 调用使用
Returns:
(start_date, end_date) 或 (None, None) 表示不筛选
"""
start_date = None
end_date = None
# 1. 根据年份设置范围
if year:
y = int(year)
start_date = date(y, 1, 1)
end_date = date(y, 12, 31)
# 2. 根据月份进一步收窄
if month:
m = int(month)
y = int(year) if year else datetime.now().year
if not start_date:
start_date = date(y, 1, 1)
end_date = date(y, 12, 31)
month_start = date(y, m, 1)
if m == 12:
month_end = date(y, 12, 31)
else:
month_end = date(y, m + 1, 1) - timedelta(days=1)
start_date = max(start_date, month_start) if start_date else month_start
end_date = min(end_date, month_end) if end_date else month_end
# 3. 根据 start/end 参数进一步收窄
if start:
custom_start = parse_date(start)
start_date = max(start_date, custom_start) if start_date else custom_start
if end:
custom_end = parse_date(end)
end_date = min(end_date, custom_end) if end_date else custom_end
return start_date, end_date
def is_in_date_range(date_str: str, start_date: date | None, end_date: date | None) -> bool:
"""检查日期字符串是否在指定范围内"""
if start_date is None and end_date is None:

View File

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

View File

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

348
analyzer/server.py Normal file
View File

@@ -0,0 +1,348 @@
#!/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
# =============================================================================
# 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="1.0.0")
@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)

View File

@@ -1,4 +1,81 @@
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

49
server/Dockerfile Normal file
View File

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

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

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

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

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

View File

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

View File

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

View File

@@ -4,13 +4,20 @@
server:
port: 8080
# Python 配置
# Python 配置 (subprocess 模式使用)
python:
# Python 解释器路径(相对于项目根目录或绝对路径)
path: analyzer/venv/Scripts/python.exe
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

View File

@@ -18,6 +18,10 @@ type Config struct {
UploadDir string // 上传文件目录
OutputDir string // 输出文件目录
// Analyzer 服务配置 (HTTP 模式)
AnalyzerURL string // Python 分析服务 URL
AnalyzerMode string // 适配器模式: http 或 subprocess
// MongoDB 配置
MongoURI string // MongoDB 连接 URI
MongoDatabase string // 数据库名称
@@ -34,6 +38,10 @@ 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"`
@@ -116,6 +124,10 @@ 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")
@@ -148,6 +160,13 @@ 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
@@ -173,6 +192,13 @@ 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
@@ -195,4 +221,3 @@ func ResolvePath(path string) string {
}
return filepath.Join(Global.ProjectRoot, path)
}

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

@@ -0,0 +1,165 @@
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 != "" {
startTime, err := time.Parse("2006-01-02", req.StartDate)
if err == nil {
timeFilter["$gte"] = startTime
}
}
if req.EndDate != "" {
endTime, err := time.Parse("2006-01-02", req.EndDate)
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
}

View File

@@ -6,7 +6,6 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -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)
@@ -64,9 +80,6 @@ func Upload(c *gin.Context) {
return
}
// 账单类型从去重结果获取
billType := dedupResult.BillType
fmt.Printf(" 原始记录: %d 条\n", dedupResult.OriginalCount)
if dedupResult.DuplicateCount > 0 {
fmt.Printf(" 重复记录: %d 条(已跳过)\n", dedupResult.DuplicateCount)
@@ -91,14 +104,14 @@ func Upload(c *gin.Context) {
// 使用去重后的文件路径进行后续处理
processFilePath := dedupResult.DedupFilePath
// 5. 构建输出文件路径
baseName := strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))
// 5. 构建输出文件路径时间_type_编号
outputExt := ".csv"
if req.Format == "json" {
outputExt = ".json"
}
outputFileName := fmt.Sprintf("%s_%s_cleaned%s", timestamp, baseName, outputExt)
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 清洗脚本
@@ -109,7 +122,7 @@ func Upload(c *gin.Context) {
End: req.End,
Format: req.Format,
}
cleanResult, cleanErr := service.RunCleanScript(processFilePath, outputPath, cleanOpts)
_, cleanErr := service.RunCleanScript(processFilePath, outputPath, cleanOpts)
if cleanErr != nil {
c.JSON(http.StatusInternalServerError, model.UploadResponse{
Result: false,
@@ -118,12 +131,7 @@ func Upload(c *gin.Context) {
return
}
// 7. 如果去重检测没有识别出类型,从 Python 输出中检测
if billType == "" {
billType = cleanResult.BillType
}
// 8. 将去重后的原始数据存入 MongoDB原始数据集合
// 7. 将去重后的原始数据存入 MongoDB原始数据集合
rawCount, rawErr := service.SaveRawBillsFromFile(processFilePath, billType, header.Filename, timestamp)
if rawErr != nil {
fmt.Printf("⚠️ 存储原始数据到 MongoDB 失败: %v\n", rawErr)
@@ -163,3 +171,14 @@ func Upload(c *gin.Context) {
},
})
}
// generateFileSequence 生成文件序号
// 根据当前目录下同一时间戳和类型的文件数量生成序号
func generateFileSequence(dir, timestamp, billType, ext string) string {
pattern := fmt.Sprintf("%s_%s_*%s", timestamp, billType, ext)
matches, err := filepath.Glob(filepath.Join(dir, pattern))
if err != nil || len(matches) == 0 {
return "001"
}
return fmt.Sprintf("%03d", len(matches)+1)
}

View File

@@ -2,16 +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/database"
"billai-server/handler"
"billai-server/repository"
repoMongo "billai-server/repository/mongo"
"billai-server/router"
)
func main() {
@@ -36,7 +40,17 @@ func main() {
fmt.Println(" 请在配置文件中指定正确的 Python 路径")
}
// 连接 MongoDB
// 初始化适配器(外部服务交互层)
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(" 账单数据将不会存储到数据库")
@@ -50,7 +64,10 @@ func main() {
r := gin.Default()
// 注册路由
setupRoutes(r, outputDirAbs, pythonPathAbs)
router.Setup(r, router.Config{
OutputDir: outputDirAbs,
PythonPath: pythonPathAbs,
})
// 监听系统信号
go func() {
@@ -67,34 +84,18 @@ func main() {
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)
@@ -106,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
}

View File

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

View File

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

View File

@@ -0,0 +1,44 @@
// 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)
// GetBillsNeedReview 获取需要复核的账单
GetBillsNeedReview() ([]model.CleanedBill, error)
// CountRawByField 按字段统计原始数据数量
CountRawByField(fieldName, value string) (int64, error)
}

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

@@ -0,0 +1,53 @@
// Package router 路由配置
package router
import (
"net/http"
"github.com/gin-gonic/gin"
"billai-server/handler"
)
// Config 路由配置参数
type Config struct {
OutputDir string // 输出目录(用于静态文件服务)
PythonPath string // Python 路径(用于健康检查显示)
}
// Setup 设置所有路由
func Setup(r *gin.Engine, cfg Config) {
// 健康检查
r.GET("/health", healthCheck(cfg.PythonPath))
// API 路由组
setupAPIRoutes(r)
// 静态文件下载
r.Static("/download", cfg.OutputDir)
}
// healthCheck 健康检查处理器
func healthCheck(pythonPath string) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"python_path": pythonPath,
})
}
}
// 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)
}
}

View File

@@ -312,7 +312,7 @@ func saveCleanedBillsFromCSV(filePath, billType, sourceFile, uploadBatch string)
// 提取字段 - 订单号(用于去重判断)
if idx, ok := colIdx["交易订单号"]; ok && len(row) > idx {
bill.TransactionID = strings.TrimSpace(row[idx])
} else if idx, ok := colIdx["交易号"]; ok && len(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 {
@@ -325,24 +325,34 @@ func saveCleanedBillsFromCSV(filePath, billType, sourceFile, uploadBatch string)
}
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 {
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]

View File

@@ -1,84 +1,48 @@
// Package service 业务逻辑层
package service
import (
"fmt"
"os/exec"
"strings"
"billai-server/config"
"billai-server/adapter"
)
// CleanOptions 清洗选项
type CleanOptions struct {
Year string // 年份筛选
Month string // 月份筛选
Start string // 起始日期
End string // 结束日期
Format string // 输出格式: csv/json
}
// CleanOptions 清洗选项(保持向后兼容)
type CleanOptions = adapter.CleanOptions
// CleanResult 清洗结果
type CleanResult struct {
BillType string // 检测到的账单类型: alipay/wechat
Output string // Python 脚本输出
}
// CleanResult 清洗结果(保持向后兼容)
type CleanResult = adapter.CleanResult
// RunCleanScript 执行 Python 清洗脚本
// RunCleanScript 执行清洗脚本(使用适配器)
// inputPath: 输入文件路径
// outputPath: 输出文件路径
// opts: 清洗选项
func RunCleanScript(inputPath, outputPath string, opts *CleanOptions) (*CleanResult, error) {
// 构建命令参数
cleanScriptAbs := config.ResolvePath(config.Global.CleanScript)
args := []string{cleanScriptAbs, 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")
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 {
return nil, fmt.Errorf("清洗脚本执行失败: %w\n输出: %s", err, outputStr)
}
// 从输出中检测账单类型
billType := DetectBillTypeFromOutput(outputStr)
return &CleanResult{
BillType: billType,
Output: outputStr,
}, nil
cleaner := adapter.GetCleaner()
return cleaner.Clean(inputPath, outputPath, opts)
}
// DetectBillTypeFromOutput 从 Python 脚本输出中检测账单类型
// DetectBillTypeFromOutput 从脚本输出中检测账单类型
// 保留此函数以兼容其他调用
func DetectBillTypeFromOutput(output string) string {
if strings.Contains(output, "支付宝") {
if containsSubstring(output, "支付宝") {
return "alipay"
}
if strings.Contains(output, "微信") {
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
}

View File

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

56
web/Dockerfile Normal file
View File

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

View File

@@ -20,7 +20,7 @@
"@eslint/js": "^9.39.1",
"@internationalized/date": "^3.10.0",
"@lucide/svelte": "^0.561.0",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.49.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.18",

View File

@@ -1,11 +1,29 @@
// API 配置
const API_BASE = 'http://localhost:8080';
// 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: 'alipay' | 'wechat';
bill_type: BillType;
file_url: string;
file_name: string;
raw_count: number;
cleaned_count: number;
duplicate_count?: number;
}
export interface UploadResponse {
@@ -52,9 +70,14 @@ export interface BillRecord {
}
// 上传账单
export async function uploadBill(file: File, options?: { year?: number; month?: number }): Promise<UploadResponse> {
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());
@@ -108,23 +131,23 @@ function parseCSV(text: string): BillRecord[] {
const lines = text.trim().split('\n');
if (lines.length < 2) return [];
const headers = lines[0].split(',');
const records: BillRecord[] = [];
// CSV 格式:交易时间,交易分类,交易对方,对方账号,商品说明,收/支,金额,收/付款方式,交易状态,交易订单号,商家订单号,备注,,复核等级
for (let i = 1; i < lines.length; i++) {
const values = parseCSVLine(lines[i]);
if (values.length >= headers.length) {
if (values.length >= 7) {
records.push({
time: values[0] || '',
category: values[1] || '',
merchant: values[2] || '',
description: values[3] || '',
income_expense: values[4] || '',
amount: values[5] || '',
payment_method: values[6] || '',
status: values[7] || '',
remark: values[8] || '',
needs_review: values[9] || '',
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列
});
}
}
@@ -160,5 +183,71 @@ function parseCSVLine(line: string): string[] {
return result;
}
// 清洗后的账单记录
export interface CleanedBill {
id: string;
bill_type: string;
time: string;
category: string;
merchant: string;
description: string;
income_expense: string;
amount: number;
pay_method: string;
status: string;
remark: string;
review_level: string;
}
// 账单列表请求参数
export interface FetchBillsParams {
page?: number;
page_size?: number;
start_date?: string;
end_date?: string;
category?: string;
type?: string; // 账单来源 alipay/wechat
income_expense?: string; // 收支类型 收入/支出
}
// 账单列表响应
export interface BillsResponse {
result: boolean;
message?: string;
data?: {
total: number;
total_expense: number; // 筛选条件下的总支出
total_income: number; // 筛选条件下的总收入
page: number;
page_size: number;
pages: number;
bills: CleanedBill[];
};
}
// 获取账单列表(支持分页和筛选)
export async function fetchBills(params: FetchBillsParams = {}): Promise<BillsResponse> {
const searchParams = new URLSearchParams();
if (params.page) searchParams.set('page', params.page.toString());
if (params.page_size) searchParams.set('page_size', params.page_size.toString());
if (params.start_date) searchParams.set('start_date', params.start_date);
if (params.end_date) searchParams.set('end_date', params.end_date);
if (params.category) searchParams.set('category', params.category);
if (params.type) searchParams.set('type', params.type);
if (params.income_expense) searchParams.set('income_expense', params.income_expense);
const queryString = searchParams.toString();
const url = `${API_BASE}/api/bills${queryString ? '?' + queryString : ''}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import * as Table from '$lib/components/ui/table';
import * as Dialog from '$lib/components/ui/dialog';
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';
@@ -334,22 +334,22 @@
{/if}
<!-- 详情/编辑弹窗 -->
<Dialog.Root bind:open={detailDialogOpen}>
<Dialog.Content class="sm:max-w-md">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<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 ? '编辑账单' : '账单详情'}
</Dialog.Title>
<Dialog.Description>
</Drawer.Title>
<Drawer.Description>
{isEditing ? '修改这笔支出的信息' : '查看这笔支出的详细信息'}
</Dialog.Description>
</Dialog.Header>
</Drawer.Description>
</Drawer.Header>
{#if selectedRecord}
{#if isEditing}
<!-- 编辑表单 -->
<div class="space-y-4 py-4">
<div class="space-y-4 py-4 px-4 md:px-0">
<div class="space-y-2">
<Label>金额</Label>
<div class="relative">
@@ -400,7 +400,7 @@
</div>
{:else}
<!-- 详情展示 -->
<div class="py-4">
<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}
@@ -459,7 +459,7 @@
{/if}
{/if}
<Dialog.Footer>
<Drawer.Footer>
{#if isEditing}
<Button variant="outline" onclick={cancelEdit}>
<X class="h-4 w-4 mr-2" />
@@ -478,6 +478,6 @@
编辑
</Button>
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
</Drawer.Footer>
</Drawer.Content>
</Drawer.Root>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import * as Dialog from '$lib/components/ui/dialog';
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';
@@ -147,6 +147,7 @@
{@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}
@@ -197,28 +198,28 @@
</Card.Root>
<!-- 分类详情弹窗 -->
<Dialog.Root bind:open={dialogOpen}>
<Dialog.Content class="w-fit min-w-[500px] max-w-[90vw] max-h-[80vh] overflow-hidden flex flex-col">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<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} - 账单明细
</Dialog.Title>
<Dialog.Description>
</Drawer.Title>
<Drawer.Description>
{#if selectedStat}
{selectedStat.count} 笔,合计 ¥{selectedStat.expense.toFixed(2)}
{/if}
</Dialog.Description>
</Dialog.Header>
</Drawer.Description>
</Drawer.Header>
<div class="flex-1 overflow-auto mt-4">
<div class="flex-1 overflow-auto px-4 md:px-0">
<BillRecordsTable records={selectedRecords} showDescription={true} {categories} />
</div>
<Dialog.Footer class="mt-4">
<Drawer.Footer>
<Button variant="outline" onclick={() => dialogOpen = false}>
关闭
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
</Drawer.Footer>
</Drawer.Content>
</Drawer.Root>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import * as Select from '$lib/components/ui/select';
import * as Dialog from '$lib/components/ui/dialog';
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';
@@ -46,6 +46,12 @@
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: '本周' },
@@ -119,7 +125,7 @@
// 过滤支出记录
const expenseRecords = records.filter(r => {
if (r.income_expense !== '支出') return false;
const recordDate = new Date(r.time.split(' ')[0]);
const recordDate = new Date(extractDateStr(r.time));
return recordDate >= cutoffDate;
});
@@ -130,7 +136,7 @@
const categoryTotals: Record<string, number> = {};
expenseRecords.forEach(record => {
const dateStr = record.time.split(' ')[0];
const dateStr = extractDateStr(record.time);
const category = record.category || '其他';
const amount = parseFloat(record.amount) || 0;
@@ -526,7 +532,7 @@
selectedDate = clickedDate;
selectedDateRecords = records.filter(r => {
if (r.income_expense !== '支出') return false;
const recordDateStr = r.time.split(' ')[0];
const recordDateStr = extractDateStr(r.time);
return recordDateStr === dateStr;
});
@@ -641,7 +647,7 @@
<!-- 趋势图 (自定义 SVG) -->
<div class="relative w-full" style="aspect-ratio: {chartWidth}/{chartHeight};">
<!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
<!-- 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"
@@ -827,25 +833,25 @@
</Card.Root>
{/if}
<!-- 当日详情 Dialog -->
<Dialog.Root bind:open={dialogOpen}>
<Dialog.Content class="w-fit min-w-[500px] max-w-[90vw] max-h-[80vh] overflow-hidden flex flex-col">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<!-- 当日详情 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}
</Dialog.Title>
<Dialog.Description>
</Drawer.Title>
<Drawer.Description>
{#if selectedDateStats}
{@const stats = selectedDateStats}
{stats!.count} 笔支出,合计 ¥{stats!.total.toFixed(2)}
{/if}
</Dialog.Description>
</Dialog.Header>
</Drawer.Description>
</Drawer.Header>
<div class="flex-1 overflow-auto py-4">
<div class="flex-1 overflow-auto py-4 px-4 md:px-0">
{#if selectedDateStats}
{@const stats = selectedDateStats}
@@ -886,5 +892,5 @@
<p class="text-center text-muted-foreground py-8">暂无数据</p>
{/if}
</div>
</Dialog.Content>
</Dialog.Root>
</Drawer.Content>
</Drawer.Root>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import * as Dialog from '$lib/components/ui/dialog';
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';
@@ -134,10 +134,10 @@
</Card.Root>
<!-- 账单详情弹窗 -->
<Dialog.Root bind:open={dialogOpen}>
<Dialog.Content class="sm:max-w-[450px]">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<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}
@@ -149,16 +149,16 @@
Top {selectedRank}
</span>
{/if}
</Dialog.Title>
<Dialog.Description>
</Drawer.Title>
<Drawer.Description>
{isEditing ? '修改这笔支出的信息' : '查看这笔支出的完整信息'}
</Dialog.Description>
</Dialog.Header>
</Drawer.Description>
</Drawer.Header>
{#if selectedRecord}
{#if isEditing}
<!-- 编辑模式 -->
<div class="py-4 space-y-4">
<div class="py-4 space-y-4 px-4 md:px-0">
<div class="space-y-2">
<Label for="amount">金额</Label>
<div class="relative">
@@ -206,7 +206,7 @@
</div>
{:else}
<!-- 查看模式 -->
<div class="py-4 space-y-4">
<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>
@@ -265,7 +265,7 @@
{/if}
{/if}
<Dialog.Footer class="flex gap-2">
<Drawer.Footer class="flex gap-2">
{#if isEditing}
<Button variant="outline" onclick={cancelEdit}>
<X class="h-4 w-4 mr-2" />
@@ -284,6 +284,6 @@
编辑
</Button>
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
</Drawer.Footer>
</Drawer.Content>
</Drawer.Root>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,83 @@
<script lang="ts">
import { CalendarDate, type DateValue } from "@internationalized/date";
import CalendarIcon from "@lucide/svelte/icons/calendar";
import * as Popover from "$lib/components/ui/popover";
import { RangeCalendar } from "$lib/components/ui/range-calendar";
import { Button } from "$lib/components/ui/button";
import { cn } from "$lib/utils";
import type { DateRange } from "bits-ui";
interface Props {
startDate?: string;
endDate?: string;
onchange?: (start: string, end: string) => void;
class?: string;
}
let { startDate = $bindable(), endDate = $bindable(), onchange, class: className }: Props = $props();
// 将 YYYY-MM-DD 字符串转换为 CalendarDate
function parseDate(dateStr: string): DateValue | undefined {
if (!dateStr) return undefined;
const [year, month, day] = dateStr.split('-').map(Number);
return new CalendarDate(year, month, day);
}
// 将 CalendarDate 转换为 YYYY-MM-DD 字符串
function formatDate(date: DateValue | undefined): string {
if (!date) return '';
return `${date.year}-${String(date.month).padStart(2, '0')}-${String(date.day).padStart(2, '0')}`;
}
// 内部日期范围状态,使用 $derived 响应 props 变化
let value: DateRange = $derived({
start: parseDate(startDate),
end: parseDate(endDate)
});
// 格式化显示文本
let displayText = $derived(() => {
if (value.start && value.end) {
return `${formatDate(value.start)} ~ ${formatDate(value.end)}`;
}
if (value.start) {
return `${formatDate(value.start)} ~ `;
}
return "选择日期范围";
});
// 当日期变化时通知父组件
function handleValueChange(newValue: DateRange) {
if (newValue.start && newValue.end && onchange) {
onchange(formatDate(newValue.start), formatDate(newValue.end));
}
}
</script>
<Popover.Root>
<Popover.Trigger>
{#snippet child({ props })}
<Button
variant="outline"
class={cn(
"w-[260px] justify-start text-left font-normal",
!value.start && "text-muted-foreground",
className
)}
{...props}
>
<CalendarIcon class="mr-2 h-4 w-4" />
{displayText()}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-auto p-0" align="start">
<RangeCalendar
{value}
onValueChange={handleValueChange}
numberOfMonths={2}
locale="zh-CN"
weekStartsOn={1}
/>
</Popover.Content>
</Popover.Root>

View File

@@ -0,0 +1,3 @@
import DateRangePicker from "./date-range-picker.svelte";
export { DateRangePicker };

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import * as Sheet from '$lib/components/ui/sheet';
import { IsMobile } from '$lib/hooks/is-mobile.svelte.js';
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
class?: string;
}
let { children, class: className }: Props = $props();
const isMobile = new IsMobile();
</script>
{#if isMobile.current}
<Sheet.Close class={className}>
{@render children?.()}
</Sheet.Close>
{:else}
<Dialog.Close class={className}>
{@render children?.()}
</Dialog.Close>
{/if}

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import * as Sheet from '$lib/components/ui/sheet';
import { IsMobile } from '$lib/hooks/is-mobile.svelte.js';
import { cn } from '$lib/utils.js';
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
class?: string;
/** 移动端 Sheet 的方向,默认 bottom */
side?: 'top' | 'bottom' | 'left' | 'right';
}
let { children, class: className, side = 'bottom' }: Props = $props();
const isMobile = new IsMobile();
</script>
{#if isMobile.current}
<Sheet.Content
{side}
class={cn('max-h-[90vh] overflow-hidden flex flex-col', className)}
>
<!-- 拖拽指示器 (移动端抽屉常见设计) -->
<div class="mx-auto mt-2 h-1.5 w-12 shrink-0 rounded-full bg-muted"></div>
{@render children?.()}
</Sheet.Content>
{:else}
<Dialog.Content class={cn('max-h-[85vh] overflow-hidden flex flex-col', className)}>
{@render children?.()}
</Dialog.Content>
{/if}

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import * as Sheet from '$lib/components/ui/sheet';
import { IsMobile } from '$lib/hooks/is-mobile.svelte.js';
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
class?: string;
}
let { children, class: className }: Props = $props();
const isMobile = new IsMobile();
</script>
{#if isMobile.current}
<Sheet.Description class={className}>
{@render children?.()}
</Sheet.Description>
{:else}
<Dialog.Description class={className}>
{@render children?.()}
</Dialog.Description>
{/if}

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import * as Sheet from '$lib/components/ui/sheet';
import { IsMobile } from '$lib/hooks/is-mobile.svelte.js';
import { cn } from '$lib/utils.js';
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
class?: string;
}
let { children, class: className }: Props = $props();
const isMobile = new IsMobile();
</script>
{#if isMobile.current}
<Sheet.Footer class={cn('pt-2', className)}>
{@render children?.()}
</Sheet.Footer>
{:else}
<Dialog.Footer class={className}>
{@render children?.()}
</Dialog.Footer>
{/if}

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import * as Sheet from '$lib/components/ui/sheet';
import { IsMobile } from '$lib/hooks/is-mobile.svelte.js';
import { cn } from '$lib/utils.js';
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
class?: string;
}
let { children, class: className }: Props = $props();
const isMobile = new IsMobile();
</script>
{#if isMobile.current}
<Sheet.Header class={cn('text-left', className)}>
{@render children?.()}
</Sheet.Header>
{:else}
<Dialog.Header class={className}>
{@render children?.()}
</Dialog.Header>
{/if}

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import * as Sheet from '$lib/components/ui/sheet';
import { IsMobile } from '$lib/hooks/is-mobile.svelte.js';
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
class?: string;
}
let { children, class: className }: Props = $props();
const isMobile = new IsMobile();
</script>
{#if isMobile.current}
<Sheet.Title class={className}>
{@render children?.()}
</Sheet.Title>
{:else}
<Dialog.Title class={className}>
{@render children?.()}
</Dialog.Title>
{/if}

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import * as Sheet from '$lib/components/ui/sheet';
import { IsMobile } from '$lib/hooks/is-mobile.svelte.js';
import type { Snippet } from 'svelte';
interface Props {
open?: boolean;
onOpenChange?: (open: boolean) => void;
children: Snippet;
}
let { open = $bindable(false), onOpenChange, children }: Props = $props();
const isMobile = new IsMobile();
</script>
{#if isMobile.current}
<Sheet.Root bind:open {onOpenChange}>
{@render children?.()}
</Sheet.Root>
{:else}
<Dialog.Root bind:open {onOpenChange}>
{@render children?.()}
</Dialog.Root>
{/if}

View File

@@ -0,0 +1,25 @@
import Root from './drawer.svelte';
import Content from './drawer-content.svelte';
import Header from './drawer-header.svelte';
import Footer from './drawer-footer.svelte';
import Title from './drawer-title.svelte';
import Description from './drawer-description.svelte';
import Close from './drawer-close.svelte';
export {
Root,
Content,
Header,
Footer,
Title,
Description,
Close,
//
Root as Drawer,
Content as DrawerContent,
Header as DrawerHeader,
Footer as DrawerFooter,
Title as DrawerTitle,
Description as DrawerDescription,
Close as DrawerClose
};

View File

@@ -0,0 +1,19 @@
import Root from "./popover.svelte";
import Close from "./popover-close.svelte";
import Content from "./popover-content.svelte";
import Trigger from "./popover-trigger.svelte";
import Portal from "./popover-portal.svelte";
export {
Root,
Content,
Trigger,
Close,
Portal,
//
Root as Popover,
Content as PopoverContent,
Trigger as PopoverTrigger,
Close as PopoverClose,
Portal as PopoverPortal,
};

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props();
</script>
<PopoverPrimitive.Close bind:ref data-slot="popover-close" {...restProps} />

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
import PopoverPortal from "./popover-portal.svelte";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
align = "center",
portalProps,
...restProps
}: PopoverPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof PopoverPortal>>;
} = $props();
</script>
<PopoverPortal {...portalProps}>
<PopoverPrimitive.Content
bind:ref
data-slot="popover-content"
{sideOffset}
{align}
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...restProps}
/>
</PopoverPortal>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
</script>
<PopoverPrimitive.Portal {...restProps} />

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Popover as PopoverPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
...restProps
}: PopoverPrimitive.TriggerProps = $props();
</script>
<PopoverPrimitive.Trigger
bind:ref
data-slot="popover-trigger"
class={cn("", className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props();
</script>
<PopoverPrimitive.Root bind:open {...restProps} />

View File

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

View File

@@ -0,0 +1,76 @@
<script lang="ts">
import type { ComponentProps } from "svelte";
import type RangeCalendar from "./range-calendar.svelte";
import RangeCalendarMonthSelect from "./range-calendar-month-select.svelte";
import RangeCalendarYearSelect from "./range-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 RangeCalendar>["captionLayout"];
months: ComponentProps<typeof RangeCalendarMonthSelect>["months"];
monthFormat: ComponentProps<typeof RangeCalendarMonthSelect>["monthFormat"];
years: ComponentProps<typeof RangeCalendarYearSelect>["years"];
yearFormat: ComponentProps<typeof RangeCalendarYearSelect>["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()}
<RangeCalendarMonthSelect
{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()}
<RangeCalendarYearSelect {years} {yearFormat} value={month.year} />
{/snippet}
{#if captionLayout === "dropdown"}
{@render MonthSelect()}
{@render YearSelect()}
{:else if captionLayout === "dropdown-months"}
{@render MonthSelect()}
{#if placeholder}
{formatYear(placeholder)}
{/if}
{:else if captionLayout === "dropdown-years"}
{#if placeholder}
{formatMonth(placeholder)}
{/if}
{@render YearSelect()}
{:else}
{formatMonth(month)} {formatYear(month)}
{/if}

View File

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

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import { RangeCalendar as RangeCalendarPrimitive } from "bits-ui";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: RangeCalendarPrimitive.DayProps = $props();
</script>
<RangeCalendarPrimitive.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-[range-middle]:rounded-none",
// range Start
"data-[range-start]:bg-primary dark:data-[range-start]:hover:bg-accent data-[range-start]:text-primary-foreground",
// range End
"data-[range-end]:bg-primary dark:data-[range-end]:hover:bg-accent data-[range-end]: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]:line-through",
"dark:data-[range-middle]:hover:bg-accent/0",
// hover
"dark:hover:text-accent-foreground",
// focus
"focus:border-ring focus:ring-ring/50 focus:relative",
// inner spans
"[&>span]:text-xs [&>span]:opacity-70",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { RangeCalendar as RangeCalendarPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: RangeCalendarPrimitive.GridBodyProps = $props();
</script>
<RangeCalendarPrimitive.GridBody bind:ref {...restProps} />

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { RangeCalendar as RangeCalendarPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: RangeCalendarPrimitive.GridHeadProps = $props();
</script>
<RangeCalendarPrimitive.GridHead bind:ref {...restProps} />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,44 @@
<script lang="ts">
import { RangeCalendar as RangeCalendarPrimitive } 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<RangeCalendarPrimitive.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
)}
>
<RangeCalendarPrimitive.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}
</RangeCalendarPrimitive.MonthSelect>
</span>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { RangeCalendar as RangeCalendarPrimitive } 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
}: RangeCalendarPrimitive.NextButtonProps & {
variant?: ButtonVariant;
} = $props();
</script>
{#snippet Fallback()}
<ChevronRightIcon class="size-4" />
{/snippet}
<RangeCalendarPrimitive.NextButton
bind:ref
class={cn(
buttonVariants({ variant }),
"size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180",
className
)}
children={children || Fallback}
{...restProps}
/>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { RangeCalendar as RangeCalendarPrimitive } 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
}: RangeCalendarPrimitive.PrevButtonProps & {
variant?: ButtonVariant;
} = $props();
</script>
{#snippet Fallback()}
<ChevronLeftIcon class="size-4" />
{/snippet}
<RangeCalendarPrimitive.PrevButton
bind:ref
class={cn(
buttonVariants({ variant }),
"size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180",
className
)}
children={children || Fallback}
{...restProps}
/>

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { RangeCalendar as RangeCalendarPrimitive } 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<RangeCalendarPrimitive.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
)}
>
<RangeCalendarPrimitive.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}
</RangeCalendarPrimitive.YearSelect>
</span>

View File

@@ -0,0 +1,112 @@
<script lang="ts">
import { RangeCalendar as RangeCalendarPrimitive } from "bits-ui";
import * as RangeCalendar from "./index.js";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ButtonVariant } from "$lib/components/ui/button/index.js";
import type { Snippet } from "svelte";
import { isEqualMonth, type DateValue } from "@internationalized/date";
let {
ref = $bindable(null),
value = $bindable(),
placeholder = $bindable(),
weekdayFormat = "short",
class: className,
buttonVariant = "ghost",
captionLayout = "label",
locale = "en-US",
months: monthsProp,
years,
monthFormat: monthFormatProp,
yearFormat = "numeric",
day,
disableDaysOutsideMonth = false,
...restProps
}: WithoutChildrenOrChild<RangeCalendarPrimitive.RootProps> & {
buttonVariant?: ButtonVariant;
captionLayout?: "dropdown" | "dropdown-months" | "dropdown-years" | "label";
months?: RangeCalendarPrimitive.MonthSelectProps["months"];
years?: RangeCalendarPrimitive.YearSelectProps["years"];
monthFormat?: RangeCalendarPrimitive.MonthSelectProps["monthFormat"];
yearFormat?: RangeCalendarPrimitive.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>
<RangeCalendarPrimitive.Root
bind:ref
bind:value
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 })}
<RangeCalendar.Months>
<RangeCalendar.Nav>
<RangeCalendar.PrevButton variant={buttonVariant} />
<RangeCalendar.NextButton variant={buttonVariant} />
</RangeCalendar.Nav>
{#each months as month, monthIndex (month)}
<RangeCalendar.Month>
<RangeCalendar.Header>
<RangeCalendar.Caption
{captionLayout}
months={monthsProp}
{monthFormat}
{years}
{yearFormat}
month={month.value}
bind:placeholder
{locale}
{monthIndex}
/>
</RangeCalendar.Header>
<RangeCalendar.Grid>
<RangeCalendar.GridHead>
<RangeCalendar.GridRow class="select-none">
{#each weekdays as weekday (weekday)}
<RangeCalendar.HeadCell>
{weekday.slice(0, 2)}
</RangeCalendar.HeadCell>
{/each}
</RangeCalendar.GridRow>
</RangeCalendar.GridHead>
<RangeCalendar.GridBody>
{#each month.weeks as weekDates (weekDates)}
<RangeCalendar.GridRow class="mt-2 w-full">
{#each weekDates as date (date)}
<RangeCalendar.Cell {date} month={month.value}>
{#if day}
{@render day({
day: date,
outsideMonth: !isEqualMonth(date, month.value),
})}
{:else}
<RangeCalendar.Day />
{/if}
</RangeCalendar.Cell>
{/each}
</RangeCalendar.GridRow>
{/each}
</RangeCalendar.GridBody>
</RangeCalendar.Grid>
</RangeCalendar.Month>
{/each}
</RangeCalendar.Months>
{/snippet}
</RangeCalendarPrimitive.Root>

View File

@@ -2,6 +2,7 @@
import '../app.css';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { checkHealth } from '$lib/api';
import * as Sidebar from '$lib/components/ui/sidebar';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Avatar from '$lib/components/ui/avatar';
@@ -14,7 +15,6 @@
import BarChart3 from '@lucide/svelte/icons/bar-chart-3';
import Settings from '@lucide/svelte/icons/settings';
import HelpCircle from '@lucide/svelte/icons/help-circle';
import Search from '@lucide/svelte/icons/search';
import ChevronsUpDown from '@lucide/svelte/icons/chevrons-up-down';
import Wallet from '@lucide/svelte/icons/wallet';
import LogOut from '@lucide/svelte/icons/log-out';
@@ -35,16 +35,32 @@
let { children } = $props();
let themeMode = $state<ThemeMode>('system');
let serverOnline = $state(true);
let checkingHealth = $state(true);
async function checkServerHealth() {
checkingHealth = true;
serverOnline = await checkHealth();
checkingHealth = false;
}
onMount(() => {
themeMode = loadThemeFromStorage();
applyThemeToDocument(themeMode);
// 检查服务器状态
checkServerHealth();
// 每 30 秒检查一次
const healthInterval = setInterval(checkServerHealth, 30000);
// 监听系统主题变化
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => applyThemeToDocument(themeMode);
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
return () => {
mediaQuery.removeEventListener('change', handleChange);
clearInterval(healthInterval);
};
});
function cycleTheme() {
@@ -78,6 +94,18 @@
if (href === '/') return pathname === '/';
return pathname.startsWith(href);
}
// 根据路径获取页面标题
function getPageTitle(pathname: string): string {
const titles: Record<string, string> = {
'/': '上传账单',
'/review': '智能复核',
'/bills': '账单管理',
'/analysis': '数据分析',
'/settings': '设置',
'/help': '帮助'
};
return titles[pathname] || 'BillAI';
}
</script>
<Sidebar.Provider>
@@ -237,18 +265,32 @@
<header class="flex h-14 shrink-0 items-center gap-2 border-b px-4">
<Sidebar.Trigger class="-ml-1" />
<Separator orientation="vertical" class="mr-2 h-4" />
<div class="flex items-center gap-2">
<Search class="size-4 text-muted-foreground" />
<span class="text-sm text-muted-foreground">搜索...</span>
</div>
<div class="ml-auto flex items-center gap-2">
<div class="flex items-center gap-1.5 text-sm">
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
<span class="text-muted-foreground">服务运行中</span>
</div>
<h1 class="text-lg font-semibold">{getPageTitle($page.url.pathname)}</h1>
<div class="flex-1" />
<div class="flex items-center gap-3">
<button
class="flex items-center gap-1.5 text-sm hover:opacity-80 transition-opacity"
onclick={checkServerHealth}
title="点击刷新状态"
>
{#if checkingHealth}
<span class="relative flex h-2 w-2">
<span class="relative inline-flex rounded-full h-2 w-2 bg-gray-400 animate-pulse"></span>
</span>
<span class="text-muted-foreground">检查中...</span>
{:else if serverOnline}
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
<span class="text-muted-foreground">服务运行中</span>
{:else}
<span class="relative flex h-2 w-2">
<span class="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span>
</span>
<span class="text-red-500">服务离线</span>
{/if}
</button>
</div>
</header>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { uploadBill, type UploadResponse } from '$lib/api';
import { uploadBill, type UploadResponse, type BillType } from '$lib/api';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
@@ -16,6 +16,7 @@
let isDragOver = $state(false);
let selectedFile: File | null = $state(null);
let selectedType: BillType = $state('alipay');
let isUploading = $state(false);
let uploadResult: UploadResponse | null = $state(null);
let errorMessage = $state('');
@@ -86,6 +87,14 @@
selectedFile = file;
errorMessage = '';
uploadResult = null;
// 根据文件名自动识别账单类型
const fileName = file.name.toLowerCase();
if (fileName.includes('支付宝') || fileName.includes('alipay')) {
selectedType = 'alipay';
} else if (fileName.includes('微信') || fileName.includes('wechat')) {
selectedType = 'wechat';
}
}
function clearFile() {
@@ -101,7 +110,7 @@
errorMessage = '';
try {
const result = await uploadBill(selectedFile);
const result = await uploadBill(selectedFile, selectedType);
if (result.result) {
uploadResult = result;
} else {
@@ -135,7 +144,7 @@
<!-- 统计卡片 -->
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{#each stats as stat}
<Card.Root>
<Card.Root class="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">{stat.title}</Card.Title>
{#if stat.trend === 'up'}
@@ -226,6 +235,27 @@
</div>
{/if}
<!-- 账单类型选择 -->
<div class="flex items-center gap-3">
<span class="text-sm font-medium">账单类型:</span>
<div class="flex gap-2">
<Button
variant={selectedType === 'alipay' ? 'default' : 'outline'}
size="sm"
onclick={() => selectedType = 'alipay'}
>
支付宝
</Button>
<Button
variant={selectedType === 'wechat' ? 'default' : 'outline'}
size="sm"
onclick={() => selectedType = 'wechat'}
>
微信
</Button>
</div>
</div>
<!-- 上传按钮 -->
<Button
class="w-full"
@@ -256,7 +286,7 @@
<CheckCircle class="h-5 w-5 text-green-600 dark:text-green-400" />
<div>
<p class="font-medium text-green-800 dark:text-green-200">处理成功</p>
<p class="text-sm text-green-600 dark:text-green-400">账单已分析完成</p>
<p class="text-sm text-green-600 dark:text-green-400">{uploadResult.message}</p>
</div>
</div>
@@ -267,6 +297,14 @@
{uploadResult.data?.bill_type === 'alipay' ? '支付宝' : '微信'}
</Badge>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">原始记录数</span>
<span class="text-sm font-medium">{uploadResult.data?.raw_count ?? 0}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">清洗后记录数</span>
<span class="text-sm font-medium">{uploadResult.data?.cleaned_count ?? 0}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">输出文件</span>
<span class="text-sm font-medium">{uploadResult.data?.file_name}</span>
@@ -275,7 +313,7 @@
<div class="flex gap-3 pt-2">
<a
href={`http://localhost:8080${uploadResult.data?.file_url}`}
href={uploadResult.data?.file_url || '#'}
download
class="flex-1"
>

View File

@@ -1,11 +1,16 @@
<script lang="ts">
import { fetchBillContent, type BillRecord } from '$lib/api';
import { onMount } from 'svelte';
import { fetchBills, checkHealth, type CleanedBill } from '$lib/api';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Input } from '$lib/components/ui/input';
import * as Card from '$lib/components/ui/card';
import { DateRangePicker } from '$lib/components/ui/date-range-picker';
import BarChart3 from '@lucide/svelte/icons/bar-chart-3';
import Loader2 from '@lucide/svelte/icons/loader-2';
import AlertCircle from '@lucide/svelte/icons/alert-circle';
import Activity from '@lucide/svelte/icons/activity';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Calendar from '@lucide/svelte/icons/calendar';
// 分析组件
import {
@@ -14,7 +19,6 @@
CategoryRanking,
MonthlyTrend,
TopExpenses,
EmptyState
} from '$lib/components/analysis';
// 数据处理服务
@@ -32,61 +36,119 @@
// 分类数据
import { categories as allCategories } from '$lib/data/categories';
// 计算默认日期范围(本月)
function getDefaultDates() {
const today = new Date();
const year = today.getFullYear();
const month = today.getMonth();
const startDate = new Date(year, month, 1).toISOString().split('T')[0];
const endDate = today.toISOString().split('T')[0];
return { startDate, endDate };
}
const defaultDates = getDefaultDates();
// 状态
let fileName = $state('');
let isLoading = $state(false);
let errorMessage = $state('');
let records: BillRecord[] = $state([]);
let records: CleanedBill[] = $state([]);
let isDemo = $state(false);
let serverAvailable = $state(true);
// 派生数据
let categoryStats = $derived(calculateCategoryStats(records));
let monthlyStats = $derived(calculateMonthlyStats(records));
let dailyExpenseData = $derived(calculateDailyExpenseData(records));
let totalStats = $derived(calculateTotalStats(records));
// 时间范围筛选 - 初始化为默认值
let startDate: string = $state(defaultDates.startDate);
let endDate: string = $state(defaultDates.endDate);
// 将 CleanedBill 转换为分析服务需要的格式
function toAnalysisRecords(bills: CleanedBill[]) {
return bills.map(bill => ({
time: bill.time,
category: bill.category,
merchant: bill.merchant,
description: bill.description,
income_expense: bill.income_expense,
amount: String(bill.amount),
payment_method: bill.pay_method,
status: bill.status,
remark: bill.remark,
needs_review: bill.review_level,
}));
}
// 派生分析数据
let analysisRecords = $derived(isDemo ? demoRecords : toAnalysisRecords(records));
let categoryStats = $derived(calculateCategoryStats(analysisRecords));
let monthlyStats = $derived(calculateMonthlyStats(analysisRecords));
let dailyExpenseData = $derived(calculateDailyExpenseData(analysisRecords));
let totalStats = $derived(calculateTotalStats(analysisRecords));
let pieChartData = $derived(calculatePieChartData(categoryStats, totalStats.expense));
let topExpenses = $derived(getTopExpenses(records, 10));
let topExpenses = $derived(getTopExpenses(analysisRecords, 10));
// 分类列表按数据中出现次数排序(出现次数多的优先)
// 分类列表按数据中出现次数排序
let sortedCategories = $derived(() => {
// 统计每个分类的记录数量
const categoryCounts = new Map<string, number>();
for (const record of records) {
for (const record of analysisRecords) {
categoryCounts.set(record.category, (categoryCounts.get(record.category) || 0) + 1);
}
// 对分类进行排序:先按数据中的数量降序,未出现的分类按原顺序排在后面
return [...allCategories].sort((a, b) => {
const countA = categoryCounts.get(a) || 0;
const countB = categoryCounts.get(b) || 0;
// 数量大的排前面
if (countA !== countB) return countB - countA;
// 数量相同时保持原有顺序
return allCategories.indexOf(a) - allCategories.indexOf(b);
});
});
async function loadData() {
if (!fileName) return;
isLoading = true;
errorMessage = '';
isDemo = false;
try {
records = await fetchBillContent(fileName);
// 先检查服务器状态
serverAvailable = await checkHealth();
if (!serverAvailable) {
errorMessage = '服务器不可用';
return;
}
// 获取账单数据(带时间范围筛选)
const response = await fetchBills({
page_size: 10000,
start_date: startDate || undefined,
end_date: endDate || undefined,
});
if (response.result && response.data) {
records = response.data.bills || [];
if (records.length === 0) {
errorMessage = '暂无账单数据';
}
} else {
errorMessage = response.message || '加载失败';
}
} catch (err) {
errorMessage = err instanceof Error ? err.message : '加载失败';
serverAvailable = false;
} finally {
isLoading = false;
}
}
// 日期变化时重新加载
function onDateChange() {
if (!isDemo) {
loadData();
}
}
function loadDemoData() {
isDemo = true;
errorMessage = '';
records = demoRecords;
}
// 页面加载时自动获取数据
onMount(() => {
loadData();
});
</script>
<svelte:head>
@@ -95,55 +157,52 @@
<div class="space-y-6">
<!-- 页面标题 -->
<div class="flex items-center justify-between">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight">数据分析</h1>
<p class="text-muted-foreground">可视化你的消费数据,洞察消费习惯</p>
</div>
{#if isDemo}
<Badge variant="secondary" class="text-xs">
📊 示例数据
</Badge>
{/if}
</div>
<!-- 搜索栏 -->
<div class="flex gap-3">
<div class="relative flex-1">
<BarChart3 class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
placeholder="输入文件名..."
class="pl-10"
bind:value={fileName}
onkeydown={(e) => e.key === 'Enter' && loadData()}
/>
</div>
<Button onclick={loadData} disabled={isLoading}>
{#if isLoading}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
分析中
<div class="flex items-center gap-3">
{#if isDemo}
<Badge variant="secondary" class="text-xs">
📊 示例数据
</Badge>
{:else}
<BarChart3 class="mr-2 h-4 w-4" />
分析
<!-- 时间范围筛选 -->
<DateRangePicker
bind:startDate
bind:endDate
onchange={onDateChange}
/>
{/if}
</Button>
<Button variant="outline" size="icon" onclick={loadData} disabled={isLoading} title="刷新数据">
<RefreshCw class="h-4 w-4 {isLoading ? 'animate-spin' : ''}" />
</Button>
</div>
</div>
<!-- 错误提示 -->
{#if errorMessage}
{#if errorMessage && !isDemo}
<div class="flex items-center gap-2 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle class="h-4 w-4" />
{errorMessage}
</div>
{/if}
{#if records.length > 0}
<!-- 加载中 -->
{#if isLoading}
<Card.Root>
<Card.Content class="flex flex-col items-center justify-center py-16">
<Loader2 class="h-16 w-16 text-muted-foreground mb-4 animate-spin" />
<p class="text-lg font-medium">正在加载数据...</p>
</Card.Content>
</Card.Root>
{:else if analysisRecords.length > 0}
<!-- 总览卡片 -->
<OverviewCards {totalStats} {records} />
<OverviewCards {totalStats} records={analysisRecords} />
<!-- 每日支出趋势图(按分类堆叠) -->
<DailyTrendChart bind:records categories={sortedCategories()} />
<DailyTrendChart records={analysisRecords} categories={sortedCategories()} />
<div class="grid gap-6 lg:grid-cols-2">
<!-- 分类支出排行 -->
@@ -151,7 +210,7 @@
{categoryStats}
{pieChartData}
totalExpense={totalStats.expense}
bind:records
records={analysisRecords}
categories={sortedCategories()}
/>
@@ -161,7 +220,30 @@
<!-- Top 10 支出 -->
<TopExpenses records={topExpenses} categories={sortedCategories()} />
{:else if !isLoading}
<EmptyState onLoadDemo={loadDemoData} />
{:else}
<!-- 空状态:服务器不可用或没有数据时显示示例按钮 -->
<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">
{#if !serverAvailable}
服务器不可用
{:else}
暂无账单数据
{/if}
</p>
<p class="text-sm text-muted-foreground mb-4">
{#if !serverAvailable}
请检查后端服务是否正常运行
{:else}
上传账单后可在此进行数据分析
{/if}
</p>
<Button variant="outline" onclick={loadDemoData}>
<Activity class="mr-2 h-4 w-4" />
查看示例数据
</Button>
</Card.Content>
</Card.Root>
{/if}
</div>

View File

@@ -0,0 +1,39 @@
import { env } from '$env/dynamic/private';
import type { RequestHandler } from './$types';
// 服务端使用 Docker 内部地址,默认使用 localhost
const API_URL = env.API_URL || 'http://localhost:8080';
export const GET: RequestHandler = async ({ params, url, fetch }) => {
const path = params.path;
const queryString = url.search;
const response = await fetch(`${API_URL}/api/${path}${queryString}`);
return new Response(response.body, {
status: response.status,
headers: {
'Content-Type': response.headers.get('Content-Type') || 'application/json',
},
});
};
export const POST: RequestHandler = async ({ params, request, fetch }) => {
const path = params.path;
// 转发原始请求体
const response = await fetch(`${API_URL}/api/${path}`, {
method: 'POST',
body: await request.arrayBuffer(),
headers: {
'Content-Type': request.headers.get('Content-Type') || 'application/octet-stream',
},
});
return new Response(response.body, {
status: response.status,
headers: {
'Content-Type': response.headers.get('Content-Type') || 'application/json',
},
});
};

View File

@@ -1,12 +1,13 @@
<script lang="ts">
import { fetchBillContent, type BillRecord } from '$lib/api';
import { onMount } from 'svelte';
import { fetchBills, type CleanedBill } from '$lib/api';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Table from '$lib/components/ui/table';
import FolderOpen from '@lucide/svelte/icons/folder-open';
import { DateRangePicker } from '$lib/components/ui/date-range-picker';
import Loader2 from '@lucide/svelte/icons/loader-2';
import AlertCircle from '@lucide/svelte/icons/alert-circle';
import Search from '@lucide/svelte/icons/search';
@@ -15,56 +16,124 @@
import TrendingUp from '@lucide/svelte/icons/trending-up';
import FileText from '@lucide/svelte/icons/file-text';
import Filter from '@lucide/svelte/icons/filter';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
let fileName = $state('');
// 状态
let isLoading = $state(false);
let errorMessage = $state('');
let records: BillRecord[] = $state([]);
let filterCategory = $state('all');
let filterType = $state<'all' | '支出' | '收入'>('all');
let records: CleanedBill[] = $state([]);
// 分页
let currentPage = $state(1);
let pageSize = $state(20);
let totalRecords = $state(0);
let totalPages = $state(0);
// 聚合统计(所有筛选条件下的数据)
let totalExpense = $state(0);
let totalIncome = $state(0);
// 计算默认日期(当前月)
function getDefaultDates() {
const today = new Date();
const year = today.getFullYear();
const month = today.getMonth();
const startDate = new Date(year, month, 1).toISOString().split('T')[0];
const endDate = today.toISOString().split('T')[0];
return { startDate, endDate };
}
const defaultDates = getDefaultDates();
// 筛选
let filterCategory = $state('');
let filterIncomeExpense = $state(''); // 收支类型
let filterBillType = $state(''); // 账单来源
let startDate = $state(defaultDates.startDate);
let endDate = $state(defaultDates.endDate);
let searchText = $state('');
async function loadBillData() {
if (!fileName) return;
// 分类列表(硬编码常用分类)
const categories = [
'餐饮美食', '交通出行', '生活服务', '日用百货',
'服饰美容', '医疗健康', '通讯话费', '住房缴费',
'文化娱乐', '金融理财', '教育培训', '人情往来', '其他'
];
async function loadBills() {
isLoading = true;
errorMessage = '';
try {
records = await fetchBillContent(fileName);
const response = await fetchBills({
page: currentPage,
page_size: pageSize,
start_date: startDate || undefined,
end_date: endDate || undefined,
category: filterCategory || undefined,
type: filterBillType || undefined,
income_expense: filterIncomeExpense || undefined,
});
if (response.result && response.data) {
records = response.data.bills || [];
totalRecords = response.data.total;
totalPages = response.data.pages;
totalExpense = response.data.total_expense || 0;
totalIncome = response.data.total_income || 0;
} else {
errorMessage = response.message || '加载失败';
records = [];
}
} catch (err) {
errorMessage = err instanceof Error ? err.message : '加载失败';
records = [];
} finally {
isLoading = false;
}
}
// 获取所有分类
let categories = $derived([...new Set(records.map(r => r.category))].sort());
// 切换页面
function goToPage(page: number) {
if (page >= 1 && page <= totalPages) {
currentPage = page;
loadBills();
}
}
// 过滤后的记录
let filteredRecords = $derived(
records.filter(r => {
if (filterCategory !== 'all' && r.category !== filterCategory) return false;
if (filterType !== 'all' && r.income_expense !== filterType) return false;
if (searchText) {
const text = searchText.toLowerCase();
return r.merchant.toLowerCase().includes(text) ||
r.description.toLowerCase().includes(text);
}
return true;
})
// 筛选变化时重置到第一页
function applyFilters() {
currentPage = 1;
loadBills();
}
// 清除筛选(恢复默认值)
function clearFilters() {
filterCategory = '';
filterIncomeExpense = '';
filterBillType = '';
startDate = defaultDates.startDate;
endDate = defaultDates.endDate;
searchText = '';
currentPage = 1;
loadBills();
}
// 本地搜索(在当前页数据中筛选)
let displayRecords = $derived(
searchText
? records.filter(r => {
const text = searchText.toLowerCase();
return r.merchant?.toLowerCase().includes(text) ||
r.description?.toLowerCase().includes(text);
})
: records
);
// 统计
let stats = $derived({
total: filteredRecords.length,
expense: filteredRecords
.filter(r => r.income_expense === '支出')
.reduce((sum, r) => sum + parseFloat(r.amount || '0'), 0),
income: filteredRecords
.filter(r => r.income_expense === '收入')
.reduce((sum, r) => sum + parseFloat(r.amount || '0'), 0),
// 页面加载时获取数据
onMount(() => {
loadBills();
});
</script>
@@ -74,31 +143,14 @@
<div class="space-y-6">
<!-- 页面标题 -->
<div>
<h1 class="text-2xl font-bold tracking-tight">账单列表</h1>
<p class="text-muted-foreground">查看和筛选已处理的账单记录</p>
</div>
<!-- 搜索栏 -->
<div class="flex gap-3">
<div class="relative flex-1">
<FolderOpen class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
placeholder="输入文件名..."
class="pl-10"
bind:value={fileName}
onkeydown={(e) => e.key === 'Enter' && loadBillData()}
/>
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight">账单列表</h1>
<p class="text-muted-foreground">查看和筛选已处理的账单记录</p>
</div>
<Button onclick={loadBillData} disabled={isLoading}>
{#if isLoading}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
加载中
{:else}
<FolderOpen class="mr-2 h-4 w-4" />
加载
{/if}
<Button variant="outline" onclick={loadBills} disabled={isLoading}>
<RefreshCw class="mr-2 h-4 w-4 {isLoading ? 'animate-spin' : ''}" />
刷新
</Button>
</div>
@@ -110,166 +162,235 @@
</div>
{/if}
{#if records.length > 0}
<!-- 统计概览 -->
<div class="grid gap-4 md:grid-cols-3">
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
<Card.Title class="text-sm font-medium">交易笔数</Card.Title>
<Receipt class="h-4 w-4 text-muted-foreground" />
</Card.Header>
<Card.Content>
<div class="text-2xl font-bold">{stats.total}</div>
<p class="text-xs text-muted-foreground">符合筛选条件的记录</p>
</Card.Content>
</Card.Root>
<!-- 统计概览 -->
<div class="grid gap-4 md:grid-cols-3">
<Card.Root class="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>
<Receipt class="h-4 w-4 text-muted-foreground" />
</Card.Header>
<Card.Content>
<div class="text-2xl font-bold">{totalRecords}</div>
<p class="text-xs text-muted-foreground">筛选条件下的账单总数</p>
</Card.Content>
</Card.Root>
<Card.Root class="border-red-200 dark:border-red-900">
<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">
¥{stats.expense.toFixed(2)}
</div>
<p class="text-xs text-muted-foreground">支出金额汇总</p>
</Card.Content>
</Card.Root>
<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">
¥{totalExpense.toFixed(2)}
</div>
<p class="text-xs text-muted-foreground">筛选条件下的支出汇总</p>
</Card.Content>
</Card.Root>
<Card.Root class="border-green-200 dark:border-green-900">
<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">
¥{stats.income.toFixed(2)}
</div>
<p class="text-xs text-muted-foreground">收入金额汇总</p>
</Card.Content>
</Card.Root>
</div>
<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">
¥{totalIncome.toFixed(2)}
</div>
<p class="text-xs text-muted-foreground">筛选条件下的收入汇总</p>
</Card.Content>
</Card.Root>
</div>
<!-- 筛选和表格 -->
<Card.Root>
<Card.Header>
<div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<!-- 筛选和表格 -->
<Card.Root>
<Card.Header>
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<Card.Title class="flex items-center gap-2">
<Filter class="h-5 w-5" />
筛选条件
</Card.Title>
<div class="flex flex-wrap gap-4">
<div class="space-y-1.5">
<Label class="text-xs">分类</Label>
<select
class="h-9 rounded-md border border-input bg-background px-3 text-sm"
bind:value={filterCategory}
>
<option value="all">全部分类</option>
{#each categories as cat}
<option value={cat}>{cat}</option>
{/each}
</select>
</div>
<div class="space-y-1.5">
<Label class="text-xs">类型</Label>
<select
class="h-9 rounded-md border border-input bg-background px-3 text-sm"
bind:value={filterType}
>
<option value="all">全部</option>
<option value="支出">支出</option>
<option value="收入">收入</option>
</select>
</div>
<div class="space-y-1.5 flex-1 min-w-[200px]">
<Label class="text-xs">搜索</Label>
<div class="relative">
<Search class="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
placeholder="商家/商品..."
class="pl-8"
bind:value={searchText}
/>
</div>
{#if filterCategory || filterIncomeExpense || filterBillType || startDate || endDate}
<Button variant="ghost" size="sm" onclick={clearFilters}>
清除筛选
</Button>
{/if}
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
<div class="space-y-1.5 col-span-2 sm:col-span-2">
<Label class="text-xs">日期范围</Label>
<DateRangePicker
{startDate}
{endDate}
onchange={(start, end) => {
startDate = start;
endDate = end;
applyFilters();
}}
/>
</div>
<div class="space-y-1.5">
<Label class="text-xs">分类</Label>
<select
class="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
bind:value={filterCategory}
onchange={applyFilters}
>
<option value="">全部</option>
{#each categories as cat}
<option value={cat}>{cat}</option>
{/each}
</select>
</div>
<div class="space-y-1.5">
<Label class="text-xs">收/支</Label>
<select
class="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
bind:value={filterIncomeExpense}
onchange={applyFilters}
>
<option value="">全部</option>
<option value="支出">支出</option>
<option value="收入">收入</option>
</select>
</div>
<div class="space-y-1.5">
<Label class="text-xs">来源</Label>
<select
class="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
bind:value={filterBillType}
onchange={applyFilters}
>
<option value="">全部</option>
<option value="alipay">支付宝</option>
<option value="wechat">微信</option>
</select>
</div>
<div class="space-y-1.5 col-span-2 sm:col-span-1">
<Label class="text-xs">搜索</Label>
<div class="relative">
<Search class="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
placeholder="商家/商品..."
class="pl-8"
bind:value={searchText}
/>
</div>
</div>
</div>
</Card.Header>
<Card.Content>
{#if filteredRecords.length > 0}
<div class="rounded-md border">
<Table.Root>
<Table.Header>
</div>
</Card.Header>
<Card.Content>
{#if isLoading}
<div class="flex flex-col items-center justify-center py-12">
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground mb-4" />
<p class="text-muted-foreground">加载中...</p>
</div>
{:else if displayRecords.length > 0}
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-[100px] lg:w-[160px]">时间</Table.Head>
<Table.Head class="hidden xl:table-cell">来源</Table.Head>
<Table.Head>分类</Table.Head>
<Table.Head class="hidden sm:table-cell">交易对方</Table.Head>
<Table.Head class="hidden lg:table-cell">商品说明</Table.Head>
<Table.Head class="hidden min-[480px]:table-cell">收/支</Table.Head>
<Table.Head class="text-right">金额</Table.Head>
<Table.Head class="hidden xl:table-cell">支付方式</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each displayRecords as record}
<Table.Row>
<Table.Head class="w-[160px]">时间</Table.Head>
<Table.Head>分类</Table.Head>
<Table.Head>交易对方</Table.Head>
<Table.Head>商品说明</Table.Head>
<Table.Head>收/支</Table.Head>
<Table.Head class="text-right">金额</Table.Head>
<Table.Head>支付方式</Table.Head>
<Table.Head>状态</Table.Head>
<Table.Cell class="text-muted-foreground text-sm">
{record.time}
</Table.Cell>
<Table.Cell class="hidden xl:table-cell">
<Badge variant={record.bill_type === 'alipay' ? 'default' : 'secondary'}>
{record.bill_type === 'alipay' ? '支付宝' : '微信'}
</Badge>
</Table.Cell>
<Table.Cell>
<Badge variant="outline">{record.category}</Badge>
</Table.Cell>
<Table.Cell class="hidden sm:table-cell max-w-[100px] md:max-w-[150px] truncate" title={record.merchant}>
{record.merchant}
</Table.Cell>
<Table.Cell class="hidden lg:table-cell max-w-[150px] truncate text-muted-foreground" title={record.description}>
{record.description || '-'}
</Table.Cell>
<Table.Cell class="hidden min-[480px]:table-cell">
<span class={record.income_expense === '支出' ? 'text-red-500' : 'text-green-500'}>
{record.income_expense}
</span>
</Table.Cell>
<Table.Cell class="text-right font-mono font-medium">
¥{record.amount.toFixed(2)}
</Table.Cell>
<Table.Cell class="hidden xl:table-cell text-muted-foreground text-sm">
{record.pay_method || '-'}
</Table.Cell>
</Table.Row>
</Table.Header>
<Table.Body>
{#each filteredRecords.slice(0, 100) as record}
<Table.Row>
<Table.Cell class="text-muted-foreground text-sm">
{record.time}
</Table.Cell>
<Table.Cell>
<Badge variant="secondary">{record.category}</Badge>
</Table.Cell>
<Table.Cell class="max-w-[180px] truncate" title={record.merchant}>
{record.merchant}
</Table.Cell>
<Table.Cell class="max-w-[180px] truncate text-muted-foreground" title={record.description}>
{record.description || '-'}
</Table.Cell>
<Table.Cell>
<span class={record.income_expense === '支出' ? 'text-red-500' : 'text-green-500'}>
{record.income_expense}
</span>
</Table.Cell>
<Table.Cell class="text-right font-mono font-medium">
¥{record.amount}
</Table.Cell>
<Table.Cell class="text-muted-foreground text-sm">
{record.payment_method || '-'}
</Table.Cell>
<Table.Cell>
<Badge variant="outline" class="text-green-600 border-green-200">
{record.status || '已完成'}
</Badge>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/each}
</Table.Body>
</Table.Root>
</div>
<!-- 分页控件 -->
<div class="flex items-center justify-between mt-4">
<p class="text-sm text-muted-foreground">
显示 {(currentPage - 1) * pageSize + 1} - {Math.min(currentPage * pageSize, totalRecords)} 条,共 {totalRecords}
</p>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={currentPage <= 1}
onclick={() => goToPage(currentPage - 1)}
>
<ChevronLeft class="h-4 w-4" />
上一页
</Button>
<div class="flex items-center gap-1">
{#each Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
// 计算显示的页码范围
let start = Math.max(1, currentPage - 2);
let end = Math.min(totalPages, start + 4);
start = Math.max(1, end - 4);
return start + i;
}).filter(p => p <= totalPages) as page}
<Button
variant={page === currentPage ? 'default' : 'outline'}
size="sm"
class="w-9"
onclick={() => goToPage(page)}
>
{page}
</Button>
{/each}
</div>
<Button
variant="outline"
size="sm"
disabled={currentPage >= totalPages}
onclick={() => goToPage(currentPage + 1)}
>
下一页
<ChevronRight class="h-4 w-4" />
</Button>
</div>
{#if filteredRecords.length > 100}
<p class="text-center text-sm text-muted-foreground mt-4">
显示前 100 条记录,共 {filteredRecords.length}
</p>
{/if}
{:else}
<div class="flex flex-col items-center justify-center py-12 text-center">
<FileText class="h-12 w-12 text-muted-foreground mb-4" />
<p class="text-muted-foreground">没有匹配的记录</p>
</div>
{/if}
</Card.Content>
</Card.Root>
{:else if !isLoading}
<Card.Root>
<Card.Content class="flex flex-col items-center justify-center py-16">
<FileText class="h-16 w-16 text-muted-foreground mb-4" />
<p class="text-lg font-medium">输入文件名加载账单数据</p>
<p class="text-sm text-muted-foreground">上传账单后可在此查看完整记录</p>
</Card.Content>
</Card.Root>
{/if}
</div>
{:else}
<div class="flex flex-col items-center justify-center py-12 text-center">
<FileText class="h-12 w-12 text-muted-foreground mb-4" />
<p class="text-muted-foreground">没有找到账单记录</p>
<p class="text-sm text-muted-foreground mt-1">请先上传账单或调整筛选条件</p>
</div>
{/if}
</Card.Content>
</Card.Root>
</div>

View File

@@ -0,0 +1,19 @@
import { env } from '$env/dynamic/private';
import type { RequestHandler } from './$types';
// 服务端使用 Docker 内部地址
const API_URL = env.API_URL || 'http://localhost:8080';
export const GET: RequestHandler = async ({ params, fetch }) => {
const path = params.path;
const response = await fetch(`${API_URL}/download/${path}`);
return new Response(response.body, {
status: response.status,
headers: {
'Content-Type': response.headers.get('Content-Type') || 'text/csv',
'Content-Disposition': response.headers.get('Content-Disposition') || '',
},
});
};

View File

@@ -1,4 +1,4 @@
import adapter from '@sveltejs/adapter-auto';
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
@@ -7,11 +7,21 @@ const config = {
// for more information about preprocessors
preprocess: vitePreprocess(),
// 忽略图表组件的无障碍警告
onwarn: (warning, handler) => {
if (warning.code.startsWith('a11y_')) return;
handler(warning);
},
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
// 使用 adapter-node 以支持 Docker 部署
adapter: adapter({
out: 'build'
}),
// 信任的来源(禁用 CSRF 检查)
csrf: {
trustedOrigins: ['*']
}
}
};

View File

@@ -6,6 +6,23 @@ import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [sveltekit(), tailwindcss()],
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
},
'/health': {
target: 'http://localhost:8080',
changeOrigin: true
},
'/download': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
},
test: {
expect: { requireAssertions: true },

View File

@@ -390,6 +390,46 @@
resolved "https://registry.npmmirror.com/@polka/url/-/url-1.0.0-next.29.tgz#5a40109a1ab5f84d6fd8fc928b19f367cbe7e7b1"
integrity sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==
"@rollup/plugin-commonjs@^28.0.1":
version "28.0.9"
resolved "https://registry.npmmirror.com/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.9.tgz#b875cd1590617a40c4916d561d75761c6ca3c6d1"
integrity sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA==
dependencies:
"@rollup/pluginutils" "^5.0.1"
commondir "^1.0.1"
estree-walker "^2.0.2"
fdir "^6.2.0"
is-reference "1.2.1"
magic-string "^0.30.3"
picomatch "^4.0.2"
"@rollup/plugin-json@^6.1.0":
version "6.1.0"
resolved "https://registry.npmmirror.com/@rollup/plugin-json/-/plugin-json-6.1.0.tgz#fbe784e29682e9bb6dee28ea75a1a83702e7b805"
integrity sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==
dependencies:
"@rollup/pluginutils" "^5.1.0"
"@rollup/plugin-node-resolve@^16.0.0":
version "16.0.3"
resolved "https://registry.npmmirror.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz#0988e6f2cbb13316b0f5e7213f757bc9ed44928f"
integrity sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==
dependencies:
"@rollup/pluginutils" "^5.0.1"
"@types/resolve" "1.20.2"
deepmerge "^4.2.2"
is-module "^1.0.0"
resolve "^1.22.1"
"@rollup/pluginutils@^5.0.1", "@rollup/pluginutils@^5.1.0":
version "5.3.0"
resolved "https://registry.npmmirror.com/@rollup/pluginutils/-/pluginutils-5.3.0.tgz#57ba1b0cbda8e7a3c597a4853c807b156e21a7b4"
integrity sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==
dependencies:
"@types/estree" "^1.0.0"
estree-walker "^2.0.2"
picomatch "^4.0.2"
"@rollup/rollup-android-arm-eabi@4.55.1":
version "4.55.1"
resolved "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz#76e0fef6533b3ce313f969879e61e8f21f0eeb28"
@@ -525,10 +565,15 @@
resolved "https://registry.npmmirror.com/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz#69c746a7c232094c117c50dedbd1279fc64887b7"
integrity sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==
"@sveltejs/adapter-auto@^7.0.0":
version "7.0.0"
resolved "https://registry.npmmirror.com/@sveltejs/adapter-auto/-/adapter-auto-7.0.0.tgz#e3f257a0d1be3383f6cd0c146aed8d470b33a7fe"
integrity sha512-ImDWaErTOCkRS4Gt+5gZuymKFBobnhChXUZ9lhUZLahUgvA4OOvRzi3sahzYgbxGj5nkA6OV0GAW378+dl/gyw==
"@sveltejs/adapter-node@^5.4.0":
version "5.4.0"
resolved "https://registry.npmmirror.com/@sveltejs/adapter-node/-/adapter-node-5.4.0.tgz#d013d48fb86d807f6da060d9fd026c932a1b0af2"
integrity sha512-NMsrwGVPEn+J73zH83Uhss/hYYZN6zT3u31R3IHAn3MiKC3h8fjmIAhLfTSOeNHr5wPYfjjMg8E+1gyFgyrEcQ==
dependencies:
"@rollup/plugin-commonjs" "^28.0.1"
"@rollup/plugin-json" "^6.1.0"
"@rollup/plugin-node-resolve" "^16.0.0"
rollup "^4.9.5"
"@sveltejs/kit@^2.49.1":
version "2.49.3"
@@ -730,7 +775,7 @@
resolved "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz#334311971d3a07121e7eb91b684a605e7eea9cbd"
integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==
"@types/estree@1.0.8", "@types/estree@^1.0.0", "@types/estree@^1.0.5", "@types/estree@^1.0.6":
"@types/estree@*", "@types/estree@1.0.8", "@types/estree@^1.0.0", "@types/estree@^1.0.5", "@types/estree@^1.0.6":
version "1.0.8"
resolved "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e"
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
@@ -747,6 +792,11 @@
dependencies:
undici-types "~5.26.4"
"@types/resolve@1.20.2":
version "1.20.2"
resolved "https://registry.npmmirror.com/@types/resolve/-/resolve-1.20.2.tgz#97d26e00cd4a0423b4af620abecf3e6f442b7975"
integrity sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==
"@typescript-eslint/eslint-plugin@8.52.0":
version "8.52.0"
resolved "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.52.0.tgz#9a9f1d2ee974ed77a8b1bda94e77123f697ee8b4"
@@ -1050,6 +1100,11 @@ commander@7:
resolved "https://registry.npmmirror.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7"
integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==
commondir@^1.0.1:
version "1.0.1"
resolved "https://registry.npmmirror.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
integrity sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==
concat-map@0.0.1:
version "0.0.1"
resolved "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
@@ -1267,7 +1322,7 @@ deep-is@^0.1.3:
resolved "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
deepmerge@^4.3.1:
deepmerge@^4.2.2, deepmerge@^4.3.1:
version "4.3.1"
resolved "https://registry.npmmirror.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"
integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
@@ -1463,6 +1518,11 @@ estraverse@^5.1.0, estraverse@^5.2.0:
resolved "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123"
integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==
estree-walker@^2.0.2:
version "2.0.2"
resolved "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac"
integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==
estree-walker@^3.0.3:
version "3.0.3"
resolved "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d"
@@ -1538,6 +1598,11 @@ fsevents@~2.3.2, fsevents@~2.3.3:
resolved "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
function-bind@^1.1.2:
version "1.1.2"
resolved "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c"
integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
glob-parent@^6.0.2:
version "6.0.2"
resolved "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3"
@@ -1565,6 +1630,13 @@ has-flag@^4.0.0:
resolved "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
hasown@^2.0.2:
version "2.0.2"
resolved "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003"
integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==
dependencies:
function-bind "^1.1.2"
iconv-lite@0.6:
version "0.6.3"
resolved "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501"
@@ -1610,6 +1682,13 @@ internmap@^1.0.0:
resolved "https://registry.npmmirror.com/internmap/-/internmap-1.0.1.tgz#0017cc8a3b99605f0302f2b198d272e015e5df95"
integrity sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==
is-core-module@^2.16.1:
version "2.16.1"
resolved "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4"
integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==
dependencies:
hasown "^2.0.2"
is-extglob@^2.1.1:
version "2.1.1"
resolved "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
@@ -1622,6 +1701,18 @@ is-glob@^4.0.0, is-glob@^4.0.3:
dependencies:
is-extglob "^2.1.1"
is-module@^1.0.0:
version "1.0.0"
resolved "https://registry.npmmirror.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591"
integrity sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==
is-reference@1.2.1:
version "1.2.1"
resolved "https://registry.npmmirror.com/is-reference/-/is-reference-1.2.1.tgz#8b2dac0b371f4bc994fdeaba9eb542d03002d0b7"
integrity sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==
dependencies:
"@types/estree" "*"
is-reference@^3.0.3:
version "3.0.3"
resolved "https://registry.npmmirror.com/is-reference/-/is-reference-3.0.3.tgz#9ef7bf9029c70a67b2152da4adf57c23d718910f"
@@ -1825,7 +1916,7 @@ lz-string@^1.5.0:
resolved "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941"
integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==
magic-string@^0.30.11, magic-string@^0.30.21, magic-string@^0.30.5:
magic-string@^0.30.11, magic-string@^0.30.21, magic-string@^0.30.3, magic-string@^0.30.5:
version "0.30.21"
resolved "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91"
integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==
@@ -1931,6 +2022,11 @@ path-key@^3.1.0:
resolved "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==
path-parse@^1.0.7:
version "1.0.7"
resolved "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
pathe@^2.0.3:
version "2.0.3"
resolved "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716"
@@ -1941,7 +2037,7 @@ picocolors@^1.0.0, picocolors@^1.1.1:
resolved "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
picomatch@^4.0.3:
picomatch@^4.0.2, picomatch@^4.0.3:
version "4.0.3"
resolved "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042"
integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==
@@ -2037,12 +2133,21 @@ resolve-from@^4.0.0:
resolved "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==
resolve@^1.22.1:
version "1.22.11"
resolved "https://registry.npmmirror.com/resolve/-/resolve-1.22.11.tgz#aad857ce1ffb8bfa9b0b1ac29f1156383f68c262"
integrity sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==
dependencies:
is-core-module "^2.16.1"
path-parse "^1.0.7"
supports-preserve-symlinks-flag "^1.0.0"
robust-predicates@^3.0.2:
version "3.0.2"
resolved "https://registry.npmmirror.com/robust-predicates/-/robust-predicates-3.0.2.tgz#d5b28528c4824d20fc48df1928d41d9efa1ad771"
integrity sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==
rollup@^4.43.0:
rollup@^4.43.0, rollup@^4.9.5:
version "4.55.1"
resolved "https://registry.npmmirror.com/rollup/-/rollup-4.55.1.tgz#4ec182828be440648e7ee6520dc35e9f20e05144"
integrity sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==
@@ -2179,6 +2284,11 @@ supports-color@^7.1.0:
dependencies:
has-flag "^4.0.0"
supports-preserve-symlinks-flag@^1.0.0:
version "1.0.0"
resolved "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
svelte-check@^4.3.4:
version "4.3.5"
resolved "https://registry.npmmirror.com/svelte-check/-/svelte-check-4.3.5.tgz#2e9e05eca63fdb5523a37c666f47614d36c11212"