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:
265
README.md
Normal file
265
README.md
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
# 💰 BillAI - 智能账单分析系统
|
||||||
|
|
||||||
|
一个基于微服务架构的个人账单分析工具,支持微信和支付宝账单的自动解析、智能分类和可视化分析。
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
## ✨ 功能特性
|
||||||
|
|
||||||
|
- 📊 **账单分析** - 自动解析微信/支付宝账单,生成可视化报表
|
||||||
|
- 🏷️ **智能分类** - 基于关键词匹配的交易分类推断
|
||||||
|
- 📈 **趋势图表** - 日/月消费趋势、分类排行、收支对比
|
||||||
|
- 🔍 **复核修正** - 对不确定的分类进行人工复核
|
||||||
|
- 🐳 **一键部署** - Docker Compose 快速启动全部服务
|
||||||
|
|
||||||
|
## 🏗️ 系统架构
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph 用户层
|
||||||
|
User[👤 用户]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph 前端服务
|
||||||
|
Web[🌐 Web 前端<br/>SvelteKit :3000]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph 后端服务
|
||||||
|
Server[⚙️ Go 后端<br/>Gin :8080]
|
||||||
|
Analyzer[🐍 Python 分析服务<br/>FastAPI :8001]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph 数据层
|
||||||
|
MongoDB[(🍃 MongoDB<br/>:27017)]
|
||||||
|
MongoExpress[📊 Mongo Express<br/>:8083]
|
||||||
|
end
|
||||||
|
|
||||||
|
User -->|访问| Web
|
||||||
|
Web -->|HTTP API| Server
|
||||||
|
Server -->|HTTP 调用| Analyzer
|
||||||
|
Server -->|读写数据| MongoDB
|
||||||
|
MongoExpress -->|管理| MongoDB
|
||||||
|
|
||||||
|
style Web fill:#ff3e00,color:#fff
|
||||||
|
style Server fill:#00ADD8,color:#fff
|
||||||
|
style Analyzer fill:#3776AB,color:#fff
|
||||||
|
style MongoDB fill:#47A248,color:#fff
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据流
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as 👤 用户
|
||||||
|
participant W as 🌐 Web
|
||||||
|
participant S as ⚙️ Server
|
||||||
|
participant A as 🐍 Analyzer
|
||||||
|
participant D as 🍃 MongoDB
|
||||||
|
|
||||||
|
rect rgb(240, 248, 255)
|
||||||
|
Note over U,D: 上传账单流程
|
||||||
|
U->>W: 上传账单文件
|
||||||
|
W->>S: POST /api/upload
|
||||||
|
S->>A: POST /clean (清洗账单)
|
||||||
|
A-->>S: 清洗结果 + 分类
|
||||||
|
S->>D: 存储账单数据
|
||||||
|
D-->>S: 保存成功
|
||||||
|
S-->>W: 返回分析结果
|
||||||
|
W-->>U: 显示分析报表
|
||||||
|
end
|
||||||
|
|
||||||
|
rect rgb(255, 248, 240)
|
||||||
|
Note over U,D: 复核修正流程
|
||||||
|
U->>W: 查看待复核记录
|
||||||
|
W->>S: GET /api/review
|
||||||
|
S->>D: 查询不确定分类
|
||||||
|
D-->>S: 返回记录列表
|
||||||
|
S-->>W: 待复核数据
|
||||||
|
W-->>U: 显示复核界面
|
||||||
|
U->>W: 修正分类
|
||||||
|
W->>S: 更新分类
|
||||||
|
S->>D: 更新记录
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
BillAI/
|
||||||
|
├── web/ # 前端 (SvelteKit + TailwindCSS)
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── routes/ # 页面路由
|
||||||
|
│ │ │ ├── analysis/ # 📊 账单分析页
|
||||||
|
│ │ │ ├── bills/ # 📋 账单列表页
|
||||||
|
│ │ │ └── review/ # ✅ 复核页面
|
||||||
|
│ │ └── lib/
|
||||||
|
│ │ ├── components/ # UI 组件
|
||||||
|
│ │ └── services/ # API 服务
|
||||||
|
│ └── Dockerfile
|
||||||
|
│
|
||||||
|
├── server/ # 后端 (Go + Gin)
|
||||||
|
│ ├── adapter/ # 适配器层
|
||||||
|
│ │ ├── http/ # HTTP 客户端
|
||||||
|
│ │ └── python/ # 子进程调用
|
||||||
|
│ ├── handler/ # HTTP 处理器
|
||||||
|
│ ├── service/ # 业务逻辑
|
||||||
|
│ ├── repository/ # 数据访问层
|
||||||
|
│ └── Dockerfile
|
||||||
|
│
|
||||||
|
├── analyzer/ # 分析服务 (Python + FastAPI)
|
||||||
|
│ ├── server.py # FastAPI 入口
|
||||||
|
│ ├── clean_bill.py # 账单清洗
|
||||||
|
│ ├── category.py # 分类推断
|
||||||
|
│ ├── cleaners/ # 清洗器
|
||||||
|
│ │ ├── alipay.py # 支付宝
|
||||||
|
│ │ └── wechat.py # 微信
|
||||||
|
│ └── Dockerfile
|
||||||
|
│
|
||||||
|
├── data/ # 测试数据目录
|
||||||
|
├── mongo/ # MongoDB 数据
|
||||||
|
└── docker-compose.yaml # 容器编排
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
|
||||||
|
- Docker & Docker Compose
|
||||||
|
- (可选) Go 1.21+、Python 3.12+、Node.js 20+
|
||||||
|
|
||||||
|
### 一键启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 克隆项目
|
||||||
|
git clone https://github.com/your-username/BillAI.git
|
||||||
|
cd BillAI
|
||||||
|
|
||||||
|
# 启动所有服务
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
# 查看服务状态
|
||||||
|
docker-compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### 访问地址
|
||||||
|
|
||||||
|
| 服务 | 地址 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| **前端页面** | http://localhost:3000 | Web 界面 |
|
||||||
|
| **后端 API** | http://localhost:8080 | RESTful API |
|
||||||
|
| **分析服务** | http://localhost:8001 | Python API |
|
||||||
|
| **Mongo Express** | http://localhost:8083 | 数据库管理 |
|
||||||
|
|
||||||
|
## 💻 本地开发
|
||||||
|
|
||||||
|
### 前端开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
yarn install
|
||||||
|
yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 后端开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
go mod download
|
||||||
|
go run .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 分析服务开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd analyzer
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📖 API 文档
|
||||||
|
|
||||||
|
### 后端 API (Go)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `POST` | `/api/upload` | 上传并分析账单 |
|
||||||
|
| `GET` | `/api/review` | 获取待复核记录 |
|
||||||
|
| `GET` | `/health` | 健康检查 |
|
||||||
|
|
||||||
|
### 分析服务 API (Python)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `POST` | `/clean` | 清洗账单文件 |
|
||||||
|
| `POST` | `/category/infer` | 推断交易分类 |
|
||||||
|
| `GET` | `/category/list` | 获取分类列表 |
|
||||||
|
| `POST` | `/detect` | 检测账单类型 |
|
||||||
|
| `GET` | `/health` | 健康检查 |
|
||||||
|
|
||||||
|
## ⚙️ 配置说明
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
|
||||||
|
| 变量 | 默认值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| `ANALYZER_URL` | `http://localhost:8001` | Python 分析服务地址 |
|
||||||
|
| `ANALYZER_MODE` | `http` | 适配器模式: http/subprocess |
|
||||||
|
| `MONGO_URI` | `mongodb://localhost:27017` | MongoDB 连接 URI |
|
||||||
|
| `MONGO_DATABASE` | `billai` | 数据库名称 |
|
||||||
|
|
||||||
|
### 配置文件
|
||||||
|
|
||||||
|
- `server/config.yaml` - Go 后端配置
|
||||||
|
- `analyzer/config/category.yaml` - 分类规则配置
|
||||||
|
|
||||||
|
## 🔧 技术栈
|
||||||
|
|
||||||
|
| 层级 | 技术 | 版本 |
|
||||||
|
|------|------|------|
|
||||||
|
| **前端** | SvelteKit + TailwindCSS | 5.x / 4.x |
|
||||||
|
| **后端** | Go + Gin | 1.21 / 1.9 |
|
||||||
|
| **分析服务** | Python + FastAPI | 3.12 / 0.109+ |
|
||||||
|
| **数据库** | MongoDB | 8.0 |
|
||||||
|
| **容器化** | Docker Compose | - |
|
||||||
|
|
||||||
|
## 📊 支持的账单格式
|
||||||
|
|
||||||
|
- ✅ **微信支付** - 微信支付账单流水文件 (CSV)
|
||||||
|
- ✅ **支付宝** - 支付宝交易明细 (CSV)
|
||||||
|
|
||||||
|
## 🛣️ 路线图
|
||||||
|
|
||||||
|
- [ ] 添加用户认证 (JWT)
|
||||||
|
- [ ] 支持更多账单格式(银行账单)
|
||||||
|
- [ ] AI 智能分类(LLM)
|
||||||
|
- [ ] 预算管理功能
|
||||||
|
- [ ] 移动端适配
|
||||||
|
- [ ] 数据导出 (Excel/PDF)
|
||||||
|
|
||||||
|
## 🤝 贡献指南
|
||||||
|
|
||||||
|
欢迎提交 Issue 和 Pull Request!
|
||||||
|
|
||||||
|
1. Fork 本仓库
|
||||||
|
2. 创建特性分支 (`git checkout -b feature/amazing-feature`)
|
||||||
|
3. 提交更改 (`git commit -m 'Add amazing feature'`)
|
||||||
|
4. 推送分支 (`git push origin feature/amazing-feature`)
|
||||||
|
5. 创建 Pull Request
|
||||||
|
|
||||||
|
## 📄 许可证
|
||||||
|
|
||||||
|
本项目采用 MIT 许可证 - 详见 [LICENSE](LICENSE) 文件
|
||||||
|
|
||||||
|
## 🙏 致谢
|
||||||
|
|
||||||
|
- [SvelteKit](https://kit.svelte.dev/)
|
||||||
|
- [Gin](https://gin-gonic.com/)
|
||||||
|
- [FastAPI](https://fastapi.tiangolo.com/)
|
||||||
|
- [shadcn-svelte](https://shadcn-svelte.com/)
|
||||||
101
TODO.md
Normal file
101
TODO.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# BillAI 开发计划
|
||||||
|
|
||||||
|
## 已完成功能
|
||||||
|
|
||||||
|
### 前端 (web)
|
||||||
|
- [x] 侧边栏导航布局
|
||||||
|
- [x] 上传账单页面
|
||||||
|
- [x] 智能复核页面
|
||||||
|
- [x] 账单管理页面(分页、筛选、响应式表格)
|
||||||
|
- [x] 数据分析页面(图表、统计)
|
||||||
|
- [x] 日期范围选择器 (DateRangePicker)
|
||||||
|
- [x] 主题切换(亮色/暗色/跟随系统)
|
||||||
|
- [x] 服务状态指示器(轮询检查)
|
||||||
|
- [x] 顶部导航栏(页面标题、状态指示)
|
||||||
|
- [x] shadcn-svelte UI 组件库集成
|
||||||
|
|
||||||
|
### 后端 (server)
|
||||||
|
- [x] 账单上传与解析
|
||||||
|
- [x] 智能分类(Python 分析器)
|
||||||
|
- [x] 复核记录查询
|
||||||
|
- [x] 账单列表 API(分页、筛选)
|
||||||
|
- [x] 健康检查端点
|
||||||
|
- [x] MongoDB 数据存储
|
||||||
|
|
||||||
|
### 分析器 (analyzer)
|
||||||
|
- [x] 支付宝账单解析
|
||||||
|
- [x] 微信账单解析
|
||||||
|
- [x] 分类规则引擎
|
||||||
|
- [x] 重复记录检测
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 待实现功能
|
||||||
|
|
||||||
|
### 高优先级
|
||||||
|
|
||||||
|
- [ ] **SSE 实时状态推送**
|
||||||
|
- 服务器实现 `/events` SSE 端点
|
||||||
|
- 前端使用 EventSource 接收状态
|
||||||
|
- 支持服务状态、任务进度等实时推送
|
||||||
|
|
||||||
|
- [ ] **服务异常页面提示**
|
||||||
|
- 服务离线时显示遮罩层
|
||||||
|
- 提示用户检查服务器状态
|
||||||
|
- 自动重试连接
|
||||||
|
|
||||||
|
### 中优先级
|
||||||
|
|
||||||
|
- [ ] **账单编辑功能**
|
||||||
|
- 在账单管理页面编辑记录
|
||||||
|
- 修改分类、备注等字段
|
||||||
|
- 保存到数据库
|
||||||
|
|
||||||
|
- [ ] **账单删除功能**
|
||||||
|
- 单条删除
|
||||||
|
- 批量删除
|
||||||
|
- 删除确认对话框
|
||||||
|
|
||||||
|
- [ ] **数据导出**
|
||||||
|
- 导出为 CSV
|
||||||
|
- 导出为 Excel
|
||||||
|
- 自定义导出字段
|
||||||
|
|
||||||
|
- [ ] **分类管理**
|
||||||
|
- 自定义分类
|
||||||
|
- 分类图标配置
|
||||||
|
- 分类规则编辑
|
||||||
|
|
||||||
|
### 低优先级
|
||||||
|
|
||||||
|
- [ ] **用户认证**
|
||||||
|
- 登录/注册
|
||||||
|
- 多用户支持
|
||||||
|
- 权限管理
|
||||||
|
|
||||||
|
- [ ] **数据备份**
|
||||||
|
- 自动备份
|
||||||
|
- 导入/导出备份
|
||||||
|
|
||||||
|
- [ ] **移动端适配**
|
||||||
|
- PWA 支持
|
||||||
|
- 触摸手势优化
|
||||||
|
|
||||||
|
- [ ] **AI 智能分析**
|
||||||
|
- 消费趋势预测
|
||||||
|
- 异常消费提醒
|
||||||
|
- 智能预算建议
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术债务
|
||||||
|
|
||||||
|
- [ ] 统一错误处理
|
||||||
|
- [ ] 添加单元测试
|
||||||
|
- [ ] API 文档(Swagger)
|
||||||
|
- [ ] 日志系统完善
|
||||||
|
- [ ] 性能优化(大数据量分页)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*最后更新: 2026-01-10*
|
||||||
25
analyzer/Dockerfile
Normal file
25
analyzer/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Python 分析服务 Dockerfile
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 配置国内镜像源(pip + apt)
|
||||||
|
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources && \
|
||||||
|
pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && \
|
||||||
|
pip config set global.trusted-host mirrors.aliyun.com
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# 复制源代码
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 8001
|
||||||
|
|
||||||
|
# 健康检查需要 curl
|
||||||
|
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 启动服务
|
||||||
|
CMD ["python", "server.py"]
|
||||||
@@ -60,6 +60,8 @@ class AlipayCleaner(BaseCleaner):
|
|||||||
print(f"\n处理结果:")
|
print(f"\n处理结果:")
|
||||||
print(f" 全额退款删除: {self.stats['fully_refunded']} 条")
|
print(f" 全额退款删除: {self.stats['fully_refunded']} 条")
|
||||||
print(f" 部分退款调整: {self.stats['partially_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)}")
|
print(f" 最终保留行数: {len(final_rows)}")
|
||||||
|
|
||||||
# 第五步:重新分类并添加"需复核"标注
|
# 第五步:重新分类并添加"需复核"标注
|
||||||
@@ -134,7 +136,11 @@ class AlipayCleaner(BaseCleaner):
|
|||||||
self.stats["partially_refunded"] += 1
|
self.stats["partially_refunded"] += 1
|
||||||
print(f" 部分退款: {row[0]} | {row[2]} | 原{expense_amount}元 -> {format_amount(remaining)}元")
|
print(f" 部分退款: {row[0]} | {row[2]} | 原{expense_amount}元 -> {format_amount(remaining)}元")
|
||||||
else:
|
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:
|
else:
|
||||||
final_rows.append(row)
|
final_rows.append(row)
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,58 @@ def compute_date_range(args) -> tuple[date | None, date | None]:
|
|||||||
return start_date, end_date
|
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:
|
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:
|
if start_date is None and end_date is None:
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ class WechatCleaner(BaseCleaner):
|
|||||||
print(f"\n处理结果:")
|
print(f"\n处理结果:")
|
||||||
print(f" 全额退款删除: {self.stats['fully_refunded']} 条")
|
print(f" 全额退款删除: {self.stats['fully_refunded']} 条")
|
||||||
print(f" 部分退款调整: {self.stats['partially_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(final_expense_rows)} 条")
|
||||||
print(f" 保留收入条目: {len(income_rows)} 条")
|
print(f" 保留收入条目: {len(income_rows)} 条")
|
||||||
|
|
||||||
@@ -177,7 +179,11 @@ class WechatCleaner(BaseCleaner):
|
|||||||
if merchant in transfer_refunds:
|
if merchant in transfer_refunds:
|
||||||
del transfer_refunds[merchant]
|
del transfer_refunds[merchant]
|
||||||
else:
|
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
|
return final_expense_rows, income_rows
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
pyyaml>=6.0
|
pyyaml>=6.0
|
||||||
|
fastapi>=0.109.0
|
||||||
|
uvicorn[standard]>=0.27.0
|
||||||
|
python-multipart>=0.0.6
|
||||||
|
|||||||
348
analyzer/server.py
Normal file
348
analyzer/server.py
Normal 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)
|
||||||
@@ -1,4 +1,81 @@
|
|||||||
services:
|
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:
|
mongodb:
|
||||||
image: mongo:8.0
|
image: mongo:8.0
|
||||||
container_name: billai-mongodb
|
container_name: billai-mongodb
|
||||||
|
|||||||
49
server/Dockerfile
Normal file
49
server/Dockerfile
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Go 服务 Dockerfile
|
||||||
|
# 多阶段构建:编译阶段 + 运行阶段
|
||||||
|
|
||||||
|
# ===== 编译阶段 =====
|
||||||
|
FROM golang:1.21-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# 配置 Go 代理(国内镜像)
|
||||||
|
ENV GOPROXY=https://goproxy.cn,direct
|
||||||
|
|
||||||
|
# 先复制依赖文件,利用 Docker 缓存
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# 复制源代码并编译
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o billai-server .
|
||||||
|
|
||||||
|
# ===== 运行阶段 =====
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 配置 Alpine 镜像源(国内)
|
||||||
|
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||||
|
|
||||||
|
# 安装必要工具
|
||||||
|
RUN apk --no-cache add ca-certificates tzdata curl
|
||||||
|
|
||||||
|
# 设置时区
|
||||||
|
ENV TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
# 从编译阶段复制二进制文件
|
||||||
|
COPY --from=builder /build/billai-server .
|
||||||
|
COPY --from=builder /build/config.yaml .
|
||||||
|
|
||||||
|
# 创建必要目录
|
||||||
|
RUN mkdir -p uploads outputs
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
HEALTHCHECK --interval=10s --timeout=5s --retries=5 \
|
||||||
|
CMD curl -f http://localhost:8080/health || exit 1
|
||||||
|
|
||||||
|
# 启动服务
|
||||||
|
CMD ["./billai-server"]
|
||||||
28
server/adapter/adapter.go
Normal file
28
server/adapter/adapter.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// Package adapter 定义与外部系统交互的抽象接口
|
||||||
|
// 这样可以方便后续更换通信方式(如从子进程调用改为 HTTP/gRPC/消息队列等)
|
||||||
|
package adapter
|
||||||
|
|
||||||
|
// CleanOptions 清洗选项
|
||||||
|
type CleanOptions struct {
|
||||||
|
Year string // 年份筛选
|
||||||
|
Month string // 月份筛选
|
||||||
|
Start string // 起始日期
|
||||||
|
End string // 结束日期
|
||||||
|
Format string // 输出格式: csv/json
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanResult 清洗结果
|
||||||
|
type CleanResult struct {
|
||||||
|
BillType string // 检测到的账单类型: alipay/wechat
|
||||||
|
Output string // 脚本输出信息
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleaner 账单清洗器接口
|
||||||
|
// 负责将原始账单数据清洗为标准格式
|
||||||
|
type Cleaner interface {
|
||||||
|
// Clean 执行账单清洗
|
||||||
|
// inputPath: 输入文件路径
|
||||||
|
// outputPath: 输出文件路径
|
||||||
|
// opts: 清洗选项
|
||||||
|
Clean(inputPath, outputPath string, opts *CleanOptions) (*CleanResult, error)
|
||||||
|
}
|
||||||
15
server/adapter/global.go
Normal file
15
server/adapter/global.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// Package adapter 全局适配器实例管理
|
||||||
|
package adapter
|
||||||
|
|
||||||
|
// 全局清洗器实例
|
||||||
|
var globalCleaner Cleaner
|
||||||
|
|
||||||
|
// SetCleaner 设置全局清洗器实例
|
||||||
|
func SetCleaner(c Cleaner) {
|
||||||
|
globalCleaner = c
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCleaner 获取全局清洗器实例
|
||||||
|
func GetCleaner() Cleaner {
|
||||||
|
return globalCleaner
|
||||||
|
}
|
||||||
204
server/adapter/http/cleaner.go
Normal file
204
server/adapter/http/cleaner.go
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
// Package http 实现通过 HTTP API 调用 Python 服务的清洗器
|
||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"billai-server/adapter"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CleanRequest HTTP 清洗请求
|
||||||
|
type CleanRequest struct {
|
||||||
|
InputPath string `json:"input_path"`
|
||||||
|
OutputPath string `json:"output_path"`
|
||||||
|
Year string `json:"year,omitempty"`
|
||||||
|
Month string `json:"month,omitempty"`
|
||||||
|
Start string `json:"start,omitempty"`
|
||||||
|
End string `json:"end,omitempty"`
|
||||||
|
Format string `json:"format,omitempty"`
|
||||||
|
BillType string `json:"bill_type,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanResponse HTTP 清洗响应
|
||||||
|
type CleanResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
BillType string `json:"bill_type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
OutputPath string `json:"output_path,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorResponse 错误响应
|
||||||
|
type ErrorResponse struct {
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleaner 通过 HTTP API 调用 Python 服务的清洗器实现
|
||||||
|
type Cleaner struct {
|
||||||
|
baseURL string // Python 服务基础 URL
|
||||||
|
httpClient *http.Client // HTTP 客户端
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCleaner 创建 HTTP 清洗器
|
||||||
|
func NewCleaner(baseURL string) *Cleaner {
|
||||||
|
return &Cleaner{
|
||||||
|
baseURL: baseURL,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 60 * time.Second, // 清洗可能需要较长时间
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCleanerWithClient 使用自定义 HTTP 客户端创建清洗器
|
||||||
|
func NewCleanerWithClient(baseURL string, client *http.Client) *Cleaner {
|
||||||
|
return &Cleaner{
|
||||||
|
baseURL: baseURL,
|
||||||
|
httpClient: client,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean 执行账单清洗(使用文件上传方式)
|
||||||
|
func (c *Cleaner) Clean(inputPath, outputPath string, opts *adapter.CleanOptions) (*adapter.CleanResult, error) {
|
||||||
|
// 打开输入文件
|
||||||
|
file, err := os.Open(inputPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("打开文件失败: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// 创建 multipart form
|
||||||
|
var body bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&body)
|
||||||
|
|
||||||
|
// 添加文件
|
||||||
|
part, err := writer.CreateFormFile("file", filepath.Base(inputPath))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("创建表单文件失败: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(part, file); err != nil {
|
||||||
|
return nil, fmt.Errorf("复制文件内容失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加其他参数
|
||||||
|
if opts != nil {
|
||||||
|
if opts.Year != "" {
|
||||||
|
writer.WriteField("year", opts.Year)
|
||||||
|
}
|
||||||
|
if opts.Month != "" {
|
||||||
|
writer.WriteField("month", opts.Month)
|
||||||
|
}
|
||||||
|
if opts.Start != "" {
|
||||||
|
writer.WriteField("start", opts.Start)
|
||||||
|
}
|
||||||
|
if opts.End != "" {
|
||||||
|
writer.WriteField("end", opts.End)
|
||||||
|
}
|
||||||
|
if opts.Format != "" {
|
||||||
|
writer.WriteField("format", opts.Format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writer.WriteField("bill_type", "auto")
|
||||||
|
writer.Close()
|
||||||
|
|
||||||
|
// 发送上传请求
|
||||||
|
fmt.Printf("🌐 调用清洗服务: %s/clean/upload\n", c.baseURL)
|
||||||
|
req, err := http.NewRequest("POST", c.baseURL+"/clean/upload", &body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("创建请求失败: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("HTTP 请求失败: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// 读取响应
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("读取响应失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理错误响应
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
var errResp ErrorResponse
|
||||||
|
if err := json.Unmarshal(respBody, &errResp); err == nil {
|
||||||
|
return nil, fmt.Errorf("清洗失败: %s", errResp.Detail)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("清洗失败: HTTP %d - %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析成功响应
|
||||||
|
var cleanResp CleanResponse
|
||||||
|
if err := json.Unmarshal(respBody, &cleanResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析响应失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载清洗后的文件
|
||||||
|
if cleanResp.OutputPath != "" {
|
||||||
|
if err := c.downloadFile(cleanResp.OutputPath, outputPath); err != nil {
|
||||||
|
return nil, fmt.Errorf("下载清洗结果失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &adapter.CleanResult{
|
||||||
|
BillType: cleanResp.BillType,
|
||||||
|
Output: cleanResp.Message,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadFile 下载清洗后的文件
|
||||||
|
func (c *Cleaner) downloadFile(remotePath, localPath string) error {
|
||||||
|
// 构建下载 URL
|
||||||
|
downloadURL := fmt.Sprintf("%s/clean/download/%s", c.baseURL, remotePath)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Get(downloadURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("下载请求失败: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("下载失败: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建本地文件
|
||||||
|
out, err := os.Create(localPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("创建文件失败: %w", err)
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
// 写入文件内容
|
||||||
|
if _, err := io.Copy(out, resp.Body); err != nil {
|
||||||
|
return fmt.Errorf("写入文件失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthCheck 检查 Python 服务健康状态
|
||||||
|
func (c *Cleaner) HealthCheck() error {
|
||||||
|
resp, err := c.httpClient.Get(c.baseURL + "/health")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("健康检查失败: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("服务不健康: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 Cleaner 实现了 adapter.Cleaner 接口
|
||||||
|
var _ adapter.Cleaner = (*Cleaner)(nil)
|
||||||
94
server/adapter/python/cleaner.go
Normal file
94
server/adapter/python/cleaner.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
// Package python 实现通过子进程调用 Python 脚本的清洗器
|
||||||
|
package python
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"billai-server/adapter"
|
||||||
|
"billai-server/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cleaner 通过子进程调用 Python 脚本的清洗器实现
|
||||||
|
type Cleaner struct {
|
||||||
|
pythonPath string // Python 解释器路径
|
||||||
|
scriptPath string // 清洗脚本路径
|
||||||
|
workDir string // 工作目录
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCleaner 创建 Python 清洗器
|
||||||
|
func NewCleaner() *Cleaner {
|
||||||
|
return &Cleaner{
|
||||||
|
pythonPath: config.ResolvePath(config.Global.PythonPath),
|
||||||
|
scriptPath: config.ResolvePath(config.Global.CleanScript),
|
||||||
|
workDir: config.Global.ProjectRoot,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCleanerWithConfig 使用自定义配置创建 Python 清洗器
|
||||||
|
func NewCleanerWithConfig(pythonPath, scriptPath, workDir string) *Cleaner {
|
||||||
|
return &Cleaner{
|
||||||
|
pythonPath: pythonPath,
|
||||||
|
scriptPath: scriptPath,
|
||||||
|
workDir: workDir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean 执行 Python 清洗脚本
|
||||||
|
func (c *Cleaner) Clean(inputPath, outputPath string, opts *adapter.CleanOptions) (*adapter.CleanResult, error) {
|
||||||
|
// 构建命令参数
|
||||||
|
args := []string{c.scriptPath, inputPath, outputPath}
|
||||||
|
|
||||||
|
if opts != nil {
|
||||||
|
if opts.Year != "" {
|
||||||
|
args = append(args, "--year", opts.Year)
|
||||||
|
}
|
||||||
|
if opts.Month != "" {
|
||||||
|
args = append(args, "--month", opts.Month)
|
||||||
|
}
|
||||||
|
if opts.Start != "" {
|
||||||
|
args = append(args, "--start", opts.Start)
|
||||||
|
}
|
||||||
|
if opts.End != "" {
|
||||||
|
args = append(args, "--end", opts.End)
|
||||||
|
}
|
||||||
|
if opts.Format != "" {
|
||||||
|
args = append(args, "--format", opts.Format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行 Python 脚本
|
||||||
|
fmt.Printf("🐍 执行清洗脚本...\n")
|
||||||
|
cmd := exec.Command(c.pythonPath, args...)
|
||||||
|
cmd.Dir = c.workDir
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("清洗脚本执行失败: %w\n输出: %s", err, outputStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从输出中检测账单类型
|
||||||
|
billType := detectBillTypeFromOutput(outputStr)
|
||||||
|
|
||||||
|
return &adapter.CleanResult{
|
||||||
|
BillType: billType,
|
||||||
|
Output: outputStr,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectBillTypeFromOutput 从 Python 脚本输出中检测账单类型
|
||||||
|
func detectBillTypeFromOutput(output string) string {
|
||||||
|
if strings.Contains(output, "支付宝") {
|
||||||
|
return "alipay"
|
||||||
|
}
|
||||||
|
if strings.Contains(output, "微信") {
|
||||||
|
return "wechat"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 Cleaner 实现了 adapter.Cleaner 接口
|
||||||
|
var _ adapter.Cleaner = (*Cleaner)(nil)
|
||||||
@@ -4,13 +4,20 @@
|
|||||||
server:
|
server:
|
||||||
port: 8080
|
port: 8080
|
||||||
|
|
||||||
# Python 配置
|
# Python 配置 (subprocess 模式使用)
|
||||||
python:
|
python:
|
||||||
# Python 解释器路径(相对于项目根目录或绝对路径)
|
# Python 解释器路径(相对于项目根目录或绝对路径)
|
||||||
path: analyzer/venv/Scripts/python.exe
|
path: analyzer/venv/bin/python
|
||||||
# 分析脚本路径(相对于项目根目录)
|
# 分析脚本路径(相对于项目根目录)
|
||||||
script: analyzer/clean_bill.py
|
script: analyzer/clean_bill.py
|
||||||
|
|
||||||
|
# Analyzer 服务配置 (HTTP 模式使用)
|
||||||
|
analyzer:
|
||||||
|
# Python 分析服务 URL
|
||||||
|
url: http://localhost:8001
|
||||||
|
# 适配器模式: http (推荐) 或 subprocess
|
||||||
|
mode: http
|
||||||
|
|
||||||
# 文件目录配置(相对于项目根目录)
|
# 文件目录配置(相对于项目根目录)
|
||||||
directories:
|
directories:
|
||||||
upload: server/uploads
|
upload: server/uploads
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ type Config struct {
|
|||||||
UploadDir string // 上传文件目录
|
UploadDir string // 上传文件目录
|
||||||
OutputDir string // 输出文件目录
|
OutputDir string // 输出文件目录
|
||||||
|
|
||||||
|
// Analyzer 服务配置 (HTTP 模式)
|
||||||
|
AnalyzerURL string // Python 分析服务 URL
|
||||||
|
AnalyzerMode string // 适配器模式: http 或 subprocess
|
||||||
|
|
||||||
// MongoDB 配置
|
// MongoDB 配置
|
||||||
MongoURI string // MongoDB 连接 URI
|
MongoURI string // MongoDB 连接 URI
|
||||||
MongoDatabase string // 数据库名称
|
MongoDatabase string // 数据库名称
|
||||||
@@ -34,6 +38,10 @@ type configFile struct {
|
|||||||
Path string `yaml:"path"`
|
Path string `yaml:"path"`
|
||||||
Script string `yaml:"script"`
|
Script string `yaml:"script"`
|
||||||
} `yaml:"python"`
|
} `yaml:"python"`
|
||||||
|
Analyzer struct {
|
||||||
|
URL string `yaml:"url"`
|
||||||
|
Mode string `yaml:"mode"` // http 或 subprocess
|
||||||
|
} `yaml:"analyzer"`
|
||||||
Directories struct {
|
Directories struct {
|
||||||
Upload string `yaml:"upload"`
|
Upload string `yaml:"upload"`
|
||||||
Output string `yaml:"output"`
|
Output string `yaml:"output"`
|
||||||
@@ -116,6 +124,10 @@ func Load() {
|
|||||||
Global.UploadDir = "server/uploads"
|
Global.UploadDir = "server/uploads"
|
||||||
Global.OutputDir = "server/outputs"
|
Global.OutputDir = "server/outputs"
|
||||||
|
|
||||||
|
// Analyzer 默认值
|
||||||
|
Global.AnalyzerURL = getEnvOrDefault("ANALYZER_URL", "http://localhost:8001")
|
||||||
|
Global.AnalyzerMode = getEnvOrDefault("ANALYZER_MODE", "http")
|
||||||
|
|
||||||
// MongoDB 默认值
|
// MongoDB 默认值
|
||||||
Global.MongoURI = getEnvOrDefault("MONGO_URI", "mongodb://localhost:27017")
|
Global.MongoURI = getEnvOrDefault("MONGO_URI", "mongodb://localhost:27017")
|
||||||
Global.MongoDatabase = getEnvOrDefault("MONGO_DATABASE", "billai")
|
Global.MongoDatabase = getEnvOrDefault("MONGO_DATABASE", "billai")
|
||||||
@@ -148,6 +160,13 @@ func Load() {
|
|||||||
if cfg.Directories.Output != "" {
|
if cfg.Directories.Output != "" {
|
||||||
Global.OutputDir = 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 配置
|
// MongoDB 配置
|
||||||
if cfg.MongoDB.URI != "" {
|
if cfg.MongoDB.URI != "" {
|
||||||
Global.MongoURI = cfg.MongoDB.URI
|
Global.MongoURI = cfg.MongoDB.URI
|
||||||
@@ -173,6 +192,13 @@ func Load() {
|
|||||||
if root := os.Getenv("BILLAI_ROOT"); root != "" {
|
if root := os.Getenv("BILLAI_ROOT"); root != "" {
|
||||||
Global.ProjectRoot = 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 环境变量覆盖
|
// MongoDB 环境变量覆盖
|
||||||
if uri := os.Getenv("MONGO_URI"); uri != "" {
|
if uri := os.Getenv("MONGO_URI"); uri != "" {
|
||||||
Global.MongoURI = uri
|
Global.MongoURI = uri
|
||||||
@@ -195,4 +221,3 @@ func ResolvePath(path string) string {
|
|||||||
}
|
}
|
||||||
return filepath.Join(Global.ProjectRoot, path)
|
return filepath.Join(Global.ProjectRoot, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
165
server/handler/bills.go
Normal file
165
server/handler/bills.go
Normal 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
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -36,6 +35,23 @@ func Upload(c *gin.Context) {
|
|||||||
req.Format = "csv"
|
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. 保存上传的文件
|
// 3. 保存上传的文件
|
||||||
timestamp := time.Now().Format("20060102_150405")
|
timestamp := time.Now().Format("20060102_150405")
|
||||||
inputFileName := fmt.Sprintf("%s_%s", timestamp, header.Filename)
|
inputFileName := fmt.Sprintf("%s_%s", timestamp, header.Filename)
|
||||||
@@ -64,9 +80,6 @@ func Upload(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 账单类型从去重结果获取
|
|
||||||
billType := dedupResult.BillType
|
|
||||||
|
|
||||||
fmt.Printf(" 原始记录: %d 条\n", dedupResult.OriginalCount)
|
fmt.Printf(" 原始记录: %d 条\n", dedupResult.OriginalCount)
|
||||||
if dedupResult.DuplicateCount > 0 {
|
if dedupResult.DuplicateCount > 0 {
|
||||||
fmt.Printf(" 重复记录: %d 条(已跳过)\n", dedupResult.DuplicateCount)
|
fmt.Printf(" 重复记录: %d 条(已跳过)\n", dedupResult.DuplicateCount)
|
||||||
@@ -91,14 +104,14 @@ func Upload(c *gin.Context) {
|
|||||||
// 使用去重后的文件路径进行后续处理
|
// 使用去重后的文件路径进行后续处理
|
||||||
processFilePath := dedupResult.DedupFilePath
|
processFilePath := dedupResult.DedupFilePath
|
||||||
|
|
||||||
// 5. 构建输出文件路径
|
// 5. 构建输出文件路径:时间_type_编号
|
||||||
baseName := strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))
|
|
||||||
outputExt := ".csv"
|
outputExt := ".csv"
|
||||||
if req.Format == "json" {
|
if req.Format == "json" {
|
||||||
outputExt = ".json"
|
outputExt = ".json"
|
||||||
}
|
}
|
||||||
outputFileName := fmt.Sprintf("%s_%s_cleaned%s", timestamp, baseName, outputExt)
|
|
||||||
outputDirAbs := config.ResolvePath(config.Global.OutputDir)
|
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)
|
outputPath := filepath.Join(outputDirAbs, outputFileName)
|
||||||
|
|
||||||
// 6. 执行 Python 清洗脚本
|
// 6. 执行 Python 清洗脚本
|
||||||
@@ -109,7 +122,7 @@ func Upload(c *gin.Context) {
|
|||||||
End: req.End,
|
End: req.End,
|
||||||
Format: req.Format,
|
Format: req.Format,
|
||||||
}
|
}
|
||||||
cleanResult, cleanErr := service.RunCleanScript(processFilePath, outputPath, cleanOpts)
|
_, cleanErr := service.RunCleanScript(processFilePath, outputPath, cleanOpts)
|
||||||
if cleanErr != nil {
|
if cleanErr != nil {
|
||||||
c.JSON(http.StatusInternalServerError, model.UploadResponse{
|
c.JSON(http.StatusInternalServerError, model.UploadResponse{
|
||||||
Result: false,
|
Result: false,
|
||||||
@@ -118,12 +131,7 @@ func Upload(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. 如果去重检测没有识别出类型,从 Python 输出中检测
|
// 7. 将去重后的原始数据存入 MongoDB(原始数据集合)
|
||||||
if billType == "" {
|
|
||||||
billType = cleanResult.BillType
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8. 将去重后的原始数据存入 MongoDB(原始数据集合)
|
|
||||||
rawCount, rawErr := service.SaveRawBillsFromFile(processFilePath, billType, header.Filename, timestamp)
|
rawCount, rawErr := service.SaveRawBillsFromFile(processFilePath, billType, header.Filename, timestamp)
|
||||||
if rawErr != nil {
|
if rawErr != nil {
|
||||||
fmt.Printf("⚠️ 存储原始数据到 MongoDB 失败: %v\n", rawErr)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
105
server/main.go
105
server/main.go
@@ -2,16 +2,20 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"billai-server/adapter"
|
||||||
|
adapterHttp "billai-server/adapter/http"
|
||||||
|
"billai-server/adapter/python"
|
||||||
"billai-server/config"
|
"billai-server/config"
|
||||||
"billai-server/database"
|
"billai-server/database"
|
||||||
"billai-server/handler"
|
"billai-server/repository"
|
||||||
|
repoMongo "billai-server/repository/mongo"
|
||||||
|
"billai-server/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -36,7 +40,17 @@ func main() {
|
|||||||
fmt.Println(" 请在配置文件中指定正确的 Python 路径")
|
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 {
|
if err := database.Connect(); err != nil {
|
||||||
fmt.Printf("⚠️ 警告: MongoDB 连接失败: %v\n", err)
|
fmt.Printf("⚠️ 警告: MongoDB 连接失败: %v\n", err)
|
||||||
fmt.Println(" 账单数据将不会存储到数据库")
|
fmt.Println(" 账单数据将不会存储到数据库")
|
||||||
@@ -50,7 +64,10 @@ func main() {
|
|||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
|
||||||
// 注册路由
|
// 注册路由
|
||||||
setupRoutes(r, outputDirAbs, pythonPathAbs)
|
router.Setup(r, router.Config{
|
||||||
|
OutputDir: outputDirAbs,
|
||||||
|
PythonPath: pythonPathAbs,
|
||||||
|
})
|
||||||
|
|
||||||
// 监听系统信号
|
// 监听系统信号
|
||||||
go func() {
|
go func() {
|
||||||
@@ -67,34 +84,18 @@ func main() {
|
|||||||
r.Run(":" + config.Global.Port)
|
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 打印启动横幅
|
// printBanner 打印启动横幅
|
||||||
func printBanner(pythonPath, uploadDir, outputDir string) {
|
func printBanner(pythonPath, uploadDir, outputDir string) {
|
||||||
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||||
fmt.Println("📦 BillAI 账单分析服务")
|
fmt.Println("📦 BillAI 账单分析服务")
|
||||||
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||||
fmt.Printf("📁 项目根目录: %s\n", config.Global.ProjectRoot)
|
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", uploadDir)
|
||||||
fmt.Printf("📂 输出目录: %s\n", outputDir)
|
fmt.Printf("📂 输出目录: %s\n", outputDir)
|
||||||
fmt.Printf("🍃 MongoDB: %s/%s\n", config.Global.MongoURI, config.Global.MongoDatabase)
|
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.Printf("\n🚀 服务已启动: http://localhost:%s\n", config.Global.Port)
|
||||||
fmt.Println("📝 API 接口:")
|
fmt.Println("📝 API 接口:")
|
||||||
fmt.Println(" POST /api/upload - 上传并分析账单")
|
fmt.Println(" POST /api/upload - 上传并分析账单")
|
||||||
|
fmt.Println(" GET /api/bills - 获取账单列表(支持分页和时间筛选)")
|
||||||
fmt.Println(" GET /api/review - 获取需要复核的记录")
|
fmt.Println(" GET /api/review - 获取需要复核的记录")
|
||||||
fmt.Println(" GET /download/* - 下载结果文件")
|
fmt.Println(" GET /download/* - 下载结果文件")
|
||||||
fmt.Println(" GET /health - 健康检查")
|
fmt.Println(" GET /health - 健康检查")
|
||||||
fmt.Println()
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ package model
|
|||||||
|
|
||||||
// UploadRequest 上传请求参数
|
// UploadRequest 上传请求参数
|
||||||
type UploadRequest struct {
|
type UploadRequest struct {
|
||||||
|
Type string `form:"type"` // 账单类型: alipay/wechat(必填)
|
||||||
Year string `form:"year"` // 年份筛选
|
Year string `form:"year"` // 年份筛选
|
||||||
Month string `form:"month"` // 月份筛选
|
Month string `form:"month"` // 月份筛选
|
||||||
Start string `form:"start"` // 起始日期
|
Start string `form:"start"` // 起始日期
|
||||||
End string `form:"end"` // 结束日期
|
End string `form:"end"` // 结束日期
|
||||||
Format string `form:"format"` // 输出格式: csv/json
|
Format string `form:"format"` // 输出格式: csv/json
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
14
server/repository/global.go
Normal file
14
server/repository/global.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// Package repository 全局存储实例管理
|
||||||
|
package repository
|
||||||
|
|
||||||
|
var globalRepo BillRepository
|
||||||
|
|
||||||
|
// SetRepository 设置全局存储实例
|
||||||
|
func SetRepository(r BillRepository) {
|
||||||
|
globalRepo = r
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepository 获取全局存储实例
|
||||||
|
func GetRepository() BillRepository {
|
||||||
|
return globalRepo
|
||||||
|
}
|
||||||
44
server/repository/repository.go
Normal file
44
server/repository/repository.go
Normal 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
53
server/router/router.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -312,7 +312,7 @@ func saveCleanedBillsFromCSV(filePath, billType, sourceFile, uploadBatch string)
|
|||||||
// 提取字段 - 订单号(用于去重判断)
|
// 提取字段 - 订单号(用于去重判断)
|
||||||
if idx, ok := colIdx["交易订单号"]; ok && len(row) > idx {
|
if idx, ok := colIdx["交易订单号"]; ok && len(row) > idx {
|
||||||
bill.TransactionID = strings.TrimSpace(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])
|
bill.TransactionID = strings.TrimSpace(row[idx])
|
||||||
}
|
}
|
||||||
if idx, ok := colIdx["商家订单号"]; ok && len(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 {
|
if idx, ok := colIdx["交易分类"]; ok && len(row) > idx {
|
||||||
bill.Category = 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 {
|
if idx, ok := colIdx["交易对方"]; ok && len(row) > idx {
|
||||||
bill.Merchant = row[idx]
|
bill.Merchant = row[idx]
|
||||||
}
|
}
|
||||||
if idx, ok := colIdx["商品说明"]; ok && len(row) > idx {
|
if idx, ok := colIdx["商品说明"]; ok && len(row) > idx {
|
||||||
bill.Description = 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 {
|
if idx, ok := colIdx["收/支"]; ok && len(row) > idx {
|
||||||
bill.IncomeExpense = row[idx]
|
bill.IncomeExpense = row[idx]
|
||||||
}
|
}
|
||||||
if idx, ok := colIdx["金额"]; ok && len(row) > idx {
|
if idx, ok := colIdx["金额"]; ok && len(row) > idx {
|
||||||
bill.Amount = parseAmount(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]
|
bill.PayMethod = row[idx]
|
||||||
}
|
}
|
||||||
if idx, ok := colIdx["交易状态"]; ok && len(row) > idx {
|
if idx, ok := colIdx["交易状态"]; ok && len(row) > idx {
|
||||||
bill.Status = 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 {
|
if idx, ok := colIdx["备注"]; ok && len(row) > idx {
|
||||||
bill.Remark = row[idx]
|
bill.Remark = row[idx]
|
||||||
|
|||||||
@@ -1,84 +1,48 @@
|
|||||||
|
// Package service 业务逻辑层
|
||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"billai-server/adapter"
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"billai-server/config"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// CleanOptions 清洗选项
|
// CleanOptions 清洗选项(保持向后兼容)
|
||||||
type CleanOptions struct {
|
type CleanOptions = adapter.CleanOptions
|
||||||
Year string // 年份筛选
|
|
||||||
Month string // 月份筛选
|
|
||||||
Start string // 起始日期
|
|
||||||
End string // 结束日期
|
|
||||||
Format string // 输出格式: csv/json
|
|
||||||
}
|
|
||||||
|
|
||||||
// CleanResult 清洗结果
|
// CleanResult 清洗结果(保持向后兼容)
|
||||||
type CleanResult struct {
|
type CleanResult = adapter.CleanResult
|
||||||
BillType string // 检测到的账单类型: alipay/wechat
|
|
||||||
Output string // Python 脚本输出
|
|
||||||
}
|
|
||||||
|
|
||||||
// RunCleanScript 执行 Python 清洗脚本
|
// RunCleanScript 执行清洗脚本(使用适配器)
|
||||||
// inputPath: 输入文件路径
|
// inputPath: 输入文件路径
|
||||||
// outputPath: 输出文件路径
|
// outputPath: 输出文件路径
|
||||||
// opts: 清洗选项
|
// opts: 清洗选项
|
||||||
func RunCleanScript(inputPath, outputPath string, opts *CleanOptions) (*CleanResult, error) {
|
func RunCleanScript(inputPath, outputPath string, opts *CleanOptions) (*CleanResult, error) {
|
||||||
// 构建命令参数
|
cleaner := adapter.GetCleaner()
|
||||||
cleanScriptAbs := config.ResolvePath(config.Global.CleanScript)
|
return cleaner.Clean(inputPath, outputPath, opts)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DetectBillTypeFromOutput 从 Python 脚本输出中检测账单类型
|
// DetectBillTypeFromOutput 从脚本输出中检测账单类型
|
||||||
|
// 保留此函数以兼容其他调用
|
||||||
func DetectBillTypeFromOutput(output string) string {
|
func DetectBillTypeFromOutput(output string) string {
|
||||||
if strings.Contains(output, "支付宝") {
|
if containsSubstring(output, "支付宝") {
|
||||||
return "alipay"
|
return "alipay"
|
||||||
}
|
}
|
||||||
if strings.Contains(output, "微信") {
|
if containsSubstring(output, "微信") {
|
||||||
return "wechat"
|
return "wechat"
|
||||||
}
|
}
|
||||||
return ""
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
engine-strict=true
|
engine-strict=true
|
||||||
|
registry=https://registry.npmmirror.com
|
||||||
|
|||||||
56
web/Dockerfile
Normal file
56
web/Dockerfile
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# SvelteKit Web 前端 Dockerfile
|
||||||
|
# 多阶段构建:构建阶段 + 运行阶段
|
||||||
|
|
||||||
|
# ===== 构建阶段 =====
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 配置 yarn 镜像源(国内)
|
||||||
|
RUN yarn config set registry https://registry.npmmirror.com
|
||||||
|
|
||||||
|
# 先复制依赖文件,利用 Docker 缓存
|
||||||
|
COPY package.json yarn.lock* ./
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
RUN yarn install --frozen-lockfile || yarn install
|
||||||
|
|
||||||
|
# 复制源代码
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 构建生产版本
|
||||||
|
RUN yarn build
|
||||||
|
|
||||||
|
# ===== 运行阶段 =====
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 配置 Alpine 镜像源(国内)
|
||||||
|
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||||
|
|
||||||
|
# 安装必要工具
|
||||||
|
RUN apk --no-cache add curl
|
||||||
|
|
||||||
|
# 设置时区
|
||||||
|
ENV TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
# 设置为生产环境
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
# 从构建阶段复制构建产物和依赖
|
||||||
|
COPY --from=builder /app/build ./build
|
||||||
|
COPY --from=builder /app/package.json ./
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
HEALTHCHECK --interval=10s --timeout=5s --retries=5 \
|
||||||
|
CMD curl -f http://localhost:3000 || exit 1
|
||||||
|
|
||||||
|
# 启动服务
|
||||||
|
CMD ["node", "build"]
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@internationalized/date": "^3.10.0",
|
"@internationalized/date": "^3.10.0",
|
||||||
"@lucide/svelte": "^0.561.0",
|
"@lucide/svelte": "^0.561.0",
|
||||||
"@sveltejs/adapter-auto": "^7.0.0",
|
"@sveltejs/adapter-node": "^5.4.0",
|
||||||
"@sveltejs/kit": "^2.49.1",
|
"@sveltejs/kit": "^2.49.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
|||||||
@@ -1,11 +1,29 @@
|
|||||||
// API 配置
|
// API 配置 - 使用相对路径,由 SvelteKit 代理到后端
|
||||||
const API_BASE = 'http://localhost:8080';
|
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 {
|
export interface UploadData {
|
||||||
bill_type: 'alipay' | 'wechat';
|
bill_type: BillType;
|
||||||
file_url: string;
|
file_url: string;
|
||||||
file_name: string;
|
file_name: string;
|
||||||
|
raw_count: number;
|
||||||
|
cleaned_count: number;
|
||||||
|
duplicate_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UploadResponse {
|
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();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
formData.append('type', type);
|
||||||
|
|
||||||
if (options?.year) {
|
if (options?.year) {
|
||||||
formData.append('year', options.year.toString());
|
formData.append('year', options.year.toString());
|
||||||
@@ -108,23 +131,23 @@ function parseCSV(text: string): BillRecord[] {
|
|||||||
const lines = text.trim().split('\n');
|
const lines = text.trim().split('\n');
|
||||||
if (lines.length < 2) return [];
|
if (lines.length < 2) return [];
|
||||||
|
|
||||||
const headers = lines[0].split(',');
|
|
||||||
const records: BillRecord[] = [];
|
const records: BillRecord[] = [];
|
||||||
|
|
||||||
|
// CSV 格式:交易时间,交易分类,交易对方,对方账号,商品说明,收/支,金额,收/付款方式,交易状态,交易订单号,商家订单号,备注,,复核等级
|
||||||
for (let i = 1; i < lines.length; i++) {
|
for (let i = 1; i < lines.length; i++) {
|
||||||
const values = parseCSVLine(lines[i]);
|
const values = parseCSVLine(lines[i]);
|
||||||
if (values.length >= headers.length) {
|
if (values.length >= 7) {
|
||||||
records.push({
|
records.push({
|
||||||
time: values[0] || '',
|
time: values[0] || '',
|
||||||
category: values[1] || '',
|
category: values[1] || '',
|
||||||
merchant: values[2] || '',
|
merchant: values[2] || '',
|
||||||
description: values[3] || '',
|
description: values[4] || '', // 跳过 values[3] (对方账号)
|
||||||
income_expense: values[4] || '',
|
income_expense: values[5] || '',
|
||||||
amount: values[5] || '',
|
amount: values[6] || '',
|
||||||
payment_method: values[6] || '',
|
payment_method: values[7] || '',
|
||||||
status: values[7] || '',
|
status: values[8] || '',
|
||||||
remark: values[8] || '',
|
remark: values[11] || '',
|
||||||
needs_review: values[9] || '',
|
needs_review: values[13] || '', // 复核等级在第14列
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,5 +183,71 @@ function parseCSVLine(line: string): string[] {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清洗后的账单记录
|
||||||
|
export interface CleanedBill {
|
||||||
|
id: string;
|
||||||
|
bill_type: string;
|
||||||
|
time: string;
|
||||||
|
category: string;
|
||||||
|
merchant: string;
|
||||||
|
description: string;
|
||||||
|
income_expense: string;
|
||||||
|
amount: number;
|
||||||
|
pay_method: string;
|
||||||
|
status: string;
|
||||||
|
remark: string;
|
||||||
|
review_level: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 账单列表请求参数
|
||||||
|
export interface FetchBillsParams {
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
category?: string;
|
||||||
|
type?: string; // 账单来源 alipay/wechat
|
||||||
|
income_expense?: string; // 收支类型 收入/支出
|
||||||
|
}
|
||||||
|
|
||||||
|
// 账单列表响应
|
||||||
|
export interface BillsResponse {
|
||||||
|
result: boolean;
|
||||||
|
message?: string;
|
||||||
|
data?: {
|
||||||
|
total: number;
|
||||||
|
total_expense: number; // 筛选条件下的总支出
|
||||||
|
total_income: number; // 筛选条件下的总收入
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
pages: number;
|
||||||
|
bills: CleanedBill[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取账单列表(支持分页和筛选)
|
||||||
|
export async function fetchBills(params: FetchBillsParams = {}): Promise<BillsResponse> {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
|
||||||
|
if (params.page) searchParams.set('page', params.page.toString());
|
||||||
|
if (params.page_size) searchParams.set('page_size', params.page_size.toString());
|
||||||
|
if (params.start_date) searchParams.set('start_date', params.start_date);
|
||||||
|
if (params.end_date) searchParams.set('end_date', params.end_date);
|
||||||
|
if (params.category) searchParams.set('category', params.category);
|
||||||
|
if (params.type) searchParams.set('type', params.type);
|
||||||
|
if (params.income_expense) searchParams.set('income_expense', params.income_expense);
|
||||||
|
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
const url = `${API_BASE}/api/bills${queryString ? '?' + queryString : ''}`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Table from '$lib/components/ui/table';
|
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 * as Select from '$lib/components/ui/select';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
@@ -334,22 +334,22 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- 详情/编辑弹窗 -->
|
<!-- 详情/编辑弹窗 -->
|
||||||
<Dialog.Root bind:open={detailDialogOpen}>
|
<Drawer.Root bind:open={detailDialogOpen}>
|
||||||
<Dialog.Content class="sm:max-w-md">
|
<Drawer.Content class="sm:max-w-md">
|
||||||
<Dialog.Header>
|
<Drawer.Header>
|
||||||
<Dialog.Title class="flex items-center gap-2">
|
<Drawer.Title class="flex items-center gap-2">
|
||||||
<Receipt class="h-5 w-5" />
|
<Receipt class="h-5 w-5" />
|
||||||
{isEditing ? '编辑账单' : '账单详情'}
|
{isEditing ? '编辑账单' : '账单详情'}
|
||||||
</Dialog.Title>
|
</Drawer.Title>
|
||||||
<Dialog.Description>
|
<Drawer.Description>
|
||||||
{isEditing ? '修改这笔支出的信息' : '查看这笔支出的详细信息'}
|
{isEditing ? '修改这笔支出的信息' : '查看这笔支出的详细信息'}
|
||||||
</Dialog.Description>
|
</Drawer.Description>
|
||||||
</Dialog.Header>
|
</Drawer.Header>
|
||||||
|
|
||||||
{#if selectedRecord}
|
{#if selectedRecord}
|
||||||
{#if isEditing}
|
{#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">
|
<div class="space-y-2">
|
||||||
<Label>金额</Label>
|
<Label>金额</Label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
@@ -400,7 +400,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- 详情展示 -->
|
<!-- 详情展示 -->
|
||||||
<div class="py-4">
|
<div class="py-4 px-4 md:px-0">
|
||||||
<div class="text-center mb-6">
|
<div class="text-center mb-6">
|
||||||
<div class="text-3xl font-bold text-red-600 dark:text-red-400 font-mono">
|
<div class="text-3xl font-bold text-red-600 dark:text-red-400 font-mono">
|
||||||
¥{selectedRecord.amount}
|
¥{selectedRecord.amount}
|
||||||
@@ -459,7 +459,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Dialog.Footer>
|
<Drawer.Footer>
|
||||||
{#if isEditing}
|
{#if isEditing}
|
||||||
<Button variant="outline" onclick={cancelEdit}>
|
<Button variant="outline" onclick={cancelEdit}>
|
||||||
<X class="h-4 w-4 mr-2" />
|
<X class="h-4 w-4 mr-2" />
|
||||||
@@ -478,6 +478,6 @@
|
|||||||
编辑
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</Dialog.Footer>
|
</Drawer.Footer>
|
||||||
</Dialog.Content>
|
</Drawer.Content>
|
||||||
</Dialog.Root>
|
</Drawer.Root>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Card from '$lib/components/ui/card';
|
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 { Button } from '$lib/components/ui/button';
|
||||||
import PieChartIcon from '@lucide/svelte/icons/pie-chart';
|
import PieChartIcon from '@lucide/svelte/icons/pie-chart';
|
||||||
import ListIcon from '@lucide/svelte/icons/list';
|
import ListIcon from '@lucide/svelte/icons/list';
|
||||||
@@ -147,6 +147,7 @@
|
|||||||
{@const x4 = Math.cos(startAngle) * innerRadius}
|
{@const x4 = Math.cos(startAngle) * innerRadius}
|
||||||
{@const y4 = Math.sin(startAngle) * innerRadius}
|
{@const y4 = Math.sin(startAngle) * innerRadius}
|
||||||
{@const largeArc = (endAngle - startAngle) > Math.PI ? 1 : 0}
|
{@const largeArc = (endAngle - startAngle) > Math.PI ? 1 : 0}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<path
|
<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"
|
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}
|
fill={item.color}
|
||||||
@@ -197,28 +198,28 @@
|
|||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
<!-- 分类详情弹窗 -->
|
<!-- 分类详情弹窗 -->
|
||||||
<Dialog.Root bind:open={dialogOpen}>
|
<Drawer.Root bind:open={dialogOpen}>
|
||||||
<Dialog.Content class="w-fit min-w-[500px] max-w-[90vw] max-h-[80vh] overflow-hidden flex flex-col">
|
<Drawer.Content class="sm:max-w-4xl">
|
||||||
<Dialog.Header>
|
<Drawer.Header>
|
||||||
<Dialog.Title class="flex items-center gap-2">
|
<Drawer.Title class="flex items-center gap-2">
|
||||||
<PieChartIcon class="h-5 w-5" />
|
<PieChartIcon class="h-5 w-5" />
|
||||||
{selectedCategory} - 账单明细
|
{selectedCategory} - 账单明细
|
||||||
</Dialog.Title>
|
</Drawer.Title>
|
||||||
<Dialog.Description>
|
<Drawer.Description>
|
||||||
{#if selectedStat}
|
{#if selectedStat}
|
||||||
共 {selectedStat.count} 笔,合计 ¥{selectedStat.expense.toFixed(2)}
|
共 {selectedStat.count} 笔,合计 ¥{selectedStat.expense.toFixed(2)}
|
||||||
{/if}
|
{/if}
|
||||||
</Dialog.Description>
|
</Drawer.Description>
|
||||||
</Dialog.Header>
|
</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} />
|
<BillRecordsTable records={selectedRecords} showDescription={true} {categories} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog.Footer class="mt-4">
|
<Drawer.Footer>
|
||||||
<Button variant="outline" onclick={() => dialogOpen = false}>
|
<Button variant="outline" onclick={() => dialogOpen = false}>
|
||||||
关闭
|
关闭
|
||||||
</Button>
|
</Button>
|
||||||
</Dialog.Footer>
|
</Drawer.Footer>
|
||||||
</Dialog.Content>
|
</Drawer.Content>
|
||||||
</Dialog.Root>
|
</Drawer.Root>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import * as Select from '$lib/components/ui/select';
|
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 Activity from '@lucide/svelte/icons/activity';
|
||||||
import TrendingUp from '@lucide/svelte/icons/trending-up';
|
import TrendingUp from '@lucide/svelte/icons/trending-up';
|
||||||
import TrendingDown from '@lucide/svelte/icons/trending-down';
|
import TrendingDown from '@lucide/svelte/icons/trending-down';
|
||||||
@@ -46,6 +46,12 @@
|
|||||||
hiddenCategories = newSet;
|
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 = [
|
const timeRangeOptions = [
|
||||||
{ value: '7d', label: '最近 7 天' },
|
{ value: '7d', label: '最近 7 天' },
|
||||||
{ value: 'week', label: '本周' },
|
{ value: 'week', label: '本周' },
|
||||||
@@ -119,7 +125,7 @@
|
|||||||
// 过滤支出记录
|
// 过滤支出记录
|
||||||
const expenseRecords = records.filter(r => {
|
const expenseRecords = records.filter(r => {
|
||||||
if (r.income_expense !== '支出') return false;
|
if (r.income_expense !== '支出') return false;
|
||||||
const recordDate = new Date(r.time.split(' ')[0]);
|
const recordDate = new Date(extractDateStr(r.time));
|
||||||
return recordDate >= cutoffDate;
|
return recordDate >= cutoffDate;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -130,7 +136,7 @@
|
|||||||
const categoryTotals: Record<string, number> = {};
|
const categoryTotals: Record<string, number> = {};
|
||||||
|
|
||||||
expenseRecords.forEach(record => {
|
expenseRecords.forEach(record => {
|
||||||
const dateStr = record.time.split(' ')[0];
|
const dateStr = extractDateStr(record.time);
|
||||||
const category = record.category || '其他';
|
const category = record.category || '其他';
|
||||||
const amount = parseFloat(record.amount) || 0;
|
const amount = parseFloat(record.amount) || 0;
|
||||||
|
|
||||||
@@ -526,7 +532,7 @@
|
|||||||
selectedDate = clickedDate;
|
selectedDate = clickedDate;
|
||||||
selectedDateRecords = records.filter(r => {
|
selectedDateRecords = records.filter(r => {
|
||||||
if (r.income_expense !== '支出') return false;
|
if (r.income_expense !== '支出') return false;
|
||||||
const recordDateStr = r.time.split(' ')[0];
|
const recordDateStr = extractDateStr(r.time);
|
||||||
return recordDateStr === dateStr;
|
return recordDateStr === dateStr;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -641,7 +647,7 @@
|
|||||||
|
|
||||||
<!-- 趋势图 (自定义 SVG) -->
|
<!-- 趋势图 (自定义 SVG) -->
|
||||||
<div class="relative w-full" style="aspect-ratio: {chartWidth}/{chartHeight};">
|
<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
|
<svg
|
||||||
viewBox="0 0 {chartWidth} {chartHeight}"
|
viewBox="0 0 {chartWidth} {chartHeight}"
|
||||||
class="w-full h-full cursor-pointer outline-none focus:outline-none"
|
class="w-full h-full cursor-pointer outline-none focus:outline-none"
|
||||||
@@ -827,25 +833,25 @@
|
|||||||
</Card.Root>
|
</Card.Root>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- 当日详情 Dialog -->
|
<!-- 当日详情 Drawer -->
|
||||||
<Dialog.Root bind:open={dialogOpen}>
|
<Drawer.Root bind:open={dialogOpen}>
|
||||||
<Dialog.Content class="w-fit min-w-[500px] max-w-[90vw] max-h-[80vh] overflow-hidden flex flex-col">
|
<Drawer.Content class="sm:max-w-4xl">
|
||||||
<Dialog.Header>
|
<Drawer.Header>
|
||||||
<Dialog.Title class="flex items-center gap-2">
|
<Drawer.Title class="flex items-center gap-2">
|
||||||
<Calendar class="h-5 w-5" />
|
<Calendar class="h-5 w-5" />
|
||||||
{#if selectedDate}
|
{#if selectedDate}
|
||||||
{selectedDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' })}
|
{selectedDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' })}
|
||||||
{/if}
|
{/if}
|
||||||
</Dialog.Title>
|
</Drawer.Title>
|
||||||
<Dialog.Description>
|
<Drawer.Description>
|
||||||
{#if selectedDateStats}
|
{#if selectedDateStats}
|
||||||
{@const stats = selectedDateStats}
|
{@const stats = selectedDateStats}
|
||||||
共 {stats!.count} 笔支出,合计 ¥{stats!.total.toFixed(2)}
|
共 {stats!.count} 笔支出,合计 ¥{stats!.total.toFixed(2)}
|
||||||
{/if}
|
{/if}
|
||||||
</Dialog.Description>
|
</Drawer.Description>
|
||||||
</Dialog.Header>
|
</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}
|
{#if selectedDateStats}
|
||||||
{@const stats = selectedDateStats}
|
{@const stats = selectedDateStats}
|
||||||
|
|
||||||
@@ -886,5 +892,5 @@
|
|||||||
<p class="text-center text-muted-foreground py-8">暂无数据</p>
|
<p class="text-center text-muted-foreground py-8">暂无数据</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Content>
|
</Drawer.Content>
|
||||||
</Dialog.Root>
|
</Drawer.Root>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Card from '$lib/components/ui/card';
|
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 * as Select from '$lib/components/ui/select';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
@@ -134,10 +134,10 @@
|
|||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
<!-- 账单详情弹窗 -->
|
<!-- 账单详情弹窗 -->
|
||||||
<Dialog.Root bind:open={dialogOpen}>
|
<Drawer.Root bind:open={dialogOpen}>
|
||||||
<Dialog.Content class="sm:max-w-[450px]">
|
<Drawer.Content class="sm:max-w-[450px]">
|
||||||
<Dialog.Header>
|
<Drawer.Header>
|
||||||
<Dialog.Title class="flex items-center gap-2">
|
<Drawer.Title class="flex items-center gap-2">
|
||||||
<Receipt class="h-5 w-5" />
|
<Receipt class="h-5 w-5" />
|
||||||
{isEditing ? '编辑账单' : '账单详情'}
|
{isEditing ? '编辑账单' : '账单详情'}
|
||||||
{#if selectedRank <= 3 && !isEditing}
|
{#if selectedRank <= 3 && !isEditing}
|
||||||
@@ -149,16 +149,16 @@
|
|||||||
Top {selectedRank}
|
Top {selectedRank}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</Dialog.Title>
|
</Drawer.Title>
|
||||||
<Dialog.Description>
|
<Drawer.Description>
|
||||||
{isEditing ? '修改这笔支出的信息' : '查看这笔支出的完整信息'}
|
{isEditing ? '修改这笔支出的信息' : '查看这笔支出的完整信息'}
|
||||||
</Dialog.Description>
|
</Drawer.Description>
|
||||||
</Dialog.Header>
|
</Drawer.Header>
|
||||||
|
|
||||||
{#if selectedRecord}
|
{#if selectedRecord}
|
||||||
{#if isEditing}
|
{#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">
|
<div class="space-y-2">
|
||||||
<Label for="amount">金额</Label>
|
<Label for="amount">金额</Label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
@@ -206,7 +206,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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">
|
<div class="text-center py-4 bg-red-50 dark:bg-red-950/30 rounded-lg">
|
||||||
<p class="text-sm text-muted-foreground mb-1">支出金额</p>
|
<p class="text-sm text-muted-foreground mb-1">支出金额</p>
|
||||||
@@ -265,7 +265,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Dialog.Footer class="flex gap-2">
|
<Drawer.Footer class="flex gap-2">
|
||||||
{#if isEditing}
|
{#if isEditing}
|
||||||
<Button variant="outline" onclick={cancelEdit}>
|
<Button variant="outline" onclick={cancelEdit}>
|
||||||
<X class="h-4 w-4 mr-2" />
|
<X class="h-4 w-4 mr-2" />
|
||||||
@@ -284,6 +284,6 @@
|
|||||||
编辑
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</Dialog.Footer>
|
</Drawer.Footer>
|
||||||
</Dialog.Content>
|
</Drawer.Content>
|
||||||
</Dialog.Root>
|
</Drawer.Root>
|
||||||
|
|||||||
76
web/src/lib/components/ui/calendar/calendar-caption.svelte
Normal file
76
web/src/lib/components/ui/calendar/calendar-caption.svelte
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ComponentProps } from "svelte";
|
||||||
|
import type Calendar from "./calendar.svelte";
|
||||||
|
import CalendarMonthSelect from "./calendar-month-select.svelte";
|
||||||
|
import CalendarYearSelect from "./calendar-year-select.svelte";
|
||||||
|
import { DateFormatter, getLocalTimeZone, type DateValue } from "@internationalized/date";
|
||||||
|
|
||||||
|
let {
|
||||||
|
captionLayout,
|
||||||
|
months,
|
||||||
|
monthFormat,
|
||||||
|
years,
|
||||||
|
yearFormat,
|
||||||
|
month,
|
||||||
|
locale,
|
||||||
|
placeholder = $bindable(),
|
||||||
|
monthIndex = 0,
|
||||||
|
}: {
|
||||||
|
captionLayout: ComponentProps<typeof Calendar>["captionLayout"];
|
||||||
|
months: ComponentProps<typeof CalendarMonthSelect>["months"];
|
||||||
|
monthFormat: ComponentProps<typeof CalendarMonthSelect>["monthFormat"];
|
||||||
|
years: ComponentProps<typeof CalendarYearSelect>["years"];
|
||||||
|
yearFormat: ComponentProps<typeof CalendarYearSelect>["yearFormat"];
|
||||||
|
month: DateValue;
|
||||||
|
placeholder: DateValue | undefined;
|
||||||
|
locale: string;
|
||||||
|
monthIndex: number;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function formatYear(date: DateValue) {
|
||||||
|
const dateObj = date.toDate(getLocalTimeZone());
|
||||||
|
if (typeof yearFormat === "function") return yearFormat(dateObj.getFullYear());
|
||||||
|
return new DateFormatter(locale, { year: yearFormat }).format(dateObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMonth(date: DateValue) {
|
||||||
|
const dateObj = date.toDate(getLocalTimeZone());
|
||||||
|
if (typeof monthFormat === "function") return monthFormat(dateObj.getMonth() + 1);
|
||||||
|
return new DateFormatter(locale, { month: monthFormat }).format(dateObj);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet MonthSelect()}
|
||||||
|
<CalendarMonthSelect
|
||||||
|
{months}
|
||||||
|
{monthFormat}
|
||||||
|
value={month.month}
|
||||||
|
onchange={(e) => {
|
||||||
|
if (!placeholder) return;
|
||||||
|
const v = Number.parseInt(e.currentTarget.value);
|
||||||
|
const newPlaceholder = placeholder.set({ month: v });
|
||||||
|
placeholder = newPlaceholder.subtract({ months: monthIndex });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet YearSelect()}
|
||||||
|
<CalendarYearSelect {years} {yearFormat} value={month.year} />
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if captionLayout === "dropdown"}
|
||||||
|
{@render MonthSelect()}
|
||||||
|
{@render YearSelect()}
|
||||||
|
{:else if captionLayout === "dropdown-months"}
|
||||||
|
{@render MonthSelect()}
|
||||||
|
{#if placeholder}
|
||||||
|
{formatYear(placeholder)}
|
||||||
|
{/if}
|
||||||
|
{:else if captionLayout === "dropdown-years"}
|
||||||
|
{#if placeholder}
|
||||||
|
{formatMonth(placeholder)}
|
||||||
|
{/if}
|
||||||
|
{@render YearSelect()}
|
||||||
|
{:else}
|
||||||
|
{formatMonth(month)} {formatYear(month)}
|
||||||
|
{/if}
|
||||||
19
web/src/lib/components/ui/calendar/calendar-cell.svelte
Normal file
19
web/src/lib/components/ui/calendar/calendar-cell.svelte
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: CalendarPrimitive.CellProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CalendarPrimitive.Cell
|
||||||
|
bind:ref
|
||||||
|
class={cn(
|
||||||
|
"relative size-(--cell-size) p-0 text-center text-sm focus-within:z-20 [&:first-child[data-selected]_[data-bits-day]]:rounded-s-md [&:last-child[data-selected]_[data-bits-day]]:rounded-e-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
35
web/src/lib/components/ui/calendar/calendar-day.svelte
Normal file
35
web/src/lib/components/ui/calendar/calendar-day.svelte
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: CalendarPrimitive.DayProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CalendarPrimitive.Day
|
||||||
|
bind:ref
|
||||||
|
class={cn(
|
||||||
|
buttonVariants({ variant: "ghost" }),
|
||||||
|
"flex size-(--cell-size) flex-col items-center justify-center gap-1 p-0 leading-none font-normal whitespace-nowrap select-none",
|
||||||
|
"[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground",
|
||||||
|
"data-[selected]:bg-primary dark:data-[selected]:hover:bg-accent/50 data-[selected]:text-primary-foreground",
|
||||||
|
// Outside months
|
||||||
|
"[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground",
|
||||||
|
// Disabled
|
||||||
|
"data-[disabled]:text-muted-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
// Unavailable
|
||||||
|
"data-[unavailable]:text-muted-foreground data-[unavailable]:line-through",
|
||||||
|
// hover
|
||||||
|
"dark:hover:text-accent-foreground",
|
||||||
|
// focus
|
||||||
|
"focus:border-ring focus:ring-ring/50 focus:relative",
|
||||||
|
// inner spans
|
||||||
|
"[&>span]:text-xs [&>span]:opacity-70",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
12
web/src/lib/components/ui/calendar/calendar-grid-body.svelte
Normal file
12
web/src/lib/components/ui/calendar/calendar-grid-body.svelte
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: CalendarPrimitive.GridBodyProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CalendarPrimitive.GridBody bind:ref class={cn(className)} {...restProps} />
|
||||||
12
web/src/lib/components/ui/calendar/calendar-grid-head.svelte
Normal file
12
web/src/lib/components/ui/calendar/calendar-grid-head.svelte
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: CalendarPrimitive.GridHeadProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CalendarPrimitive.GridHead bind:ref class={cn(className)} {...restProps} />
|
||||||
12
web/src/lib/components/ui/calendar/calendar-grid-row.svelte
Normal file
12
web/src/lib/components/ui/calendar/calendar-grid-row.svelte
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: CalendarPrimitive.GridRowProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CalendarPrimitive.GridRow bind:ref class={cn("flex", className)} {...restProps} />
|
||||||
16
web/src/lib/components/ui/calendar/calendar-grid.svelte
Normal file
16
web/src/lib/components/ui/calendar/calendar-grid.svelte
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: CalendarPrimitive.GridProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CalendarPrimitive.Grid
|
||||||
|
bind:ref
|
||||||
|
class={cn("mt-4 flex w-full border-collapse flex-col gap-1", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
19
web/src/lib/components/ui/calendar/calendar-head-cell.svelte
Normal file
19
web/src/lib/components/ui/calendar/calendar-head-cell.svelte
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: CalendarPrimitive.HeadCellProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CalendarPrimitive.HeadCell
|
||||||
|
bind:ref
|
||||||
|
class={cn(
|
||||||
|
"text-muted-foreground w-(--cell-size) rounded-md text-[0.8rem] font-normal",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
19
web/src/lib/components/ui/calendar/calendar-header.svelte
Normal file
19
web/src/lib/components/ui/calendar/calendar-header.svelte
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: CalendarPrimitive.HeaderProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CalendarPrimitive.Header
|
||||||
|
bind:ref
|
||||||
|
class={cn(
|
||||||
|
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
16
web/src/lib/components/ui/calendar/calendar-heading.svelte
Normal file
16
web/src/lib/components/ui/calendar/calendar-heading.svelte
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: CalendarPrimitive.HeadingProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CalendarPrimitive.Heading
|
||||||
|
bind:ref
|
||||||
|
class={cn("px-(--cell-size) text-sm font-medium", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
value,
|
||||||
|
onchange,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<CalendarPrimitive.MonthSelectProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class={cn(
|
||||||
|
"has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CalendarPrimitive.MonthSelect bind:ref class="absolute inset-0 opacity-0" {...restProps}>
|
||||||
|
{#snippet child({ props, monthItems, selectedMonthItem })}
|
||||||
|
<select {...props} {value} {onchange}>
|
||||||
|
{#each monthItems as monthItem (monthItem.value)}
|
||||||
|
<option
|
||||||
|
value={monthItem.value}
|
||||||
|
selected={value !== undefined
|
||||||
|
? monthItem.value === value
|
||||||
|
: monthItem.value === selectedMonthItem.value}
|
||||||
|
>
|
||||||
|
{monthItem.label}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<span
|
||||||
|
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label}
|
||||||
|
<ChevronDownIcon class="size-4" />
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
</CalendarPrimitive.MonthSelect>
|
||||||
|
</span>
|
||||||
15
web/src/lib/components/ui/calendar/calendar-month.svelte
Normal file
15
web/src/lib/components/ui/calendar/calendar-month.svelte
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { type WithElementRef, cn } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div {...restProps} bind:this={ref} class={cn("flex flex-col", className)}>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
19
web/src/lib/components/ui/calendar/calendar-months.svelte
Normal file
19
web/src/lib/components/ui/calendar/calendar-months.svelte
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
class={cn("relative flex flex-col gap-4 md:flex-row", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
19
web/src/lib/components/ui/calendar/calendar-nav.svelte
Normal file
19
web/src/lib/components/ui/calendar/calendar-nav.svelte
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
{...restProps}
|
||||||
|
bind:this={ref}
|
||||||
|
class={cn("absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", className)}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</nav>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
||||||
|
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
variant = "ghost",
|
||||||
|
...restProps
|
||||||
|
}: CalendarPrimitive.NextButtonProps & {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet Fallback()}
|
||||||
|
<ChevronRightIcon class="size-4" />
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<CalendarPrimitive.NextButton
|
||||||
|
bind:ref
|
||||||
|
class={cn(
|
||||||
|
buttonVariants({ variant }),
|
||||||
|
"size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
children={children || Fallback}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
|
||||||
|
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
variant = "ghost",
|
||||||
|
...restProps
|
||||||
|
}: CalendarPrimitive.PrevButtonProps & {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet Fallback()}
|
||||||
|
<ChevronLeftIcon class="size-4" />
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<CalendarPrimitive.PrevButton
|
||||||
|
bind:ref
|
||||||
|
class={cn(
|
||||||
|
buttonVariants({ variant }),
|
||||||
|
"size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
children={children || Fallback}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
value,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<CalendarPrimitive.YearSelectProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class={cn(
|
||||||
|
"has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CalendarPrimitive.YearSelect bind:ref class="absolute inset-0 opacity-0" {...restProps}>
|
||||||
|
{#snippet child({ props, yearItems, selectedYearItem })}
|
||||||
|
<select {...props} {value}>
|
||||||
|
{#each yearItems as yearItem (yearItem.value)}
|
||||||
|
<option
|
||||||
|
value={yearItem.value}
|
||||||
|
selected={value !== undefined
|
||||||
|
? yearItem.value === value
|
||||||
|
: yearItem.value === selectedYearItem.value}
|
||||||
|
>
|
||||||
|
{yearItem.label}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<span
|
||||||
|
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{yearItems.find((item) => item.value === value)?.label || selectedYearItem.label}
|
||||||
|
<ChevronDownIcon class="size-4" />
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
</CalendarPrimitive.YearSelect>
|
||||||
|
</span>
|
||||||
115
web/src/lib/components/ui/calendar/calendar.svelte
Normal file
115
web/src/lib/components/ui/calendar/calendar.svelte
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import * as Calendar from "./index.js";
|
||||||
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
import type { ButtonVariant } from "../button/button.svelte";
|
||||||
|
import { isEqualMonth, type DateValue } from "@internationalized/date";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
value = $bindable(),
|
||||||
|
placeholder = $bindable(),
|
||||||
|
class: className,
|
||||||
|
weekdayFormat = "short",
|
||||||
|
buttonVariant = "ghost",
|
||||||
|
captionLayout = "label",
|
||||||
|
locale = "en-US",
|
||||||
|
months: monthsProp,
|
||||||
|
years,
|
||||||
|
monthFormat: monthFormatProp,
|
||||||
|
yearFormat = "numeric",
|
||||||
|
day,
|
||||||
|
disableDaysOutsideMonth = false,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<CalendarPrimitive.RootProps> & {
|
||||||
|
buttonVariant?: ButtonVariant;
|
||||||
|
captionLayout?: "dropdown" | "dropdown-months" | "dropdown-years" | "label";
|
||||||
|
months?: CalendarPrimitive.MonthSelectProps["months"];
|
||||||
|
years?: CalendarPrimitive.YearSelectProps["years"];
|
||||||
|
monthFormat?: CalendarPrimitive.MonthSelectProps["monthFormat"];
|
||||||
|
yearFormat?: CalendarPrimitive.YearSelectProps["yearFormat"];
|
||||||
|
day?: Snippet<[{ day: DateValue; outsideMonth: boolean }]>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const monthFormat = $derived.by(() => {
|
||||||
|
if (monthFormatProp) return monthFormatProp;
|
||||||
|
if (captionLayout.startsWith("dropdown")) return "short";
|
||||||
|
return "long";
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Discriminated Unions + Destructing (required for bindable) do not
|
||||||
|
get along, so we shut typescript up by casting `value` to `never`.
|
||||||
|
-->
|
||||||
|
<CalendarPrimitive.Root
|
||||||
|
bind:value={value as never}
|
||||||
|
bind:ref
|
||||||
|
bind:placeholder
|
||||||
|
{weekdayFormat}
|
||||||
|
{disableDaysOutsideMonth}
|
||||||
|
class={cn(
|
||||||
|
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{locale}
|
||||||
|
{monthFormat}
|
||||||
|
{yearFormat}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#snippet children({ months, weekdays })}
|
||||||
|
<Calendar.Months>
|
||||||
|
<Calendar.Nav>
|
||||||
|
<Calendar.PrevButton variant={buttonVariant} />
|
||||||
|
<Calendar.NextButton variant={buttonVariant} />
|
||||||
|
</Calendar.Nav>
|
||||||
|
{#each months as month, monthIndex (month)}
|
||||||
|
<Calendar.Month>
|
||||||
|
<Calendar.Header>
|
||||||
|
<Calendar.Caption
|
||||||
|
{captionLayout}
|
||||||
|
months={monthsProp}
|
||||||
|
{monthFormat}
|
||||||
|
{years}
|
||||||
|
{yearFormat}
|
||||||
|
month={month.value}
|
||||||
|
bind:placeholder
|
||||||
|
{locale}
|
||||||
|
{monthIndex}
|
||||||
|
/>
|
||||||
|
</Calendar.Header>
|
||||||
|
<Calendar.Grid>
|
||||||
|
<Calendar.GridHead>
|
||||||
|
<Calendar.GridRow class="select-none">
|
||||||
|
{#each weekdays as weekday (weekday)}
|
||||||
|
<Calendar.HeadCell>
|
||||||
|
{weekday.slice(0, 2)}
|
||||||
|
</Calendar.HeadCell>
|
||||||
|
{/each}
|
||||||
|
</Calendar.GridRow>
|
||||||
|
</Calendar.GridHead>
|
||||||
|
<Calendar.GridBody>
|
||||||
|
{#each month.weeks as weekDates (weekDates)}
|
||||||
|
<Calendar.GridRow class="mt-2 w-full">
|
||||||
|
{#each weekDates as date (date)}
|
||||||
|
<Calendar.Cell {date} month={month.value}>
|
||||||
|
{#if day}
|
||||||
|
{@render day({
|
||||||
|
day: date,
|
||||||
|
outsideMonth: !isEqualMonth(date, month.value),
|
||||||
|
})}
|
||||||
|
{:else}
|
||||||
|
<Calendar.Day />
|
||||||
|
{/if}
|
||||||
|
</Calendar.Cell>
|
||||||
|
{/each}
|
||||||
|
</Calendar.GridRow>
|
||||||
|
{/each}
|
||||||
|
</Calendar.GridBody>
|
||||||
|
</Calendar.Grid>
|
||||||
|
</Calendar.Month>
|
||||||
|
{/each}
|
||||||
|
</Calendar.Months>
|
||||||
|
{/snippet}
|
||||||
|
</CalendarPrimitive.Root>
|
||||||
40
web/src/lib/components/ui/calendar/index.ts
Normal file
40
web/src/lib/components/ui/calendar/index.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import Root from "./calendar.svelte";
|
||||||
|
import Cell from "./calendar-cell.svelte";
|
||||||
|
import Day from "./calendar-day.svelte";
|
||||||
|
import Grid from "./calendar-grid.svelte";
|
||||||
|
import Header from "./calendar-header.svelte";
|
||||||
|
import Months from "./calendar-months.svelte";
|
||||||
|
import GridRow from "./calendar-grid-row.svelte";
|
||||||
|
import Heading from "./calendar-heading.svelte";
|
||||||
|
import GridBody from "./calendar-grid-body.svelte";
|
||||||
|
import GridHead from "./calendar-grid-head.svelte";
|
||||||
|
import HeadCell from "./calendar-head-cell.svelte";
|
||||||
|
import NextButton from "./calendar-next-button.svelte";
|
||||||
|
import PrevButton from "./calendar-prev-button.svelte";
|
||||||
|
import MonthSelect from "./calendar-month-select.svelte";
|
||||||
|
import YearSelect from "./calendar-year-select.svelte";
|
||||||
|
import Month from "./calendar-month.svelte";
|
||||||
|
import Nav from "./calendar-nav.svelte";
|
||||||
|
import Caption from "./calendar-caption.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Day,
|
||||||
|
Cell,
|
||||||
|
Grid,
|
||||||
|
Header,
|
||||||
|
Months,
|
||||||
|
GridRow,
|
||||||
|
Heading,
|
||||||
|
GridBody,
|
||||||
|
GridHead,
|
||||||
|
HeadCell,
|
||||||
|
NextButton,
|
||||||
|
PrevButton,
|
||||||
|
Nav,
|
||||||
|
Month,
|
||||||
|
YearSelect,
|
||||||
|
MonthSelect,
|
||||||
|
Caption,
|
||||||
|
//
|
||||||
|
Root as Calendar,
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
3
web/src/lib/components/ui/date-range-picker/index.ts
Normal file
3
web/src/lib/components/ui/date-range-picker/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import DateRangePicker from "./date-range-picker.svelte";
|
||||||
|
|
||||||
|
export { DateRangePicker };
|
||||||
25
web/src/lib/components/ui/drawer/drawer-close.svelte
Normal file
25
web/src/lib/components/ui/drawer/drawer-close.svelte
Normal 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}
|
||||||
33
web/src/lib/components/ui/drawer/drawer-content.svelte
Normal file
33
web/src/lib/components/ui/drawer/drawer-content.svelte
Normal 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}
|
||||||
25
web/src/lib/components/ui/drawer/drawer-description.svelte
Normal file
25
web/src/lib/components/ui/drawer/drawer-description.svelte
Normal 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}
|
||||||
26
web/src/lib/components/ui/drawer/drawer-footer.svelte
Normal file
26
web/src/lib/components/ui/drawer/drawer-footer.svelte
Normal 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}
|
||||||
26
web/src/lib/components/ui/drawer/drawer-header.svelte
Normal file
26
web/src/lib/components/ui/drawer/drawer-header.svelte
Normal 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}
|
||||||
25
web/src/lib/components/ui/drawer/drawer-title.svelte
Normal file
25
web/src/lib/components/ui/drawer/drawer-title.svelte
Normal 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}
|
||||||
26
web/src/lib/components/ui/drawer/drawer.svelte
Normal file
26
web/src/lib/components/ui/drawer/drawer.svelte
Normal 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}
|
||||||
25
web/src/lib/components/ui/drawer/index.ts
Normal file
25
web/src/lib/components/ui/drawer/index.ts
Normal 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
|
||||||
|
};
|
||||||
19
web/src/lib/components/ui/popover/index.ts
Normal file
19
web/src/lib/components/ui/popover/index.ts
Normal 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,
|
||||||
|
};
|
||||||
7
web/src/lib/components/ui/popover/popover-close.svelte
Normal file
7
web/src/lib/components/ui/popover/popover-close.svelte
Normal 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} />
|
||||||
31
web/src/lib/components/ui/popover/popover-content.svelte
Normal file
31
web/src/lib/components/ui/popover/popover-content.svelte
Normal 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>
|
||||||
7
web/src/lib/components/ui/popover/popover-portal.svelte
Normal file
7
web/src/lib/components/ui/popover/popover-portal.svelte
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PopoverPrimitive.Portal {...restProps} />
|
||||||
17
web/src/lib/components/ui/popover/popover-trigger.svelte
Normal file
17
web/src/lib/components/ui/popover/popover-trigger.svelte
Normal 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}
|
||||||
|
/>
|
||||||
7
web/src/lib/components/ui/popover/popover.svelte
Normal file
7
web/src/lib/components/ui/popover/popover.svelte
Normal 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} />
|
||||||
40
web/src/lib/components/ui/range-calendar/index.ts
Normal file
40
web/src/lib/components/ui/range-calendar/index.ts
Normal 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,
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
@@ -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} />
|
||||||
@@ -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} />
|
||||||
@@ -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} />
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
{...restProps}
|
||||||
|
bind:this={ref}
|
||||||
|
class={cn("absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", className)}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</nav>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { 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}
|
||||||
|
/>
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
@@ -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>
|
||||||
112
web/src/lib/components/ui/range-calendar/range-calendar.svelte
Normal file
112
web/src/lib/components/ui/range-calendar/range-calendar.svelte
Normal 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>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { checkHealth } from '$lib/api';
|
||||||
import * as Sidebar from '$lib/components/ui/sidebar';
|
import * as Sidebar from '$lib/components/ui/sidebar';
|
||||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||||
import * as Avatar from '$lib/components/ui/avatar';
|
import * as Avatar from '$lib/components/ui/avatar';
|
||||||
@@ -14,7 +15,6 @@
|
|||||||
import BarChart3 from '@lucide/svelte/icons/bar-chart-3';
|
import BarChart3 from '@lucide/svelte/icons/bar-chart-3';
|
||||||
import Settings from '@lucide/svelte/icons/settings';
|
import Settings from '@lucide/svelte/icons/settings';
|
||||||
import HelpCircle from '@lucide/svelte/icons/help-circle';
|
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 ChevronsUpDown from '@lucide/svelte/icons/chevrons-up-down';
|
||||||
import Wallet from '@lucide/svelte/icons/wallet';
|
import Wallet from '@lucide/svelte/icons/wallet';
|
||||||
import LogOut from '@lucide/svelte/icons/log-out';
|
import LogOut from '@lucide/svelte/icons/log-out';
|
||||||
@@ -35,16 +35,32 @@
|
|||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
let themeMode = $state<ThemeMode>('system');
|
let themeMode = $state<ThemeMode>('system');
|
||||||
|
let serverOnline = $state(true);
|
||||||
|
let checkingHealth = $state(true);
|
||||||
|
|
||||||
|
async function checkServerHealth() {
|
||||||
|
checkingHealth = true;
|
||||||
|
serverOnline = await checkHealth();
|
||||||
|
checkingHealth = false;
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
themeMode = loadThemeFromStorage();
|
themeMode = loadThemeFromStorage();
|
||||||
applyThemeToDocument(themeMode);
|
applyThemeToDocument(themeMode);
|
||||||
|
|
||||||
|
// 检查服务器状态
|
||||||
|
checkServerHealth();
|
||||||
|
// 每 30 秒检查一次
|
||||||
|
const healthInterval = setInterval(checkServerHealth, 30000);
|
||||||
|
|
||||||
// 监听系统主题变化
|
// 监听系统主题变化
|
||||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
const handleChange = () => applyThemeToDocument(themeMode);
|
const handleChange = () => applyThemeToDocument(themeMode);
|
||||||
mediaQuery.addEventListener('change', handleChange);
|
mediaQuery.addEventListener('change', handleChange);
|
||||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
return () => {
|
||||||
|
mediaQuery.removeEventListener('change', handleChange);
|
||||||
|
clearInterval(healthInterval);
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
function cycleTheme() {
|
function cycleTheme() {
|
||||||
@@ -78,6 +94,18 @@
|
|||||||
if (href === '/') return pathname === '/';
|
if (href === '/') return pathname === '/';
|
||||||
return pathname.startsWith(href);
|
return pathname.startsWith(href);
|
||||||
}
|
}
|
||||||
|
// 根据路径获取页面标题
|
||||||
|
function getPageTitle(pathname: string): string {
|
||||||
|
const titles: Record<string, string> = {
|
||||||
|
'/': '上传账单',
|
||||||
|
'/review': '智能复核',
|
||||||
|
'/bills': '账单管理',
|
||||||
|
'/analysis': '数据分析',
|
||||||
|
'/settings': '设置',
|
||||||
|
'/help': '帮助'
|
||||||
|
};
|
||||||
|
return titles[pathname] || 'BillAI';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Sidebar.Provider>
|
<Sidebar.Provider>
|
||||||
@@ -237,18 +265,32 @@
|
|||||||
<header class="flex h-14 shrink-0 items-center gap-2 border-b px-4">
|
<header class="flex h-14 shrink-0 items-center gap-2 border-b px-4">
|
||||||
<Sidebar.Trigger class="-ml-1" />
|
<Sidebar.Trigger class="-ml-1" />
|
||||||
<Separator orientation="vertical" class="mr-2 h-4" />
|
<Separator orientation="vertical" class="mr-2 h-4" />
|
||||||
<div class="flex items-center gap-2">
|
<h1 class="text-lg font-semibold">{getPageTitle($page.url.pathname)}</h1>
|
||||||
<Search class="size-4 text-muted-foreground" />
|
<div class="flex-1" />
|
||||||
<span class="text-sm text-muted-foreground">搜索...</span>
|
<div class="flex items-center gap-3">
|
||||||
</div>
|
<button
|
||||||
<div class="ml-auto flex items-center gap-2">
|
class="flex items-center gap-1.5 text-sm hover:opacity-80 transition-opacity"
|
||||||
<div class="flex items-center gap-1.5 text-sm">
|
onclick={checkServerHealth}
|
||||||
<span class="relative flex h-2 w-2">
|
title="点击刷新状态"
|
||||||
<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>
|
{#if checkingHealth}
|
||||||
</span>
|
<span class="relative flex h-2 w-2">
|
||||||
<span class="text-muted-foreground">服务运行中</span>
|
<span class="relative inline-flex rounded-full h-2 w-2 bg-gray-400 animate-pulse"></span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<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 * as Card from '$lib/components/ui/card';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
|
|
||||||
let isDragOver = $state(false);
|
let isDragOver = $state(false);
|
||||||
let selectedFile: File | null = $state(null);
|
let selectedFile: File | null = $state(null);
|
||||||
|
let selectedType: BillType = $state('alipay');
|
||||||
let isUploading = $state(false);
|
let isUploading = $state(false);
|
||||||
let uploadResult: UploadResponse | null = $state(null);
|
let uploadResult: UploadResponse | null = $state(null);
|
||||||
let errorMessage = $state('');
|
let errorMessage = $state('');
|
||||||
@@ -86,6 +87,14 @@
|
|||||||
selectedFile = file;
|
selectedFile = file;
|
||||||
errorMessage = '';
|
errorMessage = '';
|
||||||
uploadResult = null;
|
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() {
|
function clearFile() {
|
||||||
@@ -101,7 +110,7 @@
|
|||||||
errorMessage = '';
|
errorMessage = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await uploadBill(selectedFile);
|
const result = await uploadBill(selectedFile, selectedType);
|
||||||
if (result.result) {
|
if (result.result) {
|
||||||
uploadResult = result;
|
uploadResult = result;
|
||||||
} else {
|
} else {
|
||||||
@@ -135,7 +144,7 @@
|
|||||||
<!-- 统计卡片 -->
|
<!-- 统计卡片 -->
|
||||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
{#each stats as stat}
|
{#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.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>
|
<Card.Title class="text-sm font-medium">{stat.title}</Card.Title>
|
||||||
{#if stat.trend === 'up'}
|
{#if stat.trend === 'up'}
|
||||||
@@ -226,6 +235,27 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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
|
<Button
|
||||||
class="w-full"
|
class="w-full"
|
||||||
@@ -256,7 +286,7 @@
|
|||||||
<CheckCircle class="h-5 w-5 text-green-600 dark:text-green-400" />
|
<CheckCircle class="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium text-green-800 dark:text-green-200">处理成功</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -267,6 +297,14 @@
|
|||||||
{uploadResult.data?.bill_type === 'alipay' ? '支付宝' : '微信'}
|
{uploadResult.data?.bill_type === 'alipay' ? '支付宝' : '微信'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</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">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-sm text-muted-foreground">输出文件</span>
|
<span class="text-sm text-muted-foreground">输出文件</span>
|
||||||
<span class="text-sm font-medium">{uploadResult.data?.file_name}</span>
|
<span class="text-sm font-medium">{uploadResult.data?.file_name}</span>
|
||||||
@@ -275,7 +313,7 @@
|
|||||||
|
|
||||||
<div class="flex gap-3 pt-2">
|
<div class="flex gap-3 pt-2">
|
||||||
<a
|
<a
|
||||||
href={`http://localhost:8080${uploadResult.data?.file_url}`}
|
href={uploadResult.data?.file_url || '#'}
|
||||||
download
|
download
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
<script lang="ts">
|
<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 { Button } from '$lib/components/ui/button';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
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 BarChart3 from '@lucide/svelte/icons/bar-chart-3';
|
||||||
import Loader2 from '@lucide/svelte/icons/loader-2';
|
import Loader2 from '@lucide/svelte/icons/loader-2';
|
||||||
import AlertCircle from '@lucide/svelte/icons/alert-circle';
|
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 {
|
import {
|
||||||
@@ -14,7 +19,6 @@
|
|||||||
CategoryRanking,
|
CategoryRanking,
|
||||||
MonthlyTrend,
|
MonthlyTrend,
|
||||||
TopExpenses,
|
TopExpenses,
|
||||||
EmptyState
|
|
||||||
} from '$lib/components/analysis';
|
} from '$lib/components/analysis';
|
||||||
|
|
||||||
// 数据处理服务
|
// 数据处理服务
|
||||||
@@ -32,61 +36,119 @@
|
|||||||
// 分类数据
|
// 分类数据
|
||||||
import { categories as allCategories } from '$lib/data/categories';
|
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 isLoading = $state(false);
|
||||||
let errorMessage = $state('');
|
let errorMessage = $state('');
|
||||||
let records: BillRecord[] = $state([]);
|
let records: CleanedBill[] = $state([]);
|
||||||
let isDemo = $state(false);
|
let isDemo = $state(false);
|
||||||
|
let serverAvailable = $state(true);
|
||||||
|
|
||||||
// 派生数据
|
// 时间范围筛选 - 初始化为默认值
|
||||||
let categoryStats = $derived(calculateCategoryStats(records));
|
let startDate: string = $state(defaultDates.startDate);
|
||||||
let monthlyStats = $derived(calculateMonthlyStats(records));
|
let endDate: string = $state(defaultDates.endDate);
|
||||||
let dailyExpenseData = $derived(calculateDailyExpenseData(records));
|
|
||||||
let totalStats = $derived(calculateTotalStats(records));
|
// 将 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 pieChartData = $derived(calculatePieChartData(categoryStats, totalStats.expense));
|
||||||
let topExpenses = $derived(getTopExpenses(records, 10));
|
let topExpenses = $derived(getTopExpenses(analysisRecords, 10));
|
||||||
|
|
||||||
// 分类列表按数据中出现次数排序(出现次数多的优先)
|
// 分类列表按数据中出现次数排序
|
||||||
let sortedCategories = $derived(() => {
|
let sortedCategories = $derived(() => {
|
||||||
// 统计每个分类的记录数量
|
|
||||||
const categoryCounts = new Map<string, number>();
|
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);
|
categoryCounts.set(record.category, (categoryCounts.get(record.category) || 0) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 对分类进行排序:先按数据中的数量降序,未出现的分类按原顺序排在后面
|
|
||||||
return [...allCategories].sort((a, b) => {
|
return [...allCategories].sort((a, b) => {
|
||||||
const countA = categoryCounts.get(a) || 0;
|
const countA = categoryCounts.get(a) || 0;
|
||||||
const countB = categoryCounts.get(b) || 0;
|
const countB = categoryCounts.get(b) || 0;
|
||||||
// 数量大的排前面
|
|
||||||
if (countA !== countB) return countB - countA;
|
if (countA !== countB) return countB - countA;
|
||||||
// 数量相同时保持原有顺序
|
|
||||||
return allCategories.indexOf(a) - allCategories.indexOf(b);
|
return allCategories.indexOf(a) - allCategories.indexOf(b);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
if (!fileName) return;
|
|
||||||
|
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
errorMessage = '';
|
errorMessage = '';
|
||||||
isDemo = false;
|
isDemo = false;
|
||||||
|
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
errorMessage = err instanceof Error ? err.message : '加载失败';
|
errorMessage = err instanceof Error ? err.message : '加载失败';
|
||||||
|
serverAvailable = false;
|
||||||
} finally {
|
} finally {
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 日期变化时重新加载
|
||||||
|
function onDateChange() {
|
||||||
|
if (!isDemo) {
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function loadDemoData() {
|
function loadDemoData() {
|
||||||
isDemo = true;
|
isDemo = true;
|
||||||
errorMessage = '';
|
errorMessage = '';
|
||||||
records = demoRecords;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 页面加载时自动获取数据
|
||||||
|
onMount(() => {
|
||||||
|
loadData();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -95,55 +157,52 @@
|
|||||||
|
|
||||||
<div class="space-y-6">
|
<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>
|
<div>
|
||||||
<h1 class="text-2xl font-bold tracking-tight">数据分析</h1>
|
<h1 class="text-2xl font-bold tracking-tight">数据分析</h1>
|
||||||
<p class="text-muted-foreground">可视化你的消费数据,洞察消费习惯</p>
|
<p class="text-muted-foreground">可视化你的消费数据,洞察消费习惯</p>
|
||||||
</div>
|
</div>
|
||||||
{#if isDemo}
|
<div class="flex items-center gap-3">
|
||||||
<Badge variant="secondary" class="text-xs">
|
{#if isDemo}
|
||||||
📊 示例数据
|
<Badge variant="secondary" class="text-xs">
|
||||||
</Badge>
|
📊 示例数据
|
||||||
{/if}
|
</Badge>
|
||||||
</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" />
|
|
||||||
分析中
|
|
||||||
{:else}
|
{:else}
|
||||||
<BarChart3 class="mr-2 h-4 w-4" />
|
<!-- 时间范围筛选 -->
|
||||||
分析
|
<DateRangePicker
|
||||||
|
bind:startDate
|
||||||
|
bind:endDate
|
||||||
|
onchange={onDateChange}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
<Button variant="outline" size="icon" onclick={loadData} disabled={isLoading} title="刷新数据">
|
||||||
|
<RefreshCw class="h-4 w-4 {isLoading ? 'animate-spin' : ''}" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</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">
|
<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" />
|
<AlertCircle class="h-4 w-4" />
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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">
|
<div class="grid gap-6 lg:grid-cols-2">
|
||||||
<!-- 分类支出排行 -->
|
<!-- 分类支出排行 -->
|
||||||
@@ -151,7 +210,7 @@
|
|||||||
{categoryStats}
|
{categoryStats}
|
||||||
{pieChartData}
|
{pieChartData}
|
||||||
totalExpense={totalStats.expense}
|
totalExpense={totalStats.expense}
|
||||||
bind:records
|
records={analysisRecords}
|
||||||
categories={sortedCategories()}
|
categories={sortedCategories()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -161,7 +220,30 @@
|
|||||||
|
|
||||||
<!-- Top 10 支出 -->
|
<!-- Top 10 支出 -->
|
||||||
<TopExpenses records={topExpenses} categories={sortedCategories()} />
|
<TopExpenses records={topExpenses} categories={sortedCategories()} />
|
||||||
{:else if !isLoading}
|
{:else}
|
||||||
<EmptyState onLoadDemo={loadDemoData} />
|
<!-- 空状态:服务器不可用或没有数据时显示示例按钮 -->
|
||||||
|
<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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
39
web/src/routes/api/[...path]/+server.ts
Normal file
39
web/src/routes/api/[...path]/+server.ts
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
<script lang="ts">
|
<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 * as Card from '$lib/components/ui/card';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import * as Table from '$lib/components/ui/table';
|
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 Loader2 from '@lucide/svelte/icons/loader-2';
|
||||||
import AlertCircle from '@lucide/svelte/icons/alert-circle';
|
import AlertCircle from '@lucide/svelte/icons/alert-circle';
|
||||||
import Search from '@lucide/svelte/icons/search';
|
import Search from '@lucide/svelte/icons/search';
|
||||||
@@ -15,56 +16,124 @@
|
|||||||
import TrendingUp from '@lucide/svelte/icons/trending-up';
|
import TrendingUp from '@lucide/svelte/icons/trending-up';
|
||||||
import FileText from '@lucide/svelte/icons/file-text';
|
import FileText from '@lucide/svelte/icons/file-text';
|
||||||
import Filter from '@lucide/svelte/icons/filter';
|
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 isLoading = $state(false);
|
||||||
let errorMessage = $state('');
|
let errorMessage = $state('');
|
||||||
let records: BillRecord[] = $state([]);
|
let records: CleanedBill[] = $state([]);
|
||||||
let filterCategory = $state('all');
|
|
||||||
let filterType = $state<'all' | '支出' | '收入'>('all');
|
// 分页
|
||||||
|
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('');
|
let searchText = $state('');
|
||||||
|
|
||||||
async function loadBillData() {
|
// 分类列表(硬编码常用分类)
|
||||||
if (!fileName) return;
|
const categories = [
|
||||||
|
'餐饮美食', '交通出行', '生活服务', '日用百货',
|
||||||
|
'服饰美容', '医疗健康', '通讯话费', '住房缴费',
|
||||||
|
'文化娱乐', '金融理财', '教育培训', '人情往来', '其他'
|
||||||
|
];
|
||||||
|
|
||||||
|
async function loadBills() {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
errorMessage = '';
|
errorMessage = '';
|
||||||
|
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
errorMessage = err instanceof Error ? err.message : '加载失败';
|
errorMessage = err instanceof Error ? err.message : '加载失败';
|
||||||
|
records = [];
|
||||||
} finally {
|
} finally {
|
||||||
isLoading = false;
|
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(
|
function applyFilters() {
|
||||||
records.filter(r => {
|
currentPage = 1;
|
||||||
if (filterCategory !== 'all' && r.category !== filterCategory) return false;
|
loadBills();
|
||||||
if (filterType !== 'all' && r.income_expense !== filterType) return false;
|
}
|
||||||
if (searchText) {
|
|
||||||
const text = searchText.toLowerCase();
|
// 清除筛选(恢复默认值)
|
||||||
return r.merchant.toLowerCase().includes(text) ||
|
function clearFilters() {
|
||||||
r.description.toLowerCase().includes(text);
|
filterCategory = '';
|
||||||
}
|
filterIncomeExpense = '';
|
||||||
return true;
|
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({
|
onMount(() => {
|
||||||
total: filteredRecords.length,
|
loadBills();
|
||||||
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),
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -74,31 +143,14 @@
|
|||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- 页面标题 -->
|
<!-- 页面标题 -->
|
||||||
<div>
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="text-2xl font-bold tracking-tight">账单列表</h1>
|
<div>
|
||||||
<p class="text-muted-foreground">查看和筛选已处理的账单记录</p>
|
<h1 class="text-2xl font-bold tracking-tight">账单列表</h1>
|
||||||
</div>
|
<p class="text-muted-foreground">查看和筛选已处理的账单记录</p>
|
||||||
|
|
||||||
<!-- 搜索栏 -->
|
|
||||||
<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>
|
</div>
|
||||||
<Button onclick={loadBillData} disabled={isLoading}>
|
<Button variant="outline" onclick={loadBills} disabled={isLoading}>
|
||||||
{#if isLoading}
|
<RefreshCw class="mr-2 h-4 w-4 {isLoading ? 'animate-spin' : ''}" />
|
||||||
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
刷新
|
||||||
加载中
|
|
||||||
{:else}
|
|
||||||
<FolderOpen class="mr-2 h-4 w-4" />
|
|
||||||
加载
|
|
||||||
{/if}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -110,166 +162,235 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if records.length > 0}
|
<!-- 统计概览 -->
|
||||||
<!-- 统计概览 -->
|
<div class="grid gap-4 md:grid-cols-3">
|
||||||
<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.Root>
|
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
<Card.Title class="text-sm font-medium">总交易笔数</Card.Title>
|
||||||
<Card.Title class="text-sm font-medium">交易笔数</Card.Title>
|
<Receipt class="h-4 w-4 text-muted-foreground" />
|
||||||
<Receipt class="h-4 w-4 text-muted-foreground" />
|
</Card.Header>
|
||||||
</Card.Header>
|
<Card.Content>
|
||||||
<Card.Content>
|
<div class="text-2xl font-bold">{totalRecords}</div>
|
||||||
<div class="text-2xl font-bold">{stats.total}</div>
|
<p class="text-xs text-muted-foreground">筛选条件下的账单总数</p>
|
||||||
<p class="text-xs text-muted-foreground">符合筛选条件的记录</p>
|
</Card.Content>
|
||||||
</Card.Content>
|
</Card.Root>
|
||||||
</Card.Root>
|
|
||||||
|
|
||||||
<Card.Root class="border-red-200 dark:border-red-900">
|
<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.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<Card.Title class="text-sm font-medium">总支出</Card.Title>
|
<Card.Title class="text-sm font-medium">总支出</Card.Title>
|
||||||
<TrendingDown class="h-4 w-4 text-red-500" />
|
<TrendingDown class="h-4 w-4 text-red-500" />
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
<div class="text-2xl font-bold font-mono text-red-600 dark:text-red-400">
|
<div class="text-2xl font-bold font-mono text-red-600 dark:text-red-400">
|
||||||
¥{stats.expense.toFixed(2)}
|
¥{totalExpense.toFixed(2)}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-muted-foreground">支出金额汇总</p>
|
<p class="text-xs text-muted-foreground">筛选条件下的支出汇总</p>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
<Card.Root class="border-green-200 dark:border-green-900">
|
<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.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<Card.Title class="text-sm font-medium">总收入</Card.Title>
|
<Card.Title class="text-sm font-medium">总收入</Card.Title>
|
||||||
<TrendingUp class="h-4 w-4 text-green-500" />
|
<TrendingUp class="h-4 w-4 text-green-500" />
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
<div class="text-2xl font-bold font-mono text-green-600 dark:text-green-400">
|
<div class="text-2xl font-bold font-mono text-green-600 dark:text-green-400">
|
||||||
¥{stats.income.toFixed(2)}
|
¥{totalIncome.toFixed(2)}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-muted-foreground">收入金额汇总</p>
|
<p class="text-xs text-muted-foreground">筛选条件下的收入汇总</p>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 筛选和表格 -->
|
<!-- 筛选和表格 -->
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
<Card.Title class="flex items-center gap-2">
|
<Card.Title class="flex items-center gap-2">
|
||||||
<Filter class="h-5 w-5" />
|
<Filter class="h-5 w-5" />
|
||||||
筛选条件
|
筛选条件
|
||||||
</Card.Title>
|
</Card.Title>
|
||||||
<div class="flex flex-wrap gap-4">
|
{#if filterCategory || filterIncomeExpense || filterBillType || startDate || endDate}
|
||||||
<div class="space-y-1.5">
|
<Button variant="ghost" size="sm" onclick={clearFilters}>
|
||||||
<Label class="text-xs">分类</Label>
|
清除筛选
|
||||||
<select
|
</Button>
|
||||||
class="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
{/if}
|
||||||
bind:value={filterCategory}
|
</div>
|
||||||
>
|
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||||
<option value="all">全部分类</option>
|
<div class="space-y-1.5 col-span-2 sm:col-span-2">
|
||||||
{#each categories as cat}
|
<Label class="text-xs">日期范围</Label>
|
||||||
<option value={cat}>{cat}</option>
|
<DateRangePicker
|
||||||
{/each}
|
{startDate}
|
||||||
</select>
|
{endDate}
|
||||||
</div>
|
onchange={(start, end) => {
|
||||||
<div class="space-y-1.5">
|
startDate = start;
|
||||||
<Label class="text-xs">类型</Label>
|
endDate = end;
|
||||||
<select
|
applyFilters();
|
||||||
class="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
}}
|
||||||
bind:value={filterType}
|
/>
|
||||||
>
|
</div>
|
||||||
<option value="all">全部</option>
|
<div class="space-y-1.5">
|
||||||
<option value="支出">支出</option>
|
<Label class="text-xs">分类</Label>
|
||||||
<option value="收入">收入</option>
|
<select
|
||||||
</select>
|
class="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
</div>
|
bind:value={filterCategory}
|
||||||
<div class="space-y-1.5 flex-1 min-w-[200px]">
|
onchange={applyFilters}
|
||||||
<Label class="text-xs">搜索</Label>
|
>
|
||||||
<div class="relative">
|
<option value="">全部</option>
|
||||||
<Search class="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
{#each categories as cat}
|
||||||
<Input
|
<option value={cat}>{cat}</option>
|
||||||
type="text"
|
{/each}
|
||||||
placeholder="商家/商品..."
|
</select>
|
||||||
class="pl-8"
|
</div>
|
||||||
bind:value={searchText}
|
<div class="space-y-1.5">
|
||||||
/>
|
<Label class="text-xs">收/支</Label>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card.Header>
|
</div>
|
||||||
<Card.Content>
|
</Card.Header>
|
||||||
{#if filteredRecords.length > 0}
|
<Card.Content>
|
||||||
<div class="rounded-md border">
|
{#if isLoading}
|
||||||
<Table.Root>
|
<div class="flex flex-col items-center justify-center py-12">
|
||||||
<Table.Header>
|
<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.Row>
|
||||||
<Table.Head class="w-[160px]">时间</Table.Head>
|
<Table.Cell class="text-muted-foreground text-sm">
|
||||||
<Table.Head>分类</Table.Head>
|
{record.time}
|
||||||
<Table.Head>交易对方</Table.Head>
|
</Table.Cell>
|
||||||
<Table.Head>商品说明</Table.Head>
|
<Table.Cell class="hidden xl:table-cell">
|
||||||
<Table.Head>收/支</Table.Head>
|
<Badge variant={record.bill_type === 'alipay' ? 'default' : 'secondary'}>
|
||||||
<Table.Head class="text-right">金额</Table.Head>
|
{record.bill_type === 'alipay' ? '支付宝' : '微信'}
|
||||||
<Table.Head>支付方式</Table.Head>
|
</Badge>
|
||||||
<Table.Head>状态</Table.Head>
|
</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.Row>
|
||||||
</Table.Header>
|
{/each}
|
||||||
<Table.Body>
|
</Table.Body>
|
||||||
{#each filteredRecords.slice(0, 100) as record}
|
</Table.Root>
|
||||||
<Table.Row>
|
</div>
|
||||||
<Table.Cell class="text-muted-foreground text-sm">
|
|
||||||
{record.time}
|
<!-- 分页控件 -->
|
||||||
</Table.Cell>
|
<div class="flex items-center justify-between mt-4">
|
||||||
<Table.Cell>
|
<p class="text-sm text-muted-foreground">
|
||||||
<Badge variant="secondary">{record.category}</Badge>
|
显示 {(currentPage - 1) * pageSize + 1} - {Math.min(currentPage * pageSize, totalRecords)} 条,共 {totalRecords} 条
|
||||||
</Table.Cell>
|
</p>
|
||||||
<Table.Cell class="max-w-[180px] truncate" title={record.merchant}>
|
<div class="flex items-center gap-2">
|
||||||
{record.merchant}
|
<Button
|
||||||
</Table.Cell>
|
variant="outline"
|
||||||
<Table.Cell class="max-w-[180px] truncate text-muted-foreground" title={record.description}>
|
size="sm"
|
||||||
{record.description || '-'}
|
disabled={currentPage <= 1}
|
||||||
</Table.Cell>
|
onclick={() => goToPage(currentPage - 1)}
|
||||||
<Table.Cell>
|
>
|
||||||
<span class={record.income_expense === '支出' ? 'text-red-500' : 'text-green-500'}>
|
<ChevronLeft class="h-4 w-4" />
|
||||||
{record.income_expense}
|
上一页
|
||||||
</span>
|
</Button>
|
||||||
</Table.Cell>
|
<div class="flex items-center gap-1">
|
||||||
<Table.Cell class="text-right font-mono font-medium">
|
{#each Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||||
¥{record.amount}
|
// 计算显示的页码范围
|
||||||
</Table.Cell>
|
let start = Math.max(1, currentPage - 2);
|
||||||
<Table.Cell class="text-muted-foreground text-sm">
|
let end = Math.min(totalPages, start + 4);
|
||||||
{record.payment_method || '-'}
|
start = Math.max(1, end - 4);
|
||||||
</Table.Cell>
|
return start + i;
|
||||||
<Table.Cell>
|
}).filter(p => p <= totalPages) as page}
|
||||||
<Badge variant="outline" class="text-green-600 border-green-200">
|
<Button
|
||||||
{record.status || '已完成'}
|
variant={page === currentPage ? 'default' : 'outline'}
|
||||||
</Badge>
|
size="sm"
|
||||||
</Table.Cell>
|
class="w-9"
|
||||||
</Table.Row>
|
onclick={() => goToPage(page)}
|
||||||
{/each}
|
>
|
||||||
</Table.Body>
|
{page}
|
||||||
</Table.Root>
|
</Button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={currentPage >= totalPages}
|
||||||
|
onclick={() => goToPage(currentPage + 1)}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
<ChevronRight class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{#if filteredRecords.length > 100}
|
</div>
|
||||||
<p class="text-center text-sm text-muted-foreground mt-4">
|
{:else}
|
||||||
显示前 100 条记录,共 {filteredRecords.length} 条
|
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||||
</p>
|
<FileText class="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
{/if}
|
<p class="text-muted-foreground">没有找到账单记录</p>
|
||||||
{:else}
|
<p class="text-sm text-muted-foreground mt-1">请先上传账单或调整筛选条件</p>
|
||||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
</div>
|
||||||
<FileText class="h-12 w-12 text-muted-foreground mb-4" />
|
{/if}
|
||||||
<p class="text-muted-foreground">没有匹配的记录</p>
|
</Card.Content>
|
||||||
</div>
|
</Card.Root>
|
||||||
{/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>
|
</div>
|
||||||
|
|||||||
19
web/src/routes/download/[...path]/+server.ts
Normal file
19
web/src/routes/download/[...path]/+server.ts
Normal 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') || '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import adapter from '@sveltejs/adapter-auto';
|
import adapter from '@sveltejs/adapter-node';
|
||||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
@@ -7,11 +7,21 @@ const config = {
|
|||||||
// for more information about preprocessors
|
// for more information about preprocessors
|
||||||
preprocess: vitePreprocess(),
|
preprocess: vitePreprocess(),
|
||||||
|
|
||||||
|
// 忽略图表组件的无障碍警告
|
||||||
|
onwarn: (warning, handler) => {
|
||||||
|
if (warning.code.startsWith('a11y_')) return;
|
||||||
|
handler(warning);
|
||||||
|
},
|
||||||
|
|
||||||
kit: {
|
kit: {
|
||||||
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
// 使用 adapter-node 以支持 Docker 部署
|
||||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
adapter: adapter({
|
||||||
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
out: 'build'
|
||||||
adapter: adapter()
|
}),
|
||||||
|
// 信任的来源(禁用 CSRF 检查)
|
||||||
|
csrf: {
|
||||||
|
trustedOrigins: ['*']
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,23 @@ import tailwindcss from '@tailwindcss/vite';
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [sveltekit(), tailwindcss()],
|
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: {
|
test: {
|
||||||
expect: { requireAssertions: true },
|
expect: { requireAssertions: true },
|
||||||
|
|
||||||
|
|||||||
128
web/yarn.lock
128
web/yarn.lock
@@ -390,6 +390,46 @@
|
|||||||
resolved "https://registry.npmmirror.com/@polka/url/-/url-1.0.0-next.29.tgz#5a40109a1ab5f84d6fd8fc928b19f367cbe7e7b1"
|
resolved "https://registry.npmmirror.com/@polka/url/-/url-1.0.0-next.29.tgz#5a40109a1ab5f84d6fd8fc928b19f367cbe7e7b1"
|
||||||
integrity sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==
|
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":
|
"@rollup/rollup-android-arm-eabi@4.55.1":
|
||||||
version "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"
|
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"
|
resolved "https://registry.npmmirror.com/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz#69c746a7c232094c117c50dedbd1279fc64887b7"
|
||||||
integrity sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==
|
integrity sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==
|
||||||
|
|
||||||
"@sveltejs/adapter-auto@^7.0.0":
|
"@sveltejs/adapter-node@^5.4.0":
|
||||||
version "7.0.0"
|
version "5.4.0"
|
||||||
resolved "https://registry.npmmirror.com/@sveltejs/adapter-auto/-/adapter-auto-7.0.0.tgz#e3f257a0d1be3383f6cd0c146aed8d470b33a7fe"
|
resolved "https://registry.npmmirror.com/@sveltejs/adapter-node/-/adapter-node-5.4.0.tgz#d013d48fb86d807f6da060d9fd026c932a1b0af2"
|
||||||
integrity sha512-ImDWaErTOCkRS4Gt+5gZuymKFBobnhChXUZ9lhUZLahUgvA4OOvRzi3sahzYgbxGj5nkA6OV0GAW378+dl/gyw==
|
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":
|
"@sveltejs/kit@^2.49.1":
|
||||||
version "2.49.3"
|
version "2.49.3"
|
||||||
@@ -730,7 +775,7 @@
|
|||||||
resolved "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz#334311971d3a07121e7eb91b684a605e7eea9cbd"
|
resolved "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz#334311971d3a07121e7eb91b684a605e7eea9cbd"
|
||||||
integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==
|
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"
|
version "1.0.8"
|
||||||
resolved "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e"
|
resolved "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e"
|
||||||
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
|
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
|
||||||
@@ -747,6 +792,11 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types "~5.26.4"
|
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":
|
"@typescript-eslint/eslint-plugin@8.52.0":
|
||||||
version "8.52.0"
|
version "8.52.0"
|
||||||
resolved "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.52.0.tgz#9a9f1d2ee974ed77a8b1bda94e77123f697ee8b4"
|
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"
|
resolved "https://registry.npmmirror.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7"
|
||||||
integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==
|
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:
|
concat-map@0.0.1:
|
||||||
version "0.0.1"
|
version "0.0.1"
|
||||||
resolved "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
|
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"
|
resolved "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
|
||||||
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
|
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"
|
version "4.3.1"
|
||||||
resolved "https://registry.npmmirror.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"
|
resolved "https://registry.npmmirror.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"
|
||||||
integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
|
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"
|
resolved "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123"
|
||||||
integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==
|
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:
|
estree-walker@^3.0.3:
|
||||||
version "3.0.3"
|
version "3.0.3"
|
||||||
resolved "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d"
|
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"
|
resolved "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
|
||||||
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
|
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:
|
glob-parent@^6.0.2:
|
||||||
version "6.0.2"
|
version "6.0.2"
|
||||||
resolved "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3"
|
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"
|
resolved "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
|
||||||
integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
|
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:
|
iconv-lite@0.6:
|
||||||
version "0.6.3"
|
version "0.6.3"
|
||||||
resolved "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501"
|
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"
|
resolved "https://registry.npmmirror.com/internmap/-/internmap-1.0.1.tgz#0017cc8a3b99605f0302f2b198d272e015e5df95"
|
||||||
integrity sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==
|
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:
|
is-extglob@^2.1.1:
|
||||||
version "2.1.1"
|
version "2.1.1"
|
||||||
resolved "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
|
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:
|
dependencies:
|
||||||
is-extglob "^2.1.1"
|
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:
|
is-reference@^3.0.3:
|
||||||
version "3.0.3"
|
version "3.0.3"
|
||||||
resolved "https://registry.npmmirror.com/is-reference/-/is-reference-3.0.3.tgz#9ef7bf9029c70a67b2152da4adf57c23d718910f"
|
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"
|
resolved "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941"
|
||||||
integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==
|
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"
|
version "0.30.21"
|
||||||
resolved "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91"
|
resolved "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91"
|
||||||
integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==
|
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"
|
resolved "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
|
||||||
integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==
|
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:
|
pathe@^2.0.3:
|
||||||
version "2.0.3"
|
version "2.0.3"
|
||||||
resolved "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716"
|
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"
|
resolved "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
|
||||||
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
|
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
|
||||||
|
|
||||||
picomatch@^4.0.3:
|
picomatch@^4.0.2, picomatch@^4.0.3:
|
||||||
version "4.0.3"
|
version "4.0.3"
|
||||||
resolved "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042"
|
resolved "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042"
|
||||||
integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==
|
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"
|
resolved "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
|
||||||
integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==
|
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:
|
robust-predicates@^3.0.2:
|
||||||
version "3.0.2"
|
version "3.0.2"
|
||||||
resolved "https://registry.npmmirror.com/robust-predicates/-/robust-predicates-3.0.2.tgz#d5b28528c4824d20fc48df1928d41d9efa1ad771"
|
resolved "https://registry.npmmirror.com/robust-predicates/-/robust-predicates-3.0.2.tgz#d5b28528c4824d20fc48df1928d41d9efa1ad771"
|
||||||
integrity sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==
|
integrity sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==
|
||||||
|
|
||||||
rollup@^4.43.0:
|
rollup@^4.43.0, rollup@^4.9.5:
|
||||||
version "4.55.1"
|
version "4.55.1"
|
||||||
resolved "https://registry.npmmirror.com/rollup/-/rollup-4.55.1.tgz#4ec182828be440648e7ee6520dc35e9f20e05144"
|
resolved "https://registry.npmmirror.com/rollup/-/rollup-4.55.1.tgz#4ec182828be440648e7ee6520dc35e9f20e05144"
|
||||||
integrity sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==
|
integrity sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==
|
||||||
@@ -2179,6 +2284,11 @@ supports-color@^7.1.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
has-flag "^4.0.0"
|
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:
|
svelte-check@^4.3.4:
|
||||||
version "4.3.5"
|
version "4.3.5"
|
||||||
resolved "https://registry.npmmirror.com/svelte-check/-/svelte-check-4.3.5.tgz#2e9e05eca63fdb5523a37c666f47614d36c11212"
|
resolved "https://registry.npmmirror.com/svelte-check/-/svelte-check-4.3.5.tgz#2e9e05eca63fdb5523a37c666f47614d36c11212"
|
||||||
|
|||||||
Reference in New Issue
Block a user