initial_commit
This commit is contained in:
parent
35112d356b
commit
10890e515f
120
AGENTS.md
Normal file
120
AGENTS.md
Normal file
@ -0,0 +1,120 @@
|
||||
# CarCost - Agent Instructions
|
||||
|
||||
## Project Overview
|
||||
Full-stack vehicle expense tracking application.
|
||||
- **Frontend**: Vue 3 + Vite + Element Plus + ECharts (`web/`)
|
||||
- **Backend**: FastAPI + SQLAlchemy + PostgreSQL (`api/`)
|
||||
|
||||
## Architecture
|
||||
|
||||
### Monorepo Structure
|
||||
```
|
||||
web/src/
|
||||
├── App.vue # Main app component
|
||||
├── main.js # Entry with Element Plus + ECharts setup
|
||||
└── components/ # Reusable components
|
||||
├── panels/ # List panels with charts + table
|
||||
│ ├── FuelRecordsPanel.vue # Desktop & mobile shared
|
||||
│ └── CostRecordsPanel.vue # Desktop & mobile shared
|
||||
├── charts/ # Chart components
|
||||
│ └── DashboardCharts.vue
|
||||
├── cards/ # Card components
|
||||
│ └── StatsCards.vue
|
||||
└── dialogs/ # Dialog components
|
||||
├── vehicle/
|
||||
├── fuel/
|
||||
└── cost/
|
||||
```
|
||||
|
||||
**Responsive Design Pattern**:
|
||||
- `App.vue` uses CSS media queries to switch between desktop/mobile layouts
|
||||
- `*Panel.vue` components handle both desktop and mobile via `showCharts`/`showList` props
|
||||
- Desktop: Dual-column layout with StatsCards + DashboardCharts + Panels side-by-side
|
||||
- Mobile: Single-column with view switching (dashboard/fuel/cost tabs)
|
||||
|
||||
### API Routing Convention
|
||||
All API routes include `/carcost` prefix:
|
||||
- `/carcost/vehicles/*` - Vehicle CRUD
|
||||
- `/carcost/fuel_records/*` - Fuel records
|
||||
- `/carcost/costs/*` - Cost records
|
||||
- `/carcost/dashboard/*` - Dashboard data
|
||||
|
||||
Frontend uses: `API_BASE = 'https://api.yuany3721.site/carcost'`
|
||||
|
||||
### Database Models
|
||||
- **Vehicle**: `id`, `name`, `purchase_date`, `initial_mileage`, `is_deleted`
|
||||
- **FuelRecord**: `id`, `vehicle_id`, `date`, `mileage`, `fuel_amount`, `fuel_price`, `total_cost`, `is_full_tank`
|
||||
- **Cost**: `id`, `vehicle_id`, `date`, `type`, `amount`, `mileage`, `is_installment`, `installment_months`
|
||||
|
||||
All models use soft delete (`is_deleted` boolean).
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Backend (api/)
|
||||
```bash
|
||||
# Setup (requires Python 3.x)
|
||||
cd api
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Run dev server
|
||||
python main.py
|
||||
# or
|
||||
uvicorn main:app --host 0.0.0.0 --port 7030 --reload
|
||||
|
||||
# Run tests
|
||||
python test_api.py
|
||||
```
|
||||
|
||||
### Frontend (web/)
|
||||
```bash
|
||||
cd web
|
||||
npm install
|
||||
|
||||
# Dev server (exposes to all hosts)
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
|
||||
# Preview production build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## Key Configuration Files
|
||||
|
||||
### api/.env
|
||||
```
|
||||
DATABASE_URL=postgresql://user:pass@host:port/carcost
|
||||
DEBUG=true
|
||||
```
|
||||
|
||||
### web/vite.config.js
|
||||
- `server.allowedHosts: ['yuany3721.site', '.yuany3721.site']` - Required for production domain access
|
||||
|
||||
## Code Patterns
|
||||
|
||||
### Frontend (Vue 3 Composition API)
|
||||
- Uses `<script setup>` syntax
|
||||
- Element Plus for UI components (with Chinese text labels)
|
||||
- ECharts registered globally via `vue-echarts`
|
||||
- Axios for API calls
|
||||
- Responsive design with mobile/desktop layouts
|
||||
|
||||
### Backend (FastAPI)
|
||||
- Routers auto-create tables via `Base.metadata.create_all()`
|
||||
- CORS configured for all origins (dev-friendly, restrict in production)
|
||||
- SQLAlchemy 2.0 style with `declarative_base()`
|
||||
- Database connection pooling: `pool_size=5, max_overflow=10`
|
||||
|
||||
## Testing
|
||||
- `api/test_api.py`: Integration tests against live API (api.yuany3721.site)
|
||||
- Tests cover costs CRUD operations and type validation
|
||||
|
||||
## Important Notes
|
||||
- **Port**: Backend runs on 7030, frontend dev server typically on 5173
|
||||
- **CORS**: Backend allows all origins (`["*"]`) - change for production
|
||||
- **Soft Deletes**: All entities use `is_deleted` flag; queries should filter it
|
||||
- **Cost Types**: 保养/维修/保险/停车/洗车/违章/过路费/其他
|
||||
- **Frontend Max Width**: Content constrained to 900px centered
|
||||
9
api/.env.example
Normal file
9
api/.env.example
Normal file
@ -0,0 +1,9 @@
|
||||
# 数据库连接字符串
|
||||
DATABASE_URL=postgresql://username:password@localhost:5432/carcost
|
||||
|
||||
# 调试模式
|
||||
debug=true
|
||||
|
||||
# 应用配置(可选)
|
||||
TITLE=CarCost
|
||||
VERSION=1.0.0
|
||||
48
api/.gitignore
vendored
Normal file
48
api/.gitignore
vendored
Normal file
@ -0,0 +1,48 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Virtual Environment
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
.venv/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.bak
|
||||
68
api/README.md
Normal file
68
api/README.md
Normal file
@ -0,0 +1,68 @@
|
||||
# CarCost API
|
||||
|
||||
车辆费用追踪后端 API,使用 FastAPI + SQLAlchemy + PostgreSQL 构建。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
api/
|
||||
├── main.py # FastAPI 入口
|
||||
├── config.py # 配置管理
|
||||
├── database.py # 数据库连接
|
||||
├── models.py # SQLAlchemy 模型
|
||||
├── schemas.py # Pydantic 数据模型
|
||||
├── requirements.txt # 依赖
|
||||
├── routers/ # API 路由
|
||||
│ ├── vehicles.py # 车辆管理
|
||||
│ ├── fuel_records.py # 加油记录
|
||||
│ ├── costs.py # 费用记录
|
||||
│ └── dashboard.py # 仪表板
|
||||
└── .env # 环境变量(不提交)
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 配置环境变量
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# 编辑 .env 文件,设置 DATABASE_URL
|
||||
```
|
||||
|
||||
### 2. 安装依赖
|
||||
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 3. 启动服务
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
# 或
|
||||
uvicorn main:app --host 0.0.0.0 --port 7030 --reload
|
||||
```
|
||||
|
||||
### 4. 访问文档
|
||||
|
||||
- API 文档: http://localhost:7030/docs
|
||||
- 健康检查: http://localhost:7030/health
|
||||
|
||||
## API 路由
|
||||
|
||||
所有路由前缀为 `/carcost`:
|
||||
|
||||
- `GET/POST /carcost/vehicles/*` - 车辆管理
|
||||
- `GET/POST /carcost/fuel-records/*` - 加油记录
|
||||
- `GET/POST /carcost/costs/*` - 费用记录
|
||||
- `GET /carcost/dashboard/*` - 仪表板数据
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **FastAPI**: Web 框架
|
||||
- **SQLAlchemy 2.0**: ORM
|
||||
- **PostgreSQL**: 数据库
|
||||
- **Pydantic**: 数据验证
|
||||
- **python-dotenv**: 环境变量管理
|
||||
19
api/config.py
Normal file
19
api/config.py
Normal file
@ -0,0 +1,19 @@
|
||||
# config.py
|
||||
from pydantic_settings import BaseSettings
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
# 加载环境变量
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql://postgres:password@localhost:5432/carcost")
|
||||
TITLE: str = os.getenv("TITLE", "CarCost")
|
||||
VERSION: str = os.getenv("VERSION", "1.0.0")
|
||||
DEBUG: bool = os.getenv("DEBUG", "false").lower() == "true"
|
||||
|
||||
|
||||
# 创建全局配置实例
|
||||
settings = Settings()
|
||||
25
api/database.py
Normal file
25
api/database.py
Normal file
@ -0,0 +1,25 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from config import settings
|
||||
|
||||
# 创建数据库引擎
|
||||
engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
pool_pre_ping=True,
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
)
|
||||
|
||||
# 创建会话工厂
|
||||
SessionLocal = sessionmaker(bind=engine)
|
||||
|
||||
|
||||
def get_session() -> Session:
|
||||
"""获取新的数据库会话
|
||||
|
||||
使用示例:
|
||||
with get_session() as session:
|
||||
result = session.query(Model).all()
|
||||
"""
|
||||
return SessionLocal()
|
||||
45
api/main.py
Normal file
45
api/main.py
Normal file
@ -0,0 +1,45 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from config import settings
|
||||
from routers import vehicles_router, fuel_records_router, costs_router, dashboard_router
|
||||
|
||||
# 创建 FastAPI 应用
|
||||
app = FastAPI(title=settings.TITLE, version=settings.VERSION, debug=settings.DEBUG)
|
||||
|
||||
# 配置 CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # 生产环境应该限制具体域名
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 注册路由 - 添加 /carcost 前缀
|
||||
app.include_router(vehicles_router, prefix="/carcost")
|
||||
app.include_router(fuel_records_router, prefix="/carcost")
|
||||
app.include_router(costs_router, prefix="/carcost")
|
||||
app.include_router(dashboard_router, prefix="/carcost")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
"""根路径"""
|
||||
return {
|
||||
"message": "Welcome to CarCost API",
|
||||
"version": settings.VERSION,
|
||||
"docs": "/docs",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
"""健康检查"""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=7030, reload=settings.DEBUG)
|
||||
69
api/models.py
Normal file
69
api/models.py
Normal file
@ -0,0 +1,69 @@
|
||||
"""
|
||||
SQLAlchemy 数据库模型定义
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, Numeric, Date, ForeignKey
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
|
||||
# 定义基类
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class Vehicle(Base):
|
||||
"""车辆表"""
|
||||
__tablename__ = "vehicles"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(50), nullable=False)
|
||||
purchase_date = Column(Date, nullable=True)
|
||||
initial_mileage = Column(Integer, default=0, nullable=False)
|
||||
is_deleted = Column(Boolean, default=False) # 软删除标记
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
# 关联关系
|
||||
fuel_records = relationship("FuelRecord", back_populates="vehicle")
|
||||
costs = relationship("Cost", back_populates="vehicle")
|
||||
|
||||
|
||||
class FuelRecord(Base):
|
||||
"""加油记录表"""
|
||||
__tablename__ = "fuel_records"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
vehicle_id = Column(Integer, ForeignKey("vehicles.id"), nullable=False)
|
||||
date = Column(Date, nullable=False)
|
||||
mileage = Column(Integer, nullable=False)
|
||||
fuel_amount = Column(Numeric(10, 2), nullable=False)
|
||||
fuel_price = Column(Numeric(10, 2), nullable=True)
|
||||
total_cost = Column(Numeric(10, 2), nullable=False)
|
||||
is_full_tank = Column(Boolean, default=True)
|
||||
notes = Column(Text, default="")
|
||||
is_deleted = Column(Boolean, default=False) # 软删除标记
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
# 关联关系
|
||||
vehicle = relationship("Vehicle", back_populates="fuel_records")
|
||||
|
||||
|
||||
class Cost(Base):
|
||||
"""费用记录表"""
|
||||
__tablename__ = "costs"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
vehicle_id = Column(Integer, ForeignKey("vehicles.id"), nullable=False)
|
||||
date = Column(Date, nullable=False)
|
||||
type = Column(String(20), nullable=False) # 保养/维修/保险/停车/洗车/违章/过路费/其他
|
||||
amount = Column(Numeric(10, 2), nullable=False)
|
||||
mileage = Column(Integer, nullable=True)
|
||||
notes = Column(Text, default="")
|
||||
is_installment = Column(Boolean, default=False) # 是否分期/分摊
|
||||
installment_months = Column(Integer, default=12) # 分摊月数
|
||||
is_deleted = Column(Boolean, default=False) # 软删除标记
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
# 关联关系
|
||||
vehicle = relationship("Vehicle", back_populates="costs")
|
||||
8
api/requirements.txt
Normal file
8
api/requirements.txt
Normal file
@ -0,0 +1,8 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.32.0
|
||||
sqlalchemy==2.0.36
|
||||
psycopg2-binary==2.9.10
|
||||
pydantic==2.9.2
|
||||
pydantic-settings==2.6.1
|
||||
python-dotenv==1.0.1
|
||||
python-multipart==0.0.17
|
||||
20
api/routers/__init__.py
Normal file
20
api/routers/__init__.py
Normal file
@ -0,0 +1,20 @@
|
||||
"""
|
||||
路由模块统一导出
|
||||
|
||||
使用示例:
|
||||
from routers import vehicles_router, fuel_records_router, costs_router, dashboard_router
|
||||
|
||||
app.include_router(vehicles_router, prefix="/carcost")
|
||||
app.include_router(fuel_records_router, prefix="/carcost")
|
||||
"""
|
||||
from .vehicles import router as vehicles_router
|
||||
from .fuel_records import router as fuel_records_router
|
||||
from .costs import router as costs_router
|
||||
from .dashboard import router as dashboard_router
|
||||
|
||||
__all__ = [
|
||||
"vehicles_router",
|
||||
"fuel_records_router",
|
||||
"costs_router",
|
||||
"dashboard_router",
|
||||
]
|
||||
154
api/routers/costs.py
Normal file
154
api/routers/costs.py
Normal file
@ -0,0 +1,154 @@
|
||||
from fastapi import APIRouter, HTTPException, Body
|
||||
from typing import Optional
|
||||
from decimal import Decimal
|
||||
from datetime import date as date_module
|
||||
|
||||
from database import get_session
|
||||
from models import Cost, Vehicle
|
||||
from schemas import (
|
||||
CostCreate,
|
||||
CostUpdate,
|
||||
CostDelete,
|
||||
CostResponse,
|
||||
COST_TYPES,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/costs", tags=["costs"])
|
||||
|
||||
|
||||
def _to_cost_response(cost: Cost) -> CostResponse:
|
||||
"""将 Cost 模型转换为 CostResponse"""
|
||||
return CostResponse(
|
||||
id=cost.id,
|
||||
vehicle_id=cost.vehicle_id,
|
||||
date=cost.date,
|
||||
type=cost.type,
|
||||
amount=float(cost.amount),
|
||||
mileage=cost.mileage,
|
||||
notes=cost.notes,
|
||||
is_installment=cost.is_installment,
|
||||
installment_months=cost.installment_months,
|
||||
is_deleted=cost.is_deleted,
|
||||
created_at=cost.created_at,
|
||||
updated_at=cost.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/list", response_model=list[CostResponse])
|
||||
def get_costs(
|
||||
vehicle_id: Optional[int] = None, cost_type: Optional[str] = None, limit: int = 50
|
||||
):
|
||||
"""获取费用记录列表(排除已删除)"""
|
||||
with get_session() as session:
|
||||
query = session.query(Cost).filter(Cost.is_deleted == False)
|
||||
if vehicle_id:
|
||||
query = query.filter(Cost.vehicle_id == vehicle_id)
|
||||
if cost_type:
|
||||
query = query.filter(Cost.type == cost_type)
|
||||
|
||||
costs = query.order_by(Cost.date.desc()).limit(limit).all()
|
||||
return [_to_cost_response(c) for c in costs]
|
||||
|
||||
|
||||
@router.post("/create", response_model=CostResponse)
|
||||
def create_cost(cost: CostCreate):
|
||||
"""添加费用记录"""
|
||||
with get_session() as session:
|
||||
# 验证车辆存在
|
||||
vehicle = session.query(Vehicle).filter(Vehicle.id == cost.vehicle_id).first()
|
||||
if not vehicle:
|
||||
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||
|
||||
# 验证费用类型
|
||||
if cost.type not in COST_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid cost type. Must be one of: {', '.join(COST_TYPES)}",
|
||||
)
|
||||
|
||||
db_cost = Cost(
|
||||
vehicle_id=cost.vehicle_id,
|
||||
date=cost.date,
|
||||
type=cost.type,
|
||||
amount=Decimal(str(cost.amount)),
|
||||
mileage=cost.mileage,
|
||||
notes=cost.notes or "",
|
||||
is_installment=cost.is_installment or False,
|
||||
installment_months=cost.installment_months or 12,
|
||||
)
|
||||
session.add(db_cost)
|
||||
session.commit()
|
||||
session.refresh(db_cost)
|
||||
|
||||
return _to_cost_response(db_cost)
|
||||
|
||||
|
||||
@router.post("/update")
|
||||
def update_cost(
|
||||
id: int = Body(...),
|
||||
type: Optional[str] = Body(None),
|
||||
date: Optional[str] = Body(None),
|
||||
amount: Optional[float] = Body(None),
|
||||
mileage: Optional[int] = Body(None),
|
||||
notes: Optional[str] = Body(None),
|
||||
is_installment: Optional[bool] = Body(None),
|
||||
installment_months: Optional[int] = Body(None),
|
||||
):
|
||||
"""更新费用记录"""
|
||||
with get_session() as session:
|
||||
cost = session.query(Cost).filter(Cost.id == id).first()
|
||||
if not cost:
|
||||
raise HTTPException(status_code=404, detail="Cost record not found")
|
||||
|
||||
# 验证费用类型
|
||||
if type is not None:
|
||||
if type not in COST_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid cost type. Must be one of: {', '.join(COST_TYPES)}",
|
||||
)
|
||||
cost.type = type
|
||||
|
||||
# 处理日期转换
|
||||
if date is not None:
|
||||
cost.date = date_module.fromisoformat(date)
|
||||
|
||||
if amount is not None:
|
||||
cost.amount = Decimal(str(amount))
|
||||
if mileage is not None:
|
||||
cost.mileage = mileage
|
||||
if notes is not None:
|
||||
cost.notes = notes or ""
|
||||
if is_installment is not None:
|
||||
cost.is_installment = is_installment
|
||||
if installment_months is not None:
|
||||
cost.installment_months = installment_months
|
||||
|
||||
session.commit()
|
||||
session.refresh(cost)
|
||||
|
||||
return _to_cost_response(cost)
|
||||
|
||||
|
||||
@router.post("/delete")
|
||||
def delete_cost(cost: CostDelete):
|
||||
"""软删除费用记录"""
|
||||
with get_session() as session:
|
||||
db_cost = (
|
||||
session.query(Cost)
|
||||
.filter(Cost.id == cost.id, Cost.is_deleted == False)
|
||||
.first()
|
||||
)
|
||||
if not db_cost:
|
||||
raise HTTPException(status_code=404, detail="Cost record not found")
|
||||
|
||||
db_cost.is_deleted = True
|
||||
session.commit()
|
||||
return {"message": "Cost record deleted successfully"}
|
||||
|
||||
|
||||
@router.get("/types")
|
||||
def get_cost_types():
|
||||
"""获取费用类型列表"""
|
||||
return {"types": COST_TYPES}
|
||||
128
api/routers/dashboard.py
Normal file
128
api/routers/dashboard.py
Normal file
@ -0,0 +1,128 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import Optional
|
||||
|
||||
from database import get_session
|
||||
from models import Vehicle, FuelRecord
|
||||
from schemas import DashboardData, FuelRecordItem
|
||||
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||
|
||||
|
||||
@router.get("/data", response_model=DashboardData)
|
||||
def get_dashboard(vehicle_id: int):
|
||||
"""获取仪表盘数据"""
|
||||
with get_session() as session:
|
||||
vehicle = (
|
||||
session.query(Vehicle)
|
||||
.filter(Vehicle.id == vehicle_id, Vehicle.is_deleted == False)
|
||||
.first()
|
||||
)
|
||||
if not vehicle:
|
||||
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||
|
||||
# 获取所有未删除的加油记录
|
||||
fuel_records = (
|
||||
session.query(FuelRecord)
|
||||
.filter(FuelRecord.vehicle_id == vehicle_id, FuelRecord.is_deleted == False)
|
||||
.order_by(FuelRecord.date.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
# 计算总里程
|
||||
if fuel_records:
|
||||
latest_mileage = max(r.mileage for r in fuel_records)
|
||||
total_mileage = latest_mileage - vehicle.initial_mileage
|
||||
else:
|
||||
total_mileage = 0
|
||||
|
||||
# 计算总油费
|
||||
total_fuel_cost = sum(float(r.total_cost) for r in fuel_records)
|
||||
|
||||
# 计算平均油耗(只算加满的记录)
|
||||
total_fuel_amount = sum(
|
||||
float(r.fuel_amount) for r in fuel_records if r.is_full_tank
|
||||
)
|
||||
avg_fuel_consumption = None
|
||||
if total_mileage > 0 and total_fuel_amount > 0:
|
||||
avg_fuel_consumption = round((total_fuel_amount / total_mileage) * 100, 2)
|
||||
|
||||
# 获取最近5条加油记录(带油耗计算,排除已删除)
|
||||
recent_records = (
|
||||
session.query(FuelRecord)
|
||||
.filter(FuelRecord.vehicle_id == vehicle_id, FuelRecord.is_deleted == False)
|
||||
.order_by(FuelRecord.date.desc(), FuelRecord.mileage.desc())
|
||||
.limit(5)
|
||||
.all()
|
||||
)
|
||||
|
||||
recent_fuel_records = []
|
||||
for record in recent_records:
|
||||
# 计算单次油耗
|
||||
fuel_consumption = None
|
||||
if record.is_full_tank:
|
||||
# 先找更早日期的记录
|
||||
prev_record = (
|
||||
session.query(FuelRecord)
|
||||
.filter(
|
||||
FuelRecord.vehicle_id == vehicle_id,
|
||||
FuelRecord.date < record.date,
|
||||
FuelRecord.is_full_tank == True,
|
||||
FuelRecord.is_deleted == False,
|
||||
)
|
||||
.order_by(FuelRecord.date.desc(), FuelRecord.mileage.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
# 如果没找到,找同一天更早的记录
|
||||
if not prev_record:
|
||||
prev_record = (
|
||||
session.query(FuelRecord)
|
||||
.filter(
|
||||
FuelRecord.vehicle_id == vehicle_id,
|
||||
FuelRecord.date == record.date,
|
||||
FuelRecord.mileage < record.mileage,
|
||||
FuelRecord.is_full_tank == True,
|
||||
FuelRecord.is_deleted == False,
|
||||
)
|
||||
.order_by(FuelRecord.mileage.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
if prev_record:
|
||||
mileage_diff = record.mileage - prev_record.mileage
|
||||
if mileage_diff > 0:
|
||||
fuel_consumption = round(
|
||||
(float(record.fuel_amount) / mileage_diff) * 100, 2
|
||||
)
|
||||
|
||||
recent_fuel_records.append(
|
||||
FuelRecordItem(
|
||||
id=record.id,
|
||||
date=record.date,
|
||||
mileage=record.mileage,
|
||||
fuel_amount=float(record.fuel_amount),
|
||||
total_cost=float(record.total_cost),
|
||||
fuel_consumption=fuel_consumption,
|
||||
)
|
||||
)
|
||||
|
||||
# 计算日均行程
|
||||
avg_daily_km = None
|
||||
if total_mileage > 0 and fuel_records:
|
||||
first_date = fuel_records[0].date # 第一次加油日期(已按日期升序排序)
|
||||
last_date = fuel_records[-1].date # 最后一次加油日期
|
||||
days = (last_date - first_date).days
|
||||
if days > 0:
|
||||
avg_daily_km = round(total_mileage / days, 1)
|
||||
|
||||
return DashboardData(
|
||||
vehicle_id=vehicle_id,
|
||||
vehicle_name=vehicle.name,
|
||||
purchase_date=vehicle.purchase_date,
|
||||
total_mileage=total_mileage,
|
||||
total_fuel_cost=round(total_fuel_cost, 2),
|
||||
avg_fuel_consumption=avg_fuel_consumption,
|
||||
avg_daily_km=avg_daily_km,
|
||||
recent_fuel_records=recent_fuel_records,
|
||||
)
|
||||
221
api/routers/fuel_records.py
Normal file
221
api/routers/fuel_records.py
Normal file
@ -0,0 +1,221 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import Optional
|
||||
from decimal import Decimal
|
||||
|
||||
from database import get_session
|
||||
from models import FuelRecord, Vehicle
|
||||
from schemas import (
|
||||
FuelRecordCreate,
|
||||
FuelRecordUpdate,
|
||||
FuelRecordDelete,
|
||||
FuelRecordResponse,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/fuel-records", tags=["fuel_records"])
|
||||
|
||||
|
||||
def calculate_fuel_consumption(session, record: FuelRecord) -> Optional[float]:
|
||||
"""计算单次油耗
|
||||
|
||||
油耗计算逻辑:
|
||||
1. 找到上一条加满油的记录
|
||||
2. 计算两次加满之间的里程差
|
||||
3. 计算两次加满之间的累计加油量(包括中间未加满的记录)
|
||||
4. 油耗 = (累计加油量 / 里程差) * 100
|
||||
"""
|
||||
if not record.is_full_tank:
|
||||
return None
|
||||
|
||||
# 找到上一条加满油的记录(排除已删除)
|
||||
prev_record = (
|
||||
session.query(FuelRecord)
|
||||
.filter(
|
||||
FuelRecord.vehicle_id == record.vehicle_id,
|
||||
FuelRecord.date < record.date,
|
||||
FuelRecord.is_full_tank == True,
|
||||
FuelRecord.is_deleted == False,
|
||||
)
|
||||
.order_by(FuelRecord.date.desc(), FuelRecord.mileage.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
# 如果同一天有更早的记录,也考虑进去
|
||||
if not prev_record:
|
||||
prev_record = (
|
||||
session.query(FuelRecord)
|
||||
.filter(
|
||||
FuelRecord.vehicle_id == record.vehicle_id,
|
||||
FuelRecord.date == record.date,
|
||||
FuelRecord.mileage < record.mileage,
|
||||
FuelRecord.is_full_tank == True,
|
||||
FuelRecord.is_deleted == False,
|
||||
)
|
||||
.order_by(FuelRecord.mileage.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
if not prev_record:
|
||||
return None
|
||||
|
||||
mileage_diff = record.mileage - prev_record.mileage
|
||||
if mileage_diff <= 0:
|
||||
return None
|
||||
|
||||
# 计算两次加满之间的累计加油量(包括中间未加满的记录)
|
||||
# 从上一次加满之后(不包括)到当前这次(包括)的所有加油记录
|
||||
intermediate_records = (
|
||||
session.query(FuelRecord)
|
||||
.filter(
|
||||
FuelRecord.vehicle_id == record.vehicle_id,
|
||||
FuelRecord.date >= prev_record.date,
|
||||
FuelRecord.date <= record.date,
|
||||
FuelRecord.mileage > prev_record.mileage,
|
||||
FuelRecord.mileage <= record.mileage,
|
||||
FuelRecord.is_deleted == False,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
total_fuel = sum(float(r.fuel_amount) for r in intermediate_records)
|
||||
|
||||
fuel_consumption = (total_fuel / mileage_diff) * 100
|
||||
return round(fuel_consumption, 2)
|
||||
|
||||
|
||||
def get_record_with_consumption(session, record: FuelRecord) -> FuelRecordResponse:
|
||||
"""获取记录并计算油耗"""
|
||||
fuel_consumption = calculate_fuel_consumption(session, record)
|
||||
return FuelRecordResponse(
|
||||
id=record.id,
|
||||
vehicle_id=record.vehicle_id,
|
||||
date=record.date,
|
||||
mileage=record.mileage,
|
||||
fuel_amount=float(record.fuel_amount),
|
||||
fuel_price=float(record.fuel_price) if record.fuel_price else None,
|
||||
total_cost=float(record.total_cost),
|
||||
is_full_tank=record.is_full_tank,
|
||||
notes=record.notes,
|
||||
fuel_consumption=fuel_consumption,
|
||||
is_deleted=record.is_deleted,
|
||||
created_at=record.created_at,
|
||||
updated_at=record.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/list", response_model=list[FuelRecordResponse])
|
||||
def get_fuel_records(vehicle_id: Optional[int] = None, limit: int = 50):
|
||||
"""获取加油记录列表(排除已删除)"""
|
||||
with get_session() as session:
|
||||
query = session.query(FuelRecord).filter(FuelRecord.is_deleted == False)
|
||||
if vehicle_id:
|
||||
query = query.filter(FuelRecord.vehicle_id == vehicle_id)
|
||||
|
||||
records = (
|
||||
query.order_by(FuelRecord.date.desc(), FuelRecord.mileage.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [get_record_with_consumption(session, r) for r in records]
|
||||
|
||||
|
||||
@router.post("/create", response_model=FuelRecordResponse)
|
||||
def create_fuel_record(record: FuelRecordCreate):
|
||||
"""添加加油记录"""
|
||||
with get_session() as session:
|
||||
# 验证车辆存在
|
||||
vehicle = session.query(Vehicle).filter(Vehicle.id == record.vehicle_id).first()
|
||||
if not vehicle:
|
||||
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||
|
||||
# 自动计算总价或单价
|
||||
fuel_price = record.fuel_price
|
||||
total_cost = record.total_cost
|
||||
|
||||
if fuel_price is None and total_cost is not None:
|
||||
# 只有总价,计算单价
|
||||
fuel_price = total_cost / record.fuel_amount
|
||||
elif total_cost is None and fuel_price is not None:
|
||||
# 只有单价,计算总价
|
||||
total_cost = fuel_price * record.fuel_amount
|
||||
elif total_cost is None and fuel_price is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Either fuel_price or total_cost must be provided",
|
||||
)
|
||||
# 如果两者都有,使用传入的值(不重新计算,保留实付金额)
|
||||
|
||||
db_record = FuelRecord(
|
||||
vehicle_id=record.vehicle_id,
|
||||
date=record.date,
|
||||
mileage=record.mileage,
|
||||
fuel_amount=Decimal(str(record.fuel_amount)),
|
||||
fuel_price=Decimal(str(fuel_price)) if fuel_price else None,
|
||||
total_cost=Decimal(str(total_cost)),
|
||||
is_full_tank=record.is_full_tank,
|
||||
notes=record.notes or "",
|
||||
)
|
||||
session.add(db_record)
|
||||
session.commit()
|
||||
session.refresh(db_record)
|
||||
return get_record_with_consumption(session, db_record)
|
||||
|
||||
|
||||
@router.post("/update", response_model=FuelRecordResponse)
|
||||
def update_fuel_record(record_update: FuelRecordUpdate):
|
||||
"""更新加油记录"""
|
||||
with get_session() as session:
|
||||
record = (
|
||||
session.query(FuelRecord).filter(FuelRecord.id == record_update.id).first()
|
||||
)
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail="Fuel record not found")
|
||||
|
||||
# 更新字段
|
||||
if record_update.date is not None:
|
||||
record.date = record_update.date
|
||||
if record_update.mileage is not None:
|
||||
record.mileage = record_update.mileage
|
||||
if record_update.fuel_amount is not None:
|
||||
record.fuel_amount = Decimal(str(record_update.fuel_amount))
|
||||
if record_update.is_full_tank is not None:
|
||||
record.is_full_tank = record_update.is_full_tank
|
||||
if record_update.notes is not None:
|
||||
record.notes = record_update.notes
|
||||
|
||||
# 处理价格和总价
|
||||
fuel_amount = float(record.fuel_amount)
|
||||
if (
|
||||
record_update.fuel_price is not None
|
||||
and record_update.total_cost is not None
|
||||
):
|
||||
record.fuel_price = Decimal(str(record_update.fuel_price))
|
||||
record.total_cost = Decimal(str(record_update.total_cost))
|
||||
elif record_update.fuel_price is not None:
|
||||
record.fuel_price = Decimal(str(record_update.fuel_price))
|
||||
record.total_cost = Decimal(str(record_update.fuel_price * fuel_amount))
|
||||
elif record_update.total_cost is not None:
|
||||
record.total_cost = Decimal(str(record_update.total_cost))
|
||||
if fuel_amount > 0:
|
||||
record.fuel_price = Decimal(str(record_update.total_cost / fuel_amount))
|
||||
|
||||
session.commit()
|
||||
session.refresh(record)
|
||||
return get_record_with_consumption(session, record)
|
||||
|
||||
|
||||
@router.post("/delete")
|
||||
def delete_fuel_record(record: FuelRecordDelete):
|
||||
"""软删除加油记录"""
|
||||
with get_session() as session:
|
||||
db_record = (
|
||||
session.query(FuelRecord)
|
||||
.filter(FuelRecord.id == record.id, FuelRecord.is_deleted == False)
|
||||
.first()
|
||||
)
|
||||
if not db_record:
|
||||
raise HTTPException(status_code=404, detail="Fuel record not found")
|
||||
|
||||
db_record.is_deleted = True
|
||||
session.commit()
|
||||
return {"message": "Fuel record deleted successfully"}
|
||||
127
api/routers/vehicles.py
Normal file
127
api/routers/vehicles.py
Normal file
@ -0,0 +1,127 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import Optional
|
||||
from decimal import Decimal
|
||||
|
||||
from database import get_session
|
||||
from models import Vehicle, FuelRecord
|
||||
from schemas import (
|
||||
VehicleCreate,
|
||||
VehicleUpdate,
|
||||
VehicleDelete,
|
||||
VehicleResponse,
|
||||
VehicleStats,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/vehicles", tags=["vehicles"])
|
||||
|
||||
|
||||
@router.get("/list", response_model=list[VehicleResponse])
|
||||
def get_vehicles():
|
||||
"""获取所有车辆列表(排除已删除)"""
|
||||
with get_session() as session:
|
||||
vehicles = (
|
||||
session.query(Vehicle)
|
||||
.filter(Vehicle.is_deleted == False)
|
||||
.order_by(Vehicle.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return vehicles
|
||||
|
||||
|
||||
@router.post("/create", response_model=VehicleResponse)
|
||||
def create_vehicle(vehicle: VehicleCreate):
|
||||
"""添加新车辆"""
|
||||
with get_session() as session:
|
||||
db_vehicle = Vehicle(
|
||||
name=vehicle.name,
|
||||
purchase_date=vehicle.purchase_date,
|
||||
initial_mileage=vehicle.initial_mileage,
|
||||
)
|
||||
session.add(db_vehicle)
|
||||
session.commit()
|
||||
session.refresh(db_vehicle)
|
||||
return db_vehicle
|
||||
|
||||
|
||||
@router.post("/update", response_model=VehicleResponse)
|
||||
def update_vehicle(vehicle: VehicleUpdate):
|
||||
"""更新车辆信息"""
|
||||
with get_session() as session:
|
||||
db_vehicle = session.query(Vehicle).filter(Vehicle.id == vehicle.id).first()
|
||||
if not db_vehicle:
|
||||
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||
|
||||
if vehicle.name is not None:
|
||||
db_vehicle.name = vehicle.name
|
||||
if vehicle.purchase_date is not None:
|
||||
db_vehicle.purchase_date = vehicle.purchase_date
|
||||
if vehicle.initial_mileage is not None:
|
||||
db_vehicle.initial_mileage = vehicle.initial_mileage
|
||||
|
||||
session.commit()
|
||||
session.refresh(db_vehicle)
|
||||
return db_vehicle
|
||||
|
||||
|
||||
@router.post("/delete")
|
||||
def delete_vehicle(vehicle: VehicleDelete):
|
||||
"""软删除车辆"""
|
||||
with get_session() as session:
|
||||
db_vehicle = (
|
||||
session.query(Vehicle)
|
||||
.filter(Vehicle.id == vehicle.id, Vehicle.is_deleted == False)
|
||||
.first()
|
||||
)
|
||||
if not db_vehicle:
|
||||
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||
|
||||
db_vehicle.is_deleted = True
|
||||
session.commit()
|
||||
return {"message": "Vehicle deleted successfully"}
|
||||
|
||||
|
||||
@router.get("/stats", response_model=VehicleStats)
|
||||
def get_vehicle_stats(vehicle_id: int):
|
||||
"""获取车辆统计信息"""
|
||||
with get_session() as session:
|
||||
vehicle = (
|
||||
session.query(Vehicle)
|
||||
.filter(Vehicle.id == vehicle_id, Vehicle.is_deleted == False)
|
||||
.first()
|
||||
)
|
||||
if not vehicle:
|
||||
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||
|
||||
# 获取所有未删除的加油记录
|
||||
fuel_records = (
|
||||
session.query(FuelRecord)
|
||||
.filter(FuelRecord.vehicle_id == vehicle_id, FuelRecord.is_deleted == False)
|
||||
.order_by(FuelRecord.date.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
# 计算总里程
|
||||
if fuel_records:
|
||||
latest_mileage = max(r.mileage for r in fuel_records)
|
||||
total_mileage = latest_mileage - vehicle.initial_mileage
|
||||
else:
|
||||
total_mileage = 0
|
||||
|
||||
# 计算总油费和总加油量(只算加满的记录)
|
||||
total_fuel_cost = sum(float(r.total_cost) for r in fuel_records)
|
||||
total_fuel_amount = sum(
|
||||
float(r.fuel_amount) for r in fuel_records if r.is_full_tank
|
||||
)
|
||||
|
||||
# 计算平均油耗
|
||||
avg_fuel_consumption = None
|
||||
if total_mileage > 0 and total_fuel_amount > 0:
|
||||
avg_fuel_consumption = round((total_fuel_amount / total_mileage) * 100, 2)
|
||||
|
||||
return VehicleStats(
|
||||
total_mileage=total_mileage,
|
||||
total_fuel_cost=round(total_fuel_cost, 2),
|
||||
total_fuel_amount=round(total_fuel_amount, 2),
|
||||
avg_fuel_consumption=avg_fuel_consumption,
|
||||
)
|
||||
259
api/schemas.py
Normal file
259
api/schemas.py
Normal file
@ -0,0 +1,259 @@
|
||||
"""
|
||||
统一的 Pydantic 数据模型
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, field_validator, ConfigDict
|
||||
from typing import Optional, List, Union
|
||||
from datetime import date, datetime
|
||||
|
||||
|
||||
# ==================== 车辆相关模型 ====================
|
||||
|
||||
|
||||
class VehicleBase(BaseModel):
|
||||
"""车辆基础模型"""
|
||||
|
||||
name: str
|
||||
purchase_date: Optional[date] = None
|
||||
initial_mileage: int = 0
|
||||
|
||||
|
||||
class VehicleCreate(VehicleBase):
|
||||
"""创建车辆请求模型"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class VehicleUpdate(BaseModel):
|
||||
"""更新车辆请求模型"""
|
||||
|
||||
id: int
|
||||
name: Optional[str] = None
|
||||
purchase_date: Optional[date] = None
|
||||
initial_mileage: Optional[int] = None
|
||||
|
||||
|
||||
class VehicleDelete(BaseModel):
|
||||
"""删除车辆请求模型"""
|
||||
|
||||
id: int
|
||||
|
||||
|
||||
class VehicleResponse(VehicleBase):
|
||||
"""车辆响应模型"""
|
||||
|
||||
id: int
|
||||
is_deleted: bool = False
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class VehicleStats(BaseModel):
|
||||
"""车辆统计信息"""
|
||||
|
||||
total_mileage: int
|
||||
total_fuel_cost: float
|
||||
total_fuel_amount: float
|
||||
avg_fuel_consumption: Optional[float]
|
||||
|
||||
|
||||
# ==================== 加油记录相关模型 ====================
|
||||
|
||||
|
||||
class FuelRecordBase(BaseModel):
|
||||
"""加油记录基础模型"""
|
||||
|
||||
vehicle_id: int
|
||||
date: date
|
||||
mileage: int
|
||||
fuel_amount: float
|
||||
fuel_price: Optional[float] = None
|
||||
total_cost: Optional[float] = None
|
||||
is_full_tank: bool = True
|
||||
notes: Optional[str] = ""
|
||||
|
||||
|
||||
class FuelRecordCreate(FuelRecordBase):
|
||||
"""创建加油记录请求模型"""
|
||||
|
||||
@field_validator("total_cost", "fuel_price", mode="before")
|
||||
@classmethod
|
||||
def validate_costs(cls, v):
|
||||
if v is not None:
|
||||
return round(v, 2)
|
||||
return v
|
||||
|
||||
|
||||
class FuelRecordUpdate(BaseModel):
|
||||
"""更新加油记录请求模型"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
id: int
|
||||
date: Union[date, str, None] = None
|
||||
mileage: Optional[int] = None
|
||||
fuel_amount: Optional[float] = None
|
||||
fuel_price: Optional[float] = None
|
||||
total_cost: Optional[float] = None
|
||||
is_full_tank: Optional[bool] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
@field_validator("date", mode="before")
|
||||
@classmethod
|
||||
def parse_date(cls, v):
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, date):
|
||||
return v
|
||||
if isinstance(v, str):
|
||||
return date.fromisoformat(v)
|
||||
raise ValueError("Invalid date format")
|
||||
|
||||
|
||||
class FuelRecordDelete(BaseModel):
|
||||
"""删除加油记录请求模型"""
|
||||
|
||||
id: int
|
||||
|
||||
|
||||
class FuelRecordResponse(BaseModel):
|
||||
"""加油记录响应模型"""
|
||||
|
||||
id: int
|
||||
vehicle_id: int
|
||||
date: date
|
||||
mileage: int
|
||||
fuel_amount: float
|
||||
fuel_price: Optional[float]
|
||||
total_cost: float
|
||||
is_full_tank: bool
|
||||
notes: str
|
||||
fuel_consumption: Optional[float] = None # 单次油耗,计算得出
|
||||
is_deleted: bool = False
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ==================== 费用记录相关模型 ====================
|
||||
|
||||
# 费用类型枚举
|
||||
COST_TYPES = ["保养", "维修", "保险", "停车", "过路费", "洗车", "违章", "其他"]
|
||||
|
||||
|
||||
class CostBase(BaseModel):
|
||||
"""费用记录基础模型"""
|
||||
|
||||
vehicle_id: int
|
||||
date: date
|
||||
type: str
|
||||
amount: float
|
||||
mileage: Optional[int] = None
|
||||
notes: Optional[str] = ""
|
||||
is_installment: Optional[bool] = False
|
||||
installment_months: Optional[int] = 12
|
||||
|
||||
|
||||
class CostCreate(CostBase):
|
||||
"""创建费用记录请求模型"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class CostUpdate(BaseModel):
|
||||
"""更新费用记录请求模型"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
id: int
|
||||
date: Union[date, str, None] = None
|
||||
type: Optional[str] = None
|
||||
amount: Optional[float] = None
|
||||
mileage: Optional[int] = None
|
||||
notes: Optional[str] = None
|
||||
is_installment: Optional[bool] = None
|
||||
installment_months: Optional[int] = None
|
||||
|
||||
@field_validator("date", mode="before")
|
||||
@classmethod
|
||||
def parse_date(cls, v):
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, date):
|
||||
return v
|
||||
if isinstance(v, str):
|
||||
return date.fromisoformat(v)
|
||||
raise ValueError("Invalid date format")
|
||||
|
||||
|
||||
class CostDelete(BaseModel):
|
||||
"""删除费用记录请求模型"""
|
||||
|
||||
id: int
|
||||
|
||||
|
||||
class CostResponse(BaseModel):
|
||||
"""费用记录响应模型"""
|
||||
|
||||
id: int
|
||||
vehicle_id: int
|
||||
date: date
|
||||
type: str
|
||||
amount: float
|
||||
mileage: Optional[int]
|
||||
notes: str
|
||||
is_installment: bool
|
||||
installment_months: int
|
||||
is_deleted: bool = False
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ==================== 仪表板相关模型 ====================
|
||||
|
||||
|
||||
class FuelRecordItem(BaseModel):
|
||||
"""仪表板中使用的加油记录项"""
|
||||
|
||||
id: int
|
||||
date: date
|
||||
mileage: int
|
||||
fuel_amount: float
|
||||
total_cost: float
|
||||
fuel_consumption: Optional[float]
|
||||
|
||||
|
||||
class DashboardData(BaseModel):
|
||||
"""仪表板数据响应"""
|
||||
|
||||
vehicle_id: int
|
||||
vehicle_name: str
|
||||
purchase_date: Optional[date]
|
||||
total_mileage: int
|
||||
total_fuel_cost: float
|
||||
avg_fuel_consumption: Optional[float]
|
||||
avg_daily_km: Optional[float]
|
||||
recent_fuel_records: List[FuelRecordItem]
|
||||
|
||||
|
||||
# ==================== 通用响应模型 ====================
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
"""通用消息响应"""
|
||||
|
||||
message: str
|
||||
|
||||
|
||||
class CostTypesResponse(BaseModel):
|
||||
"""费用类型列表响应"""
|
||||
|
||||
types: List[str]
|
||||
24
web/.gitignore
vendored
Normal file
24
web/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
3
web/.vscode/extensions.json
vendored
Normal file
3
web/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
5
web/README.md
Normal file
5
web/README.md
Normal file
@ -0,0 +1,5 @@
|
||||
# Vue 3 + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||
13
web/index.html
Normal file
13
web/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>carcost</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1619
web/package-lock.json
generated
Normal file
1619
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
web/package.json
Normal file
22
web/package.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "carcost",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.14.0",
|
||||
"echarts": "^6.0.0",
|
||||
"element-plus": "^2.13.6",
|
||||
"vue": "^3.5.32",
|
||||
"vue-echarts": "^8.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"vite": "^8.0.4"
|
||||
}
|
||||
}
|
||||
1
web/public/favicon.svg
Normal file
1
web/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
web/public/icons.svg
Normal file
24
web/public/icons.svg
Normal file
@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
451
web/src/App.vue
Normal file
451
web/src/App.vue
Normal file
@ -0,0 +1,451 @@
|
||||
<template>
|
||||
<div class="app">
|
||||
<el-container>
|
||||
<!-- 头部 -->
|
||||
<el-header class="header">
|
||||
<div class="header-inner">
|
||||
<div class="header-content">
|
||||
<h1 class="app-title" @click="mobileView = 'dashboard'">🚗 CarCost</h1>
|
||||
|
||||
<!-- 桌面端:车辆选择 + 添加按钮 -->
|
||||
<div class="desktop-nav">
|
||||
<el-select
|
||||
v-model="selectedVehicle"
|
||||
placeholder="选择车辆"
|
||||
style="width: 180px"
|
||||
@change="onVehicleChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="vehicle in vehicles"
|
||||
:key="vehicle.id"
|
||||
:label="vehicle.name"
|
||||
:value="vehicle.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-divider direction="vertical" />
|
||||
<el-button @click="showAddVehicle = true" title="添加车辆">
|
||||
<el-icon><Plus /></el-icon> 车辆
|
||||
</el-button>
|
||||
<el-button v-if="selectedVehicle" @click="editCurrentVehicle" title="编辑车辆">
|
||||
<el-icon><Edit /></el-icon> 编辑
|
||||
</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<el-button @click="showAddFuel = true" title="添加加油记录">
|
||||
<el-icon><Plus /></el-icon> 加油
|
||||
</el-button>
|
||||
<el-button @click="showAddCost = true" title="添加费用记录">
|
||||
<el-icon><Plus /></el-icon> 费用
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 移动端:车辆选择 + 添加按钮 + 汉堡菜单 -->
|
||||
<div class="mobile-nav">
|
||||
<el-dropdown trigger="click" v-if="vehicles.length > 0">
|
||||
<el-button>
|
||||
{{ selectedVehicleName || '选择车辆' }}
|
||||
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="vehicle in vehicles"
|
||||
:key="vehicle.id"
|
||||
@click="selectVehicle(vehicle.id)"
|
||||
>
|
||||
{{ vehicle.name }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided @click="showAddVehicle = true">+ 添加车辆</el-dropdown-item>
|
||||
<el-dropdown-item v-if="selectedVehicle" @click="editCurrentVehicle">✏️ 编辑车辆</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<el-button v-if="mobileView === 'fuel'" @click="showAddFuel = true">
|
||||
<el-icon><Plus /></el-icon>
|
||||
</el-button>
|
||||
<el-button v-if="mobileView === 'cost'" @click="showAddCost = true">
|
||||
<el-icon><Plus /></el-icon>
|
||||
</el-button>
|
||||
<el-dropdown trigger="click">
|
||||
<el-button>
|
||||
<el-icon><Menu /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="mobileView = 'fuel'">⛽ 加油记录</el-dropdown-item>
|
||||
<el-dropdown-item @click="mobileView = 'cost'">💰 费用记录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<el-main>
|
||||
<div class="content-wrapper">
|
||||
<!-- 移动端:车辆选择 -->
|
||||
<div class="mobile-vehicle-select">
|
||||
<el-select
|
||||
v-model="selectedVehicle"
|
||||
placeholder="选择车辆"
|
||||
style="width: 100%"
|
||||
@change="onVehicleChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="vehicle in vehicles"
|
||||
:key="vehicle.id"
|
||||
:label="vehicle.name"
|
||||
:value="vehicle.id"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 桌面端布局 -->
|
||||
<div class="desktop-layout">
|
||||
<!-- 上方统计卡片 -->
|
||||
<StatsCards :dashboard-data="dashboardData" :total-cost="totalCost" />
|
||||
|
||||
<!-- 四个图表 -->
|
||||
<DashboardCharts :vehicle-id="selectedVehicle" />
|
||||
|
||||
<!-- 下方左右两栏:列表 -->
|
||||
<el-row :gutter="20" class="records-row">
|
||||
<el-col :span="12">
|
||||
<FuelRecordsPanel
|
||||
:vehicle-id="selectedVehicle"
|
||||
:show-charts="false"
|
||||
:show-list="true"
|
||||
@add-fuel="showAddFuel = true"
|
||||
/>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<CostRecordsPanel
|
||||
:vehicle-id="selectedVehicle"
|
||||
:show-charts="false"
|
||||
:show-list="true"
|
||||
@add-cost="showAddCost = true"
|
||||
/>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<!-- 移动端布局 -->
|
||||
<div class="mobile-layout">
|
||||
<!-- 仪表盘 -->
|
||||
<div v-if="mobileView === 'dashboard'">
|
||||
<StatsCards :dashboard-data="dashboardData" :total-cost="totalCost" />
|
||||
<!-- 移动端图表 -->
|
||||
<DashboardCharts :vehicle-id="selectedVehicle" />
|
||||
</div>
|
||||
|
||||
<!-- 加油记录 -->
|
||||
<div v-if="mobileView === 'fuel'">
|
||||
<FuelRecordsPanel
|
||||
:vehicle-id="selectedVehicle"
|
||||
:show-charts="false"
|
||||
:show-list="true"
|
||||
@add-fuel="showAddFuel = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 费用记录 -->
|
||||
<div v-if="mobileView === 'cost'">
|
||||
<CostRecordsPanel
|
||||
:vehicle-id="selectedVehicle"
|
||||
:show-charts="false"
|
||||
:show-list="true"
|
||||
@add-cost="showAddCost = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-main>
|
||||
</el-container>
|
||||
|
||||
<!-- 弹窗 -->
|
||||
<AddVehicleDialog v-model:visible="showAddVehicle" @success="loadVehicles" />
|
||||
<EditVehicleDialog v-model:visible="showEditVehicle" :vehicle="currentVehicle" @success="loadVehicles" />
|
||||
<AddFuelDialog v-model:visible="showAddFuel" :vehicle-id="selectedVehicle" @success="onRecordAdded" />
|
||||
<AddCostDialog v-model:visible="showAddCost" :vehicle-id="selectedVehicle" @success="onRecordAdded" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Menu, ArrowDown, Plus, Edit } from '@element-plus/icons-vue'
|
||||
import AddVehicleDialog from './components/dialogs/vehicle/AddVehicleDialog.vue'
|
||||
import EditVehicleDialog from './components/dialogs/vehicle/EditVehicleDialog.vue'
|
||||
import AddFuelDialog from './components/dialogs/fuel/AddFuelDialog.vue'
|
||||
import AddCostDialog from './components/dialogs/cost/AddCostDialog.vue'
|
||||
import StatsCards from './components/cards/StatsCards.vue'
|
||||
import DashboardCharts from './components/charts/DashboardCharts.vue'
|
||||
import FuelRecordsPanel from './components/panels/FuelRecordsPanel.vue'
|
||||
import CostRecordsPanel from './components/panels/CostRecordsPanel.vue'
|
||||
|
||||
const API_BASE = 'https://api.yuany3721.site/carcost'
|
||||
|
||||
const vehicles = ref([])
|
||||
const selectedVehicle = ref(null)
|
||||
const dashboardData = ref(null)
|
||||
const totalCost = ref(0)
|
||||
const mobileView = ref('dashboard') // 'dashboard', 'fuel', 'cost'
|
||||
|
||||
// 当前选中车辆名称
|
||||
const selectedVehicleName = computed(() => {
|
||||
const vehicle = vehicles.value.find(v => v.id === selectedVehicle.value)
|
||||
return vehicle?.name || ''
|
||||
})
|
||||
|
||||
const showAddVehicle = ref(false)
|
||||
const showAddFuel = ref(false)
|
||||
const showAddCost = ref(false)
|
||||
const showEditVehicle = ref(false)
|
||||
const currentVehicle = ref(null)
|
||||
|
||||
// 加载车辆列表
|
||||
const loadVehicles = async () => {
|
||||
try {
|
||||
const res = await axios.get(`${API_BASE}/vehicles/list`)
|
||||
vehicles.value = res.data
|
||||
if (vehicles.value.length > 0 && !selectedVehicle.value) {
|
||||
selectedVehicle.value = vehicles.value[0].id
|
||||
loadDashboard()
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('加载车辆失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 加载仪表盘数据
|
||||
const loadDashboard = async () => {
|
||||
if (!selectedVehicle.value) return
|
||||
try {
|
||||
const res = await axios.get(`${API_BASE}/dashboard/data?vehicle_id=${selectedVehicle.value}`)
|
||||
dashboardData.value = res.data
|
||||
|
||||
// 加载总费用
|
||||
const costRes = await axios.get(`${API_BASE}/costs/list?vehicle_id=${selectedVehicle.value}&limit=1000`)
|
||||
const costs = costRes.data || []
|
||||
totalCost.value = costs.reduce((sum, c) => sum + c.amount, 0)
|
||||
} catch (error) {
|
||||
ElMessage.error('加载数据失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 切换车辆
|
||||
const onVehicleChange = () => {
|
||||
loadDashboard()
|
||||
}
|
||||
|
||||
// 移动端选择车辆
|
||||
const selectVehicle = (id) => {
|
||||
selectedVehicle.value = id
|
||||
onVehicleChange()
|
||||
}
|
||||
|
||||
// 编辑当前车辆
|
||||
const editCurrentVehicle = () => {
|
||||
if (!selectedVehicle.value) return
|
||||
currentVehicle.value = vehicles.value.find(v => v.id === selectedVehicle.value)
|
||||
showEditVehicle.value = true
|
||||
}
|
||||
|
||||
// 记录添加成功后刷新
|
||||
const onRecordAdded = () => {
|
||||
loadDashboard()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadVehicles()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 全局样式,去掉所有默认 margin/padding */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.header-inner {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 60px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-title:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.desktop-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mobile-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-vehicle-select {
|
||||
display: none;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.desktop-layout {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.mobile-layout {
|
||||
display: none;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.records-row {
|
||||
margin: 0 -10px !important;
|
||||
width: calc(100% + 20px);
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.records-row :deep(.el-col) {
|
||||
padding-left: 10px !important;
|
||||
padding-right: 10px !important;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.records-row :deep(.el-col > *) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mobile-charts {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.mobile-tip {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* 调整 main 的 padding */
|
||||
:deep(.el-main) {
|
||||
padding: 20px !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
:deep(.el-main) {
|
||||
padding: 10px !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-container) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* 移动端内容区域 */
|
||||
.mobile-layout {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
:deep(.el-main) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 弹窗默认样式(电脑端) */
|
||||
:deep(.el-dialog) {
|
||||
width: 450px !important;
|
||||
}
|
||||
|
||||
/* 移动端弹窗适配 */
|
||||
@media (max-width: 768px) {
|
||||
:deep(.el-dialog) {
|
||||
width: 90% !important;
|
||||
max-width: 350px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 移动端适配 */
|
||||
@media (max-width: 768px) {
|
||||
.desktop-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mobile-vehicle-select {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.desktop-layout {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-layout {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
BIN
web/src/assets/hero.png
Normal file
BIN
web/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
1
web/src/assets/vite.svg
Normal file
1
web/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
1
web/src/assets/vue.svg
Normal file
1
web/src/assets/vue.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
110
web/src/components/cards/StatsCards.vue
Normal file
110
web/src/components/cards/StatsCards.vue
Normal file
@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<el-card class="stats-card" v-if="dashboardData">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="12" :sm="8" :md="4">
|
||||
<el-statistic :value="dashboardData.total_mileage || 0" title="总里程">
|
||||
<template #suffix>km</template>
|
||||
</el-statistic>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="8" :md="4">
|
||||
<el-statistic :value="dashboardData.total_fuel_cost || 0" title="总油费">
|
||||
<template #prefix>¥</template>
|
||||
</el-statistic>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="8" :md="4">
|
||||
<el-statistic :value="totalCost" title="总费用">
|
||||
<template #prefix>¥</template>
|
||||
</el-statistic>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="8" :md="4">
|
||||
<el-statistic :value="fuelCostPerKm" :precision="2" title="每公里油费">
|
||||
<template #prefix>¥</template>
|
||||
</el-statistic>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="8" :md="4">
|
||||
<el-statistic :value="totalCostPerKm" :precision="2" title="每公里总成本">
|
||||
<template #prefix>¥</template>
|
||||
</el-statistic>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="8" :md="4">
|
||||
<el-statistic :value="dashboardData?.avg_daily_km || 0" title="日均行程">
|
||||
<template #suffix>km</template>
|
||||
</el-statistic>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
<el-empty v-else description="暂无数据" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
dashboardData: Object,
|
||||
totalCost: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
})
|
||||
|
||||
// 每公里油费
|
||||
const fuelCostPerKm = computed(() => {
|
||||
if (!props.dashboardData?.total_mileage || props.dashboardData.total_mileage === 0) return 0
|
||||
return (props.dashboardData.total_fuel_cost || 0) / props.dashboardData.total_mileage
|
||||
})
|
||||
|
||||
// 每公里总成本
|
||||
const totalCostPerKm = computed(() => {
|
||||
if (!props.dashboardData?.total_mileage || props.dashboardData.total_mileage === 0) return 0
|
||||
const total = (props.dashboardData.total_fuel_cost || 0) + props.totalCost
|
||||
return total / props.dashboardData.total_mileage
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stats-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stats-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
:deep(.el-statistic) {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
:deep(.el-statistic__content) {
|
||||
font-size: 20px;
|
||||
color: #409eff;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
:deep(.el-statistic__title) {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-card :deep(.el-card__body) {
|
||||
padding: 15px 10px;
|
||||
}
|
||||
|
||||
:deep(.el-col) {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
:deep(.el-col:nth-last-child(-n+2)) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.el-statistic__content) {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
:deep(.el-statistic__title) {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
657
web/src/components/charts/DashboardCharts.vue
Normal file
657
web/src/components/charts/DashboardCharts.vue
Normal file
@ -0,0 +1,657 @@
|
||||
<template>
|
||||
<el-card class="dashboard-charts-card">
|
||||
<!-- 时间选择器 -->
|
||||
<div class="time-range-selector">
|
||||
<el-radio-group v-model="timeRange" size="small">
|
||||
<el-radio-button value="past_6months">近6个月</el-radio-button>
|
||||
<el-radio-button value="past_year">近一年</el-radio-button>
|
||||
<el-radio-button value="all">全部</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<!-- 四个图表 -->
|
||||
<div class="charts-grid">
|
||||
<!-- 费用类型分布 -->
|
||||
<div class="chart-item" v-if="pieChartOption && allRecords.length > 0">
|
||||
<div class="chart-title">📊 费用类型分布</div>
|
||||
<div class="chart-content">
|
||||
<v-chart
|
||||
:option="pieChartOption"
|
||||
autoresize
|
||||
style="height: 220px; width: 100%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-item empty-chart" v-else>
|
||||
<div class="chart-title">📊 费用类型分布</div>
|
||||
<el-empty description="暂无数据" :image-size="60" />
|
||||
</div>
|
||||
|
||||
<!-- 油耗趋势 -->
|
||||
<div class="chart-item" v-if="consumptionChartOption && allRecords.length > 0">
|
||||
<div class="chart-title">⛽ 油耗趋势</div>
|
||||
<div class="chart-content">
|
||||
<v-chart
|
||||
:option="consumptionChartOption"
|
||||
autoresize
|
||||
style="height: 200px; width: 100%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-item empty-chart" v-else>
|
||||
<div class="chart-title">⛽ 油耗趋势</div>
|
||||
<el-empty description="暂无数据" :image-size="60" />
|
||||
</div>
|
||||
|
||||
<!-- 月度费用统计 -->
|
||||
<div class="chart-item" v-if="monthlyChartOption && allRecords.length > 0">
|
||||
<div class="chart-title">📈 月度费用统计</div>
|
||||
<div class="chart-content">
|
||||
<v-chart
|
||||
:option="monthlyChartOption"
|
||||
autoresize
|
||||
style="height: 200px; width: 100%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-item empty-chart" v-else>
|
||||
<div class="chart-title">📈 月度费用统计</div>
|
||||
<el-empty description="暂无数据" :image-size="60" />
|
||||
</div>
|
||||
|
||||
<!-- 月度油费 -->
|
||||
<div class="chart-item" v-if="monthlyCostChartOption && fuelRecords.length > 0">
|
||||
<div class="chart-title">💰 月度油费</div>
|
||||
<div class="chart-content">
|
||||
<v-chart
|
||||
:option="monthlyCostChartOption"
|
||||
autoresize
|
||||
style="height: 200px; width: 100%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-item empty-chart" v-else>
|
||||
<div class="chart-title">💰 月度油费</div>
|
||||
<el-empty description="暂无数据" :image-size="60" />
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from "vue";
|
||||
import axios from "axios";
|
||||
|
||||
const props = defineProps({
|
||||
vehicleId: Number,
|
||||
});
|
||||
|
||||
const API_BASE = "https://api.yuany3721.site/carcost";
|
||||
|
||||
// 检测是否为移动端
|
||||
const isMobile = ref(false);
|
||||
|
||||
// 统一的时间范围(根据设备类型设置默认值)
|
||||
const timeRange = ref("past_year");
|
||||
|
||||
onMounted(() => {
|
||||
isMobile.value = window.innerWidth <= 768;
|
||||
timeRange.value = isMobile.value ? "past_6months" : "past_year";
|
||||
});
|
||||
|
||||
// 数据
|
||||
const allRecords = ref([]);
|
||||
const fuelRecords = ref([]);
|
||||
|
||||
// 加载数据
|
||||
const loadData = async () => {
|
||||
if (!props.vehicleId) return;
|
||||
try {
|
||||
const [fuelRes, costRes] = await Promise.all([
|
||||
axios.get(`${API_BASE}/fuel-records/list?vehicle_id=${props.vehicleId}&limit=1000`),
|
||||
axios.get(`${API_BASE}/costs/list?vehicle_id=${props.vehicleId}&limit=1000`),
|
||||
]);
|
||||
fuelRecords.value = fuelRes.data || [];
|
||||
allRecords.value = costRes.data || [];
|
||||
} catch (error) {
|
||||
console.error("加载数据失败");
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => props.vehicleId, loadData, { immediate: true });
|
||||
|
||||
// 计算费用分摊明细表
|
||||
const costInstallments = computed(() => {
|
||||
const installments = [];
|
||||
|
||||
allRecords.value.forEach((cost) => {
|
||||
if (cost.is_installment && cost.installment_months > 1) {
|
||||
// 分摊费用,生成多条记录
|
||||
const monthlyAmount = cost.amount / cost.installment_months;
|
||||
const startMonth = cost.date.slice(0, 7);
|
||||
|
||||
for (let i = 0; i < cost.installment_months; i++) {
|
||||
const month = getNextMonth(startMonth, i);
|
||||
installments.push({
|
||||
month: month,
|
||||
type: cost.type,
|
||||
amount: monthlyAmount,
|
||||
originalCostId: cost.id,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 一次性费用,生成一条记录
|
||||
installments.push({
|
||||
month: cost.date.slice(0, 7),
|
||||
type: cost.type,
|
||||
amount: cost.amount,
|
||||
originalCostId: cost.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return installments;
|
||||
});
|
||||
|
||||
// 获取N个月前的日期
|
||||
const getMonthsAgo = (months) => {
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth() - months, now.getDate());
|
||||
};
|
||||
|
||||
// 根据时间范围筛选记录
|
||||
const getFilteredRecords = () => {
|
||||
let filteredFuel = fuelRecords.value;
|
||||
let filteredCost = allRecords.value;
|
||||
|
||||
if (timeRange.value === "past_6months") {
|
||||
const sixMonthsAgo = getMonthsAgo(6);
|
||||
const cutoffDate = sixMonthsAgo.toISOString().slice(0, 10);
|
||||
filteredFuel = fuelRecords.value.filter((r) => r.date >= cutoffDate);
|
||||
filteredCost = allRecords.value.filter((r) => r.date >= cutoffDate);
|
||||
} else if (timeRange.value === "past_year") {
|
||||
const oneYearAgo = getMonthsAgo(12);
|
||||
const cutoffDate = oneYearAgo.toISOString().slice(0, 10);
|
||||
filteredFuel = fuelRecords.value.filter((r) => r.date >= cutoffDate);
|
||||
filteredCost = allRecords.value.filter((r) => r.date >= cutoffDate);
|
||||
}
|
||||
|
||||
return { filteredFuel, filteredCost };
|
||||
};
|
||||
|
||||
// 油耗趋势图
|
||||
const consumptionChartOption = computed(() => {
|
||||
const { filteredFuel } = getFilteredRecords();
|
||||
const validRecords = filteredFuel.filter((r) => r.fuel_consumption);
|
||||
|
||||
if (validRecords.length === 0) return null;
|
||||
|
||||
const data = validRecords
|
||||
.sort((a, b) => a.mileage - b.mileage)
|
||||
.map((r) => ({
|
||||
value: [r.mileage, r.fuel_consumption],
|
||||
date: r.date,
|
||||
mileage: r.mileage,
|
||||
fuelAmount: r.fuel_amount,
|
||||
}));
|
||||
|
||||
const mileages = data.map((d) => d.value[0]);
|
||||
const minMileage = Math.min(...mileages);
|
||||
const maxMileage = Math.max(...mileages);
|
||||
const padding = (maxMileage - minMileage) * 0.05;
|
||||
|
||||
// 找出最高3个和最低3个
|
||||
const sorted = [...data].sort((a, b) => b.value[1] - a.value[1]);
|
||||
const top3 = sorted.slice(0, 3);
|
||||
const bottom3 = sorted.slice(-3);
|
||||
|
||||
const markPointData = [
|
||||
...top3.map((d) => ({
|
||||
coord: d.value,
|
||||
value: d.value[1].toFixed(1),
|
||||
itemStyle: { color: "#f56c6c" },
|
||||
label: { color: "#f56c6c", position: "top" },
|
||||
})),
|
||||
...bottom3.map((d) => ({
|
||||
coord: d.value,
|
||||
value: d.value[1].toFixed(1),
|
||||
itemStyle: { color: "#67c23a" },
|
||||
label: { color: "#67c23a", position: "bottom" },
|
||||
})),
|
||||
];
|
||||
|
||||
return {
|
||||
grid: { left: "12%", right: "5%", top: "15%", bottom: "15%" },
|
||||
xAxis: {
|
||||
type: "value",
|
||||
name: "km",
|
||||
min: Math.floor(minMileage - padding),
|
||||
max: Math.ceil(maxMileage + padding),
|
||||
nameTextStyle: { fontSize: 11 },
|
||||
axisLabel: { fontSize: 11 },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
name: "L/100km",
|
||||
nameTextStyle: { fontSize: 11 },
|
||||
axisLabel: { fontSize: 11 },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: "line",
|
||||
data: data.map((d) => d.value),
|
||||
smooth: true,
|
||||
symbol: "circle",
|
||||
symbolSize: 6,
|
||||
lineStyle: { color: "#409eff", width: 2 },
|
||||
itemStyle: { color: "#409eff" },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: "linear",
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: "rgba(64, 158, 255, 0.3)" },
|
||||
{ offset: 1, color: "rgba(64, 158, 255, 0.05)" },
|
||||
],
|
||||
},
|
||||
},
|
||||
markPoint: {
|
||||
data: markPointData,
|
||||
symbol: "circle",
|
||||
symbolSize: 10,
|
||||
itemStyle: { borderColor: "#fff", borderWidth: 2 },
|
||||
label: { fontSize: 9, formatter: "{c}" },
|
||||
},
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: "none",
|
||||
data: [
|
||||
{
|
||||
type: "average",
|
||||
name: "平均",
|
||||
label: { formatter: "{c}", fontSize: 8, position: "insideEndTop" },
|
||||
},
|
||||
],
|
||||
lineStyle: { color: "#909399", type: "dashed", width: 1 },
|
||||
},
|
||||
},
|
||||
],
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
confine: true,
|
||||
formatter: function (params) {
|
||||
const d = data[params.dataIndex];
|
||||
return `${d.date}<br/>里程: ${d.mileage}km<br/>加油: ${d.fuelAmount}L<br/>油耗: <b>${params.value[1]} L/100km</b>`;
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// 月度油费图
|
||||
const monthlyCostChartOption = computed(() => {
|
||||
const { filteredFuel } = getFilteredRecords();
|
||||
|
||||
const monthlyData = {};
|
||||
filteredFuel.forEach((r) => {
|
||||
const month = r.date.slice(0, 7);
|
||||
monthlyData[month] = (monthlyData[month] || 0) + r.total_cost;
|
||||
});
|
||||
|
||||
const sortedMonths = Object.keys(monthlyData).sort();
|
||||
if (sortedMonths.length === 0) return null;
|
||||
|
||||
return {
|
||||
grid: { left: "5%", right: "5%", top: "15%", bottom: "15%", containLabel: true },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: sortedMonths.map((m) => m.slice(5)),
|
||||
axisLabel: { fontSize: 10 },
|
||||
axisLine: { show: true },
|
||||
axisTick: { show: false },
|
||||
},
|
||||
yAxis: { type: "value", show: false },
|
||||
series: [
|
||||
{
|
||||
type: "bar",
|
||||
data: sortedMonths.map((m) => monthlyData[m].toFixed(0)),
|
||||
barWidth: "60%",
|
||||
itemStyle: {
|
||||
color: "rgba(103, 194, 58, 0.6)",
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: "top",
|
||||
fontSize: 9,
|
||||
formatter: "{c}",
|
||||
},
|
||||
},
|
||||
],
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
axisPointer: { type: "shadow" },
|
||||
confine: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// 颜色映射
|
||||
const typeColorMap = {
|
||||
保养: "#67c23a",
|
||||
维修: "#e6a23c",
|
||||
保险: "#f56c6c",
|
||||
停车: "#909399",
|
||||
过路费: "#9a60b4",
|
||||
其他: "#fc8452",
|
||||
油费: "#409eff",
|
||||
};
|
||||
|
||||
// 费用类型分布
|
||||
const pieChartOption = computed(() => {
|
||||
const { filteredFuel } = getFilteredRecords();
|
||||
|
||||
// 计算时间范围
|
||||
const now = new Date();
|
||||
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||
|
||||
// 确定筛选的月份范围
|
||||
let targetMonths = [];
|
||||
if (timeRange.value === "past_6months") {
|
||||
// 近6个月:从当前月往前推5个月(共6个月)
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
targetMonths.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`);
|
||||
}
|
||||
} else if (timeRange.value === "past_year") {
|
||||
// 近一年:从当前月往前推11个月(共12个月)
|
||||
for (let i = 11; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
targetMonths.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`);
|
||||
}
|
||||
}
|
||||
// "全部"模式下不过滤月份
|
||||
|
||||
const typeData = {};
|
||||
let totalAmount = 0;
|
||||
|
||||
// 从分摊明细表中筛选数据
|
||||
costInstallments.value.forEach((item) => {
|
||||
if (timeRange.value === "all" || targetMonths.includes(item.month)) {
|
||||
typeData[item.type] = (typeData[item.type] || 0) + item.amount;
|
||||
totalAmount += item.amount;
|
||||
}
|
||||
});
|
||||
|
||||
let totalFuelCost = 0;
|
||||
filteredFuel.forEach((r) => {
|
||||
totalFuelCost += r.total_cost;
|
||||
});
|
||||
if (totalFuelCost > 0) {
|
||||
typeData["油费"] = totalFuelCost;
|
||||
totalAmount += totalFuelCost;
|
||||
}
|
||||
|
||||
const data = Object.entries(typeData)
|
||||
.map(([name, value]) => ({ name, value: parseFloat(value.toFixed(0)) }))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
|
||||
if (data.length === 0) return null;
|
||||
|
||||
return {
|
||||
tooltip: { trigger: "item", formatter: "{b}: ¥{c} ({d}%)" },
|
||||
legend: {
|
||||
orient: "vertical",
|
||||
right: "5%",
|
||||
top: "center",
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
textStyle: { fontSize: 12 },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: "pie",
|
||||
radius: ["45%", "70%"],
|
||||
center: ["35%", "50%"],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: { borderRadius: 5, borderColor: "#fff", borderWidth: 2 },
|
||||
label: { show: false },
|
||||
emphasis: {
|
||||
label: { show: true, fontSize: 12, fontWeight: "bold" },
|
||||
scale: true,
|
||||
scaleSize: 5,
|
||||
},
|
||||
data: data.map((d) => ({ ...d, itemStyle: { color: typeColorMap[d.name] || "#909399" } })),
|
||||
},
|
||||
],
|
||||
title: {
|
||||
text: `¥${totalAmount.toFixed(0)}`,
|
||||
left: "34.2%",
|
||||
top: "46%",
|
||||
textAlign: "center",
|
||||
textStyle: {
|
||||
fontSize: 16,
|
||||
color: "#303133",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// 月度费用统计
|
||||
const monthlyChartOption = computed(() => {
|
||||
const { filteredFuel } = getFilteredRecords();
|
||||
|
||||
const monthlyData = {};
|
||||
const allTypes = ["保养", "维修", "保险", "停车", "过路费", "其他", "油费"];
|
||||
|
||||
// 计算时间范围
|
||||
const now = new Date();
|
||||
|
||||
// 计算当前月份
|
||||
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||
|
||||
// 生成分摊明细表中涉及的所有月份
|
||||
const allMonthsSet = new Set();
|
||||
costInstallments.value.forEach((item) => allMonthsSet.add(item.month));
|
||||
filteredFuel.forEach((r) => allMonthsSet.add(r.date.slice(0, 7)));
|
||||
|
||||
let targetMonths = Array.from(allMonthsSet).sort();
|
||||
|
||||
// 根据时间范围筛选
|
||||
if (timeRange.value === "past_6months") {
|
||||
// 近6个月:从当前月往前推5个月(共6个月)
|
||||
const cutoffDate = new Date(now.getFullYear(), now.getMonth() - 5, 1);
|
||||
const cutoffMonth = `${cutoffDate.getFullYear()}-${String(cutoffDate.getMonth() + 1).padStart(2, "0")}`;
|
||||
targetMonths = targetMonths.filter((m) => m >= cutoffMonth && m <= currentMonth);
|
||||
} else if (timeRange.value === "past_year") {
|
||||
// 近一年:从当前月往前推11个月(共12个月)
|
||||
const cutoffDate = new Date(now.getFullYear(), now.getMonth() - 11, 1);
|
||||
const cutoffMonth = `${cutoffDate.getFullYear()}-${String(cutoffDate.getMonth() + 1).padStart(2, "0")}`;
|
||||
targetMonths = targetMonths.filter((m) => m >= cutoffMonth && m <= currentMonth);
|
||||
}
|
||||
// "全部"模式下显示所有月份(包括未来的分摊月份),不过滤
|
||||
|
||||
// 初始化月度数据
|
||||
targetMonths.forEach((month) => {
|
||||
monthlyData[month] = {};
|
||||
allTypes.forEach((type) => (monthlyData[month][type] = 0));
|
||||
});
|
||||
|
||||
// 使用分摊明细表统计费用
|
||||
costInstallments.value.forEach((item) => {
|
||||
if (monthlyData[item.month]) {
|
||||
monthlyData[item.month][item.type] += item.amount;
|
||||
}
|
||||
});
|
||||
|
||||
// 添加油费(使用筛选后的)
|
||||
filteredFuel.forEach((r) => {
|
||||
const month = r.date.slice(0, 7);
|
||||
if (monthlyData[month]) monthlyData[month]["油费"] += r.total_cost;
|
||||
});
|
||||
|
||||
// 确定存在的类型
|
||||
const existingTypes = allTypes.filter((type) => targetMonths.some((month) => monthlyData[month][type] > 0));
|
||||
|
||||
// 计算每月总计
|
||||
const monthlyTotals = targetMonths.map((month) => {
|
||||
const total = existingTypes.reduce((sum, type) => sum + monthlyData[month][type], 0);
|
||||
return total.toFixed(0);
|
||||
});
|
||||
|
||||
return {
|
||||
grid: { left: "3%", right: "3%", top: "20%", bottom: "10%", containLabel: true },
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
axisPointer: { type: "shadow" },
|
||||
confine: true,
|
||||
formatter: function (params) {
|
||||
const validParams = params.filter((p) => p.value > 0);
|
||||
if (validParams.length === 0) return "";
|
||||
let html = `<div style="font-weight:bold;margin-bottom:5px;">${params[0].axisValue}</div>`;
|
||||
validParams.forEach((p) => {
|
||||
html += `<div style="display:flex;align-items:center;margin:3px 0;">
|
||||
<span style="display:inline-block;width:10px;height:10px;background:${p.color};border-radius:50%;margin-right:5px;"></span>
|
||||
<span>${p.seriesName}: ¥${p.value.toFixed(0)}</span>
|
||||
</div>`;
|
||||
});
|
||||
const total = validParams.reduce((sum, p) => sum + p.value, 0);
|
||||
html += `<div style="border-top:1px solid #ccc;margin-top:5px;padding-top:5px;font-weight:bold;">总计: ¥${total.toFixed(0)}</div>`;
|
||||
return html;
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
top: "0%",
|
||||
textStyle: { fontSize: 11 },
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
data: existingTypes,
|
||||
},
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: targetMonths.map((m) => m.slice(5)),
|
||||
axisLabel: { fontSize: 10 },
|
||||
axisLine: { show: true },
|
||||
axisTick: { show: false },
|
||||
},
|
||||
yAxis: { type: "value", show: false },
|
||||
series: [
|
||||
...existingTypes.map((type, index) => ({
|
||||
name: type,
|
||||
type: "bar",
|
||||
stack: "total",
|
||||
data: targetMonths.map((month) => parseFloat(monthlyData[month][type].toFixed(0))),
|
||||
itemStyle: { color: typeColorMap[type] || "#909399" },
|
||||
})),
|
||||
{
|
||||
name: "总计",
|
||||
type: "bar",
|
||||
stack: "total",
|
||||
data: targetMonths.map(() => 0),
|
||||
label: {
|
||||
show: true,
|
||||
position: "top",
|
||||
fontSize: 9,
|
||||
formatter: function (params) {
|
||||
return monthlyTotals[params.dataIndex];
|
||||
},
|
||||
color: "#606266",
|
||||
distance: 5,
|
||||
},
|
||||
itemStyle: { color: "transparent" },
|
||||
tooltip: { show: false },
|
||||
showInLegend: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
// 辅助函数:获取下N个月
|
||||
const getNextMonth = (month, n) => {
|
||||
const [year, mon] = month.split("-").map(Number);
|
||||
let targetYear = year;
|
||||
let targetMon = mon + n;
|
||||
while (targetMon > 12) {
|
||||
targetMon -= 12;
|
||||
targetYear++;
|
||||
}
|
||||
return `${targetYear}-${String(targetMon).padStart(2, "0")}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-charts-card {
|
||||
margin-bottom: 20px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.time-range-selector {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
padding: 20px 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.charts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 10px 5px;
|
||||
gap: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-item {
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
min-height: 220px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chart-item.empty-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.chart-item.empty-chart .el-empty {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.chart-content {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #303133;
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.charts-grid {
|
||||
padding: 10px;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.chart-item {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
224
web/src/components/dialogs/cost/AddCostDialog.vue
Normal file
224
web/src/components/dialogs/cost/AddCostDialog.vue
Normal file
@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="添加费用记录"
|
||||
class="cost-dialog"
|
||||
>
|
||||
<el-form :model="form" class="cost-form" label-width="100px" :label-position="isMobile ? 'top' : 'right'">
|
||||
<el-form-item label="费用类型" required class="desktop-only">
|
||||
<el-select-v2
|
||||
v-model="form.type"
|
||||
:options="costTypeOptions"
|
||||
placeholder="选择或输入类型"
|
||||
allow-create
|
||||
filterable
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="费用类型" required class="mobile-only">
|
||||
<el-select-v2
|
||||
v-model="form.type"
|
||||
:options="costTypeOptions"
|
||||
placeholder="选择或输入类型"
|
||||
allow-create
|
||||
filterable
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="日期" required class="desktop-only">
|
||||
<el-date-picker
|
||||
v-model="form.date"
|
||||
type="date"
|
||||
placeholder="选择日期"
|
||||
style="width: 100%"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="日期" required class="mobile-only">
|
||||
<el-date-picker
|
||||
v-model="form.date"
|
||||
type="date"
|
||||
placeholder="选择日期"
|
||||
style="width: 100%"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="金额 (¥)" required class="desktop-only">
|
||||
<el-input-number v-model="form.amount" :min="0" :precision="2" style="width: 100%" placeholder="金额" />
|
||||
</el-form-item>
|
||||
<el-form-item label="金额 (¥)" required class="mobile-only">
|
||||
<el-input-number v-model="form.amount" :min="0" :precision="2" :controls="false" style="width: 100%" placeholder="金额" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 分摊设置 -->
|
||||
<el-form-item label="费用分摊" class="desktop-only">
|
||||
<div style="display: flex; align-items: center; gap: 15px;">
|
||||
<el-switch v-model="form.is_installment" active-text="分摊" inactive-text="一次性" />
|
||||
<el-input-number
|
||||
v-if="form.is_installment"
|
||||
v-model="form.installment_months"
|
||||
:min="1"
|
||||
:max="60"
|
||||
style="width: 120px"
|
||||
/>
|
||||
<span v-if="form.is_installment" style="color: #909399; font-size: 12px;">个月</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="费用分摊" class="mobile-only">
|
||||
<div style="display: flex; flex-direction: column; gap: 10px;">
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<el-switch v-model="form.is_installment" />
|
||||
<span style="font-size: 14px; color: #606266;">{{ form.is_installment ? '分摊' : '一次性' }}</span>
|
||||
</div>
|
||||
<el-input-number
|
||||
v-if="form.is_installment"
|
||||
v-model="form.installment_months"
|
||||
:min="1"
|
||||
:controls="false"
|
||||
:max="60"
|
||||
style="width: 100%"
|
||||
placeholder="分摊月数"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="备注" class="desktop-only">
|
||||
<el-input v-model="form.notes" type="textarea" :rows="2" placeholder="备注(可选)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" class="mobile-only">
|
||||
<el-input v-model="form.notes" type="textarea" :rows="2" placeholder="备注(可选)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submit" :loading="loading">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps(['visible', 'vehicleId'])
|
||||
const emit = defineEmits(['update:visible', 'success'])
|
||||
|
||||
const API_BASE = 'https://api.yuany3721.site/carcost'
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.visible,
|
||||
set: (val) => emit('update:visible', val)
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
// 检测是否为移动端
|
||||
const isMobile = ref(false)
|
||||
const checkMobile = () => {
|
||||
isMobile.value = window.innerWidth <= 768
|
||||
}
|
||||
onMounted(() => {
|
||||
checkMobile()
|
||||
window.addEventListener('resize', checkMobile)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', checkMobile)
|
||||
})
|
||||
|
||||
// 默认费用类型选项
|
||||
const costTypeOptions = [
|
||||
{ label: '保养', value: '保养' },
|
||||
{ label: '维修', value: '维修' },
|
||||
{ label: '保险', value: '保险' },
|
||||
{ label: '停车', value: '停车' },
|
||||
{ label: '过路费', value: '过路费' },
|
||||
{ label: '其他', value: '其他' }
|
||||
]
|
||||
|
||||
const form = ref({
|
||||
type: '',
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
amount: 0,
|
||||
notes: '',
|
||||
is_installment: false,
|
||||
installment_months: 12
|
||||
})
|
||||
|
||||
const submit = async () => {
|
||||
if (!props.vehicleId) {
|
||||
ElMessage.warning('请先选择车辆')
|
||||
return
|
||||
}
|
||||
if (!form.value.type || !form.value.date || !form.value.amount) {
|
||||
ElMessage.warning('请填写必填项')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await axios.post(`${API_BASE}/costs/create`, {
|
||||
vehicle_id: props.vehicleId,
|
||||
type: form.value.type,
|
||||
date: form.value.date,
|
||||
amount: form.value.amount,
|
||||
mileage: null,
|
||||
notes: form.value.notes,
|
||||
is_installment: form.value.is_installment,
|
||||
installment_months: form.value.is_installment ? form.value.installment_months : 1
|
||||
})
|
||||
ElMessage.success('添加成功')
|
||||
visible.value = false
|
||||
form.value = { type: '', date: new Date().toISOString().split('T')[0], amount: 0, notes: '', is_installment: false, installment_months: 12 }
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
ElMessage.error('添加失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 默认隐藏移动端 */
|
||||
.mobile-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 移动端适配 */
|
||||
@media (max-width: 768px) {
|
||||
:deep(.el-dialog.cost-dialog) {
|
||||
width: 90% !important;
|
||||
max-width: 350px;
|
||||
}
|
||||
|
||||
.desktop-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-only {
|
||||
display: block;
|
||||
}
|
||||
|
||||
:deep(.cost-form .el-form-item) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
:deep(.cost-form .mobile-only.el-form-item .el-form-item__content) {
|
||||
margin-left: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
:deep(.cost-form .el-form-item__content) {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
:deep(.cost-form) {
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
183
web/src/components/dialogs/cost/EditCostDialog.vue
Normal file
183
web/src/components/dialogs/cost/EditCostDialog.vue
Normal file
@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="编辑费用记录"
|
||||
width="450px"
|
||||
>
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="费用类型" required>
|
||||
<el-select-v2
|
||||
v-model="form.type"
|
||||
:options="costTypeOptions"
|
||||
placeholder="选择或输入类型"
|
||||
allow-create
|
||||
filterable
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="日期" required>
|
||||
<el-date-picker
|
||||
v-model="form.date"
|
||||
type="date"
|
||||
placeholder="选择日期"
|
||||
style="width: 100%"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="金额 (¥)" required>
|
||||
<el-input-number v-model="form.amount" :min="0" :precision="2" style="width: 100%" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 分摊设置 -->
|
||||
<el-form-item label="费用分摊">
|
||||
<div style="display: flex; align-items: center; gap: 15px;">
|
||||
<el-switch v-model="form.is_installment" active-text="分摊" inactive-text="一次性" />
|
||||
<el-input-number
|
||||
v-if="form.is_installment"
|
||||
v-model="form.installment_months"
|
||||
:min="1"
|
||||
:max="60"
|
||||
style="width: 120px"
|
||||
/>
|
||||
<span v-if="form.is_installment" style="color: #909399; font-size: 12px;">个月</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.notes" type="textarea" rows="2" placeholder="可选" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="danger" @click="deleteRecord" :loading="deleting">删除</el-button>
|
||||
<el-button type="primary" @click="submit" :loading="loading">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const props = defineProps(['visible', 'record'])
|
||||
const emit = defineEmits(['update:visible', 'success'])
|
||||
|
||||
const API_BASE = 'https://api.yuany3721.site/carcost'
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.visible,
|
||||
set: (val) => emit('update:visible', val)
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const deleting = ref(false)
|
||||
|
||||
// 默认费用类型选项
|
||||
const costTypeOptions = [
|
||||
{ label: '保养', value: '保养' },
|
||||
{ label: '维修', value: '维修' },
|
||||
{ label: '保险', value: '保险' },
|
||||
{ label: '停车', value: '停车' },
|
||||
{ label: '过路费', value: '过路费' },
|
||||
{ label: '其他', value: '其他' }
|
||||
]
|
||||
|
||||
const form = ref({
|
||||
id: null,
|
||||
type: '',
|
||||
date: '',
|
||||
amount: 0,
|
||||
notes: '',
|
||||
is_installment: false,
|
||||
installment_months: 12
|
||||
})
|
||||
|
||||
// 当 record 变化时更新表单
|
||||
watch(() => props.record, (newRecord) => {
|
||||
if (newRecord) {
|
||||
form.value = {
|
||||
id: newRecord.id,
|
||||
type: newRecord.type,
|
||||
date: newRecord.date,
|
||||
amount: newRecord.amount,
|
||||
notes: newRecord.notes || '',
|
||||
is_installment: newRecord.is_installment || false,
|
||||
installment_months: newRecord.installment_months || 12
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.value.id) {
|
||||
ElMessage.error('记录ID不存在')
|
||||
return
|
||||
}
|
||||
if (!form.value.type || !form.value.date || !form.value.amount) {
|
||||
ElMessage.warning('请填写必填项')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
// 构建请求体,只在不分摊时不发送 installment_months
|
||||
const requestBody = {
|
||||
id: form.value.id,
|
||||
type: form.value.type,
|
||||
date: form.value.date,
|
||||
amount: form.value.amount,
|
||||
mileage: null,
|
||||
notes: form.value.notes,
|
||||
is_installment: form.value.is_installment,
|
||||
}
|
||||
|
||||
// 只在分摊时发送 installment_months
|
||||
if (form.value.is_installment) {
|
||||
requestBody.installment_months = form.value.installment_months
|
||||
}
|
||||
|
||||
await axios.post(`${API_BASE}/costs/update`, requestBody)
|
||||
ElMessage.success('保存成功')
|
||||
visible.value = false
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
if (error.response) {
|
||||
console.error('状态码:', error.response.status)
|
||||
console.error('响应数据:', error.response.data)
|
||||
ElMessage.error(`保存失败: ${JSON.stringify(error.response.data)}`)
|
||||
} else {
|
||||
ElMessage.error('保存失败: ' + (error.message || '未知错误'))
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteRecord = async () => {
|
||||
if (!form.value.id) return
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除这条费用记录吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
deleting.value = true
|
||||
await axios.post(`${API_BASE}/costs/delete`, {
|
||||
id: form.value.id
|
||||
})
|
||||
ElMessage.success('删除成功')
|
||||
visible.value = false
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
316
web/src/components/dialogs/fuel/AddFuelDialog.vue
Normal file
316
web/src/components/dialogs/fuel/AddFuelDialog.vue
Normal file
@ -0,0 +1,316 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="添加加油记录"
|
||||
class="fuel-dialog"
|
||||
>
|
||||
<el-form :model="form" class="fuel-form" label-width="100px" :label-position="isMobile ? 'top' : 'right'">
|
||||
<el-form-item label="日期" class="desktop-only">
|
||||
<el-date-picker
|
||||
v-model="form.date"
|
||||
type="date"
|
||||
placeholder="选择日期"
|
||||
style="width: 100%"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="日期" class="mobile-only">
|
||||
<el-date-picker
|
||||
v-model="form.date"
|
||||
type="date"
|
||||
placeholder="选择日期"
|
||||
style="width: 100%"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="里程 (km)" class="desktop-only">
|
||||
<el-input-number v-model="form.mileage" :min="0" style="width: 100%" placeholder="当前里程" />
|
||||
</el-form-item>
|
||||
<el-form-item label="里程 (km)" class="mobile-only">
|
||||
<el-input-number v-model="form.mileage" :min="0" :controls="false" style="width: 100%" placeholder="当前里程" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 三个核心字段 -->
|
||||
<el-form-item label="单价 (¥/L)" class="desktop-only">
|
||||
<el-input-number
|
||||
v-model="form.fuel_price"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
placeholder="机显单价"
|
||||
@change="onPriceChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="单价 (¥/L)" class="mobile-only">
|
||||
<el-input-number
|
||||
v-model="form.fuel_price"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:controls="false"
|
||||
style="width: 100%"
|
||||
placeholder="机显单价"
|
||||
@change="onPriceChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="加油量 (L)" class="desktop-only">
|
||||
<el-input-number
|
||||
v-model="form.fuel_amount"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
placeholder="加油量"
|
||||
@change="onAmountChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="加油量 (L)" class="mobile-only">
|
||||
<el-input-number
|
||||
v-model="form.fuel_amount"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:controls="false"
|
||||
style="width: 100%"
|
||||
placeholder="加油量"
|
||||
@change="onAmountChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="机显总价 (¥)" class="desktop-only">
|
||||
<el-input-number
|
||||
v-model="form.display_cost"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
placeholder="机显金额(选填,可自动计算)"
|
||||
@change="onDisplayCostChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="机显总价 (¥)" class="mobile-only">
|
||||
<el-input-number
|
||||
v-model="form.display_cost"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:controls="false"
|
||||
style="width: 100%"
|
||||
placeholder="机显金额(选填,可自动计算)"
|
||||
@change="onDisplayCostChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 实付金额 -->
|
||||
<el-form-item label="实付金额 (¥)" class="desktop-only">
|
||||
<el-input-number v-model="form.actual_cost" :min="0" :precision="2" style="width: 100%" placeholder="实际支付金额(优惠后)" />
|
||||
<div class="hint">用于统计实际花费,不填则使用机显总价</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="实付金额 (¥)" class="mobile-only">
|
||||
<el-input-number v-model="form.actual_cost" :min="0" :precision="2" :controls="false" style="width: 100%" placeholder="实际支付金额" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="是否加满" class="desktop-only">
|
||||
<el-switch v-model="form.is_full_tank" active-text="是" inactive-text="否" />
|
||||
</el-form-item>
|
||||
<el-form-item label="是否加满" class="mobile-only">
|
||||
<el-switch v-model="form.is_full_tank" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="备注" class="desktop-only">
|
||||
<el-input v-model="form.notes" type="textarea" :rows="2" placeholder="备注(可选)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" class="mobile-only">
|
||||
<el-input v-model="form.notes" type="textarea" :rows="2" placeholder="备注(可选)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submit" :loading="loading">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps(['visible', 'vehicleId'])
|
||||
const emit = defineEmits(['update:visible', 'success'])
|
||||
|
||||
const API_BASE = 'https://api.yuany3721.site/carcost'
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.visible,
|
||||
set: (val) => emit('update:visible', val)
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
// 检测是否为移动端
|
||||
const isMobile = ref(false)
|
||||
const checkMobile = () => {
|
||||
isMobile.value = window.innerWidth <= 768
|
||||
}
|
||||
onMounted(() => {
|
||||
checkMobile()
|
||||
window.addEventListener('resize', checkMobile)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', checkMobile)
|
||||
})
|
||||
|
||||
const form = ref({
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
mileage: 0,
|
||||
fuel_amount: null,
|
||||
fuel_price: null,
|
||||
display_cost: null,
|
||||
actual_cost: null,
|
||||
is_full_tank: true,
|
||||
notes: ''
|
||||
})
|
||||
|
||||
// 标记哪个字段是最后修改的,避免循环计算
|
||||
let lastModified = null
|
||||
|
||||
// 单价变化:如果加油量有值,重新计算总价
|
||||
const onPriceChange = () => {
|
||||
lastModified = 'price'
|
||||
if (form.value.fuel_price && form.value.fuel_amount) {
|
||||
form.value.display_cost = Math.round(form.value.fuel_price * form.value.fuel_amount * 100) / 100
|
||||
}
|
||||
}
|
||||
|
||||
// 加油量变化:根据已有数据计算缺失的
|
||||
const onAmountChange = () => {
|
||||
lastModified = 'amount'
|
||||
if (form.value.fuel_amount && form.value.fuel_price) {
|
||||
// 有单价,计算总价
|
||||
form.value.display_cost = Math.round(form.value.fuel_price * form.value.fuel_amount * 100) / 100
|
||||
} else if (form.value.fuel_amount && form.value.display_cost) {
|
||||
// 有总价,计算单价
|
||||
form.value.fuel_price = Math.round(form.value.display_cost / form.value.fuel_amount * 100) / 100
|
||||
}
|
||||
}
|
||||
|
||||
// 总价变化:根据已有数据计算缺失的
|
||||
const onDisplayCostChange = () => {
|
||||
lastModified = 'cost'
|
||||
if (form.value.display_cost && form.value.fuel_price) {
|
||||
// 有单价,计算加油量
|
||||
form.value.fuel_amount = Math.round(form.value.display_cost / form.value.fuel_price * 100) / 100
|
||||
} else if (form.value.display_cost && form.value.fuel_amount) {
|
||||
// 有加油量,计算单价
|
||||
form.value.fuel_price = Math.round(form.value.display_cost / form.value.fuel_amount * 100) / 100
|
||||
}
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (!props.vehicleId) {
|
||||
ElMessage.warning('请先选择车辆')
|
||||
return
|
||||
}
|
||||
if (!form.value.date || !form.value.mileage) {
|
||||
ElMessage.warning('请填写日期和里程')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查单价和加油量是否都有值
|
||||
const hasPrice = form.value.fuel_price !== null && form.value.fuel_price > 0
|
||||
const hasAmount = form.value.fuel_amount !== null && form.value.fuel_amount > 0
|
||||
|
||||
if (!hasPrice || !hasAmount) {
|
||||
ElMessage.warning('请填写单价和加油量')
|
||||
return
|
||||
}
|
||||
|
||||
// 如果机显总价为空,自动计算
|
||||
let displayCost = form.value.display_cost
|
||||
if (!displayCost || displayCost <= 0) {
|
||||
displayCost = Math.round(form.value.fuel_price * form.value.fuel_amount * 100) / 100
|
||||
}
|
||||
|
||||
// 实付金额默认使用机显总价
|
||||
const totalCost = form.value.actual_cost !== null && form.value.actual_cost > 0
|
||||
? form.value.actual_cost
|
||||
: displayCost
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await axios.post(`${API_BASE}/fuel-records/create`, {
|
||||
vehicle_id: props.vehicleId,
|
||||
date: form.value.date,
|
||||
mileage: form.value.mileage,
|
||||
fuel_amount: form.value.fuel_amount,
|
||||
fuel_price: form.value.fuel_price,
|
||||
total_cost: totalCost,
|
||||
is_full_tank: form.value.is_full_tank,
|
||||
notes: form.value.notes || ''
|
||||
})
|
||||
ElMessage.success('添加成功')
|
||||
visible.value = false
|
||||
// 重置表单
|
||||
form.value = {
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
mileage: 0,
|
||||
fuel_amount: null,
|
||||
fuel_price: null,
|
||||
display_cost: null,
|
||||
actual_cost: null,
|
||||
is_full_tank: true,
|
||||
notes: ''
|
||||
}
|
||||
lastModified = null
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
ElMessage.error('添加失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 默认隐藏移动端 */
|
||||
.mobile-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 移动端适配 */
|
||||
@media (max-width: 768px) {
|
||||
:deep(.el-dialog.fuel-dialog) {
|
||||
width: 90% !important;
|
||||
max-width: 350px;
|
||||
}
|
||||
|
||||
.desktop-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-only {
|
||||
display: block;
|
||||
}
|
||||
|
||||
:deep(.fuel-form .el-form-item) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
:deep(.fuel-form .mobile-only.el-form-item .el-form-item__content) {
|
||||
margin-left: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
:deep(.fuel-form .el-form-item__content) {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
:deep(.fuel-form) {
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
231
web/src/components/dialogs/fuel/EditFuelDialog.vue
Normal file
231
web/src/components/dialogs/fuel/EditFuelDialog.vue
Normal file
@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="编辑加油记录"
|
||||
width="450px"
|
||||
>
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="日期" required>
|
||||
<el-date-picker
|
||||
v-model="form.date"
|
||||
type="date"
|
||||
placeholder="选择日期"
|
||||
style="width: 100%"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="里程 (km)" required>
|
||||
<el-input-number v-model="form.mileage" :min="0" style="width: 100%" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 三个核心字段 -->
|
||||
<el-form-item label="单价 (¥/L)">
|
||||
<el-input-number
|
||||
v-model="form.fuel_price"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
placeholder="机显单价"
|
||||
@change="onPriceChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="加油量 (L)">
|
||||
<el-input-number
|
||||
v-model="form.fuel_amount"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
placeholder="加油量"
|
||||
@change="onAmountChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="机显总价 (¥)">
|
||||
<el-input-number
|
||||
v-model="form.display_cost"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
placeholder="机显金额(选填,可自动计算)"
|
||||
@change="onDisplayCostChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 实付金额 -->
|
||||
<el-form-item label="实付金额 (¥)">
|
||||
<el-input-number v-model="form.actual_cost" :min="0" :precision="2" style="width: 100%" placeholder="实际支付金额(优惠后)" />
|
||||
<div class="hint">用于统计实际花费,不填则使用机显总价</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="是否加满">
|
||||
<el-switch v-model="form.is_full_tank" active-text="是" inactive-text="否" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.notes" type="textarea" rows="2" placeholder="备注(可选)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="danger" @click="deleteRecord" :loading="deleting">删除</el-button>
|
||||
<el-button type="primary" @click="submit" :loading="loading">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const props = defineProps(['visible', 'record'])
|
||||
const emit = defineEmits(['update:visible', 'success'])
|
||||
|
||||
const API_BASE = 'https://api.yuany3721.site/carcost'
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.visible,
|
||||
set: (val) => emit('update:visible', val)
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const deleting = ref(false)
|
||||
|
||||
const form = ref({
|
||||
id: null,
|
||||
date: '',
|
||||
mileage: 0,
|
||||
fuel_amount: null,
|
||||
fuel_price: null,
|
||||
display_cost: null,
|
||||
actual_cost: null,
|
||||
is_full_tank: true,
|
||||
notes: ''
|
||||
})
|
||||
|
||||
// 当 record 变化时更新表单
|
||||
watch(() => props.record, (newRecord) => {
|
||||
if (newRecord) {
|
||||
form.value = {
|
||||
id: newRecord.id,
|
||||
date: newRecord.date,
|
||||
mileage: newRecord.mileage,
|
||||
fuel_amount: newRecord.fuel_amount,
|
||||
fuel_price: newRecord.fuel_price,
|
||||
display_cost: newRecord.total_cost, // 显示用
|
||||
actual_cost: newRecord.total_cost, // 实付金额默认等于总价
|
||||
is_full_tank: newRecord.is_full_tank,
|
||||
notes: newRecord.notes || ''
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 单价变化:如果加油量有值,重新计算总价
|
||||
const onPriceChange = () => {
|
||||
if (form.value.fuel_price && form.value.fuel_amount) {
|
||||
form.value.display_cost = Math.round(form.value.fuel_price * form.value.fuel_amount * 100) / 100
|
||||
}
|
||||
}
|
||||
|
||||
// 加油量变化
|
||||
const onAmountChange = () => {
|
||||
if (form.value.fuel_amount && form.value.fuel_price) {
|
||||
form.value.display_cost = Math.round(form.value.fuel_price * form.value.fuel_amount * 100) / 100
|
||||
} else if (form.value.fuel_amount && form.value.display_cost) {
|
||||
form.value.fuel_price = Math.round(form.value.display_cost / form.value.fuel_amount * 100) / 100
|
||||
}
|
||||
}
|
||||
|
||||
// 总价变化
|
||||
const onDisplayCostChange = () => {
|
||||
if (form.value.display_cost && form.value.fuel_price) {
|
||||
form.value.fuel_amount = Math.round(form.value.display_cost / form.value.fuel_price * 100) / 100
|
||||
} else if (form.value.display_cost && form.value.fuel_amount) {
|
||||
form.value.fuel_price = Math.round(form.value.display_cost / form.value.fuel_amount * 100) / 100
|
||||
}
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.value.id) {
|
||||
ElMessage.error('记录ID不存在')
|
||||
return
|
||||
}
|
||||
if (!form.value.date || !form.value.mileage) {
|
||||
ElMessage.warning('请填写日期和里程')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查单价和加油量
|
||||
const hasPrice = form.value.fuel_price !== null && form.value.fuel_price > 0
|
||||
const hasAmount = form.value.fuel_amount !== null && form.value.fuel_amount > 0
|
||||
|
||||
if (!hasPrice || !hasAmount) {
|
||||
ElMessage.warning('请填写单价和加油量')
|
||||
return
|
||||
}
|
||||
|
||||
// 如果机显总价为空,自动计算
|
||||
let displayCost = form.value.display_cost
|
||||
if (!displayCost || displayCost <= 0) {
|
||||
displayCost = Math.round(form.value.fuel_price * form.value.fuel_amount * 100) / 100
|
||||
}
|
||||
|
||||
// 实付金额默认使用机显总价
|
||||
const totalCost = form.value.actual_cost !== null && form.value.actual_cost > 0
|
||||
? form.value.actual_cost
|
||||
: displayCost
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await axios.post(`${API_BASE}/fuel-records/update`, {
|
||||
id: form.value.id,
|
||||
date: form.value.date,
|
||||
mileage: form.value.mileage,
|
||||
fuel_amount: form.value.fuel_amount,
|
||||
fuel_price: form.value.fuel_price,
|
||||
total_cost: totalCost,
|
||||
is_full_tank: form.value.is_full_tank,
|
||||
notes: form.value.notes
|
||||
})
|
||||
ElMessage.success('保存成功')
|
||||
visible.value = false
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
ElMessage.error('保存失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteRecord = async () => {
|
||||
if (!form.value.id) return
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除这条记录吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
deleting.value = true
|
||||
await axios.post(`${API_BASE}/fuel-records/delete`, {
|
||||
id: form.value.id
|
||||
})
|
||||
ElMessage.success('删除成功')
|
||||
visible.value = false
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
76
web/src/components/dialogs/vehicle/AddVehicleDialog.vue
Normal file
76
web/src/components/dialogs/vehicle/AddVehicleDialog.vue
Normal file
@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="添加车辆"
|
||||
width="400px"
|
||||
>
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="车辆名称" required>
|
||||
<el-input v-model="form.name" placeholder="例如:我的小车" />
|
||||
</el-form-item>
|
||||
<el-form-item label="购车日期">
|
||||
<el-date-picker
|
||||
v-model="form.purchase_date"
|
||||
type="date"
|
||||
placeholder="选择日期"
|
||||
style="width: 100%"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="初始里程">
|
||||
<el-input-number v-model="form.initial_mileage" :min="0" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submit" :loading="loading">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps(['visible'])
|
||||
const emit = defineEmits(['update:visible', 'success'])
|
||||
|
||||
const API_BASE = 'https://api.yuany3721.site/carcost'
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.visible,
|
||||
set: (val) => emit('update:visible', val)
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const form = ref({
|
||||
name: '',
|
||||
purchase_date: null,
|
||||
initial_mileage: 0
|
||||
})
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.value.name.trim()) {
|
||||
ElMessage.warning('请输入车辆名称')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await axios.post(`${API_BASE}/vehicles/create`, {
|
||||
name: form.value.name,
|
||||
purchase_date: form.value.purchase_date,
|
||||
initial_mileage: form.value.initial_mileage
|
||||
})
|
||||
ElMessage.success('添加成功')
|
||||
visible.value = false
|
||||
form.value = { name: '', purchase_date: null, initial_mileage: 0 }
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
ElMessage.error('添加失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
89
web/src/components/dialogs/vehicle/EditVehicleDialog.vue
Normal file
89
web/src/components/dialogs/vehicle/EditVehicleDialog.vue
Normal file
@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="编辑车辆"
|
||||
width="400px"
|
||||
>
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="车辆名称" required>
|
||||
<el-input v-model="form.name" placeholder="例如:我的小车" />
|
||||
</el-form-item>
|
||||
<el-form-item label="购车日期">
|
||||
<el-date-picker
|
||||
v-model="form.purchase_date"
|
||||
type="date"
|
||||
placeholder="选择日期"
|
||||
style="width: 100%"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="初始里程">
|
||||
<el-input-number v-model="form.initial_mileage" :min="0" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submit" :loading="loading">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps(['visible', 'vehicle'])
|
||||
const emit = defineEmits(['update:visible', 'success'])
|
||||
|
||||
const API_BASE = 'https://api.yuany3721.site/carcost'
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.visible,
|
||||
set: (val) => emit('update:visible', val)
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const form = ref({
|
||||
id: null,
|
||||
name: '',
|
||||
purchase_date: null,
|
||||
initial_mileage: 0
|
||||
})
|
||||
|
||||
// 当 vehicle 变化时更新表单
|
||||
watch(() => props.vehicle, (newVehicle) => {
|
||||
if (newVehicle) {
|
||||
form.value = {
|
||||
id: newVehicle.id,
|
||||
name: newVehicle.name || '',
|
||||
purchase_date: newVehicle.purchase_date,
|
||||
initial_mileage: newVehicle.initial_mileage || 0
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.value.name.trim()) {
|
||||
ElMessage.warning('请输入车辆名称')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await axios.post(`${API_BASE}/vehicles/update`, {
|
||||
id: form.value.id,
|
||||
name: form.value.name,
|
||||
purchase_date: form.value.purchase_date,
|
||||
initial_mileage: form.value.initial_mileage
|
||||
})
|
||||
ElMessage.success('保存成功')
|
||||
visible.value = false
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
ElMessage.error('保存失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
584
web/src/components/panels/CostRecordsPanel.vue
Normal file
584
web/src/components/panels/CostRecordsPanel.vue
Normal file
@ -0,0 +1,584 @@
|
||||
<template>
|
||||
<div class="cost-panel">
|
||||
<el-card :body-style="{ padding: '0' }">
|
||||
<!-- 费用记录面板 -->
|
||||
|
||||
<!-- 费用类型分布 -->
|
||||
<div v-if="showCharts" class="chart-container">
|
||||
<div class="chart-header">
|
||||
<div class="chart-title">费用类型分布</div>
|
||||
<el-radio-group v-model="costTimeRange" size="small">
|
||||
<el-radio-button value="past_year">最近一年</el-radio-button>
|
||||
<el-radio-button value="all">全部时间</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<v-chart v-if="pieChartOption" :option="pieChartOption" autoresize style="height: 220px" />
|
||||
<el-empty v-else description="暂无数据" :image-size="60" />
|
||||
</div>
|
||||
|
||||
<!-- 月度费用统计 -->
|
||||
<div v-if="showCharts" class="chart-container">
|
||||
<div class="chart-header">
|
||||
<div class="chart-title">月度费用统计</div>
|
||||
<el-radio-group v-model="costTimeRange" size="small">
|
||||
<el-radio-button value="past_year">最近一年</el-radio-button>
|
||||
<el-radio-button value="all">全部时间</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<v-chart v-if="monthlyChartOption" :option="monthlyChartOption" autoresize style="height: 200px" />
|
||||
<el-empty v-else description="暂无数据" :image-size="60" />
|
||||
</div>
|
||||
|
||||
<!-- 记录列表 -->
|
||||
<div v-if="showList" class="table-container">
|
||||
<el-table
|
||||
:data="filteredRecords"
|
||||
style="width: 100%"
|
||||
v-loading="loading"
|
||||
size="small"
|
||||
highlight-current-row
|
||||
@row-click="editRecord"
|
||||
>
|
||||
<el-table-column prop="date" label="日期" width="100" />
|
||||
<el-table-column prop="type" label="类型" width="120">
|
||||
<template #header>
|
||||
<el-dropdown trigger="click">
|
||||
<span class="type-filter-header">
|
||||
类型 {{ selectedTypes.length > 0 ? `(${selectedTypes.length})` : '' }}
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item v-for="type in allTypes" :key="type">
|
||||
<el-checkbox v-model="typeFilters[type]">{{ type }}</el-checkbox>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided @click="clearTypeFilter">
|
||||
<span style="color: #909399;">清除筛选</span>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
<template #default="scope">
|
||||
<el-tag :type="getTypeTag(scope.row.type)" size="small">{{ scope.row.type }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="amount" label="金额" width="80">
|
||||
<template #default="scope"> ¥{{ scope.row.amount?.toFixed(0) }} </template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="notes" label="备注" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination
|
||||
v-if="showList"
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:total="filteredTotal"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="handlePageChange"
|
||||
style="margin-top: 15px; padding: 0 10px 10px"
|
||||
size="small"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<EditCostDialog v-model:visible="showEditDialog" :record="selectedRecord" @success="loadRecords" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, computed } from "vue";
|
||||
import axios from "axios";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { ArrowDown } from '@element-plus/icons-vue'
|
||||
import EditCostDialog from "../dialogs/cost/EditCostDialog.vue";
|
||||
|
||||
const props = defineProps({
|
||||
vehicleId: Number,
|
||||
showCharts: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showList: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
});
|
||||
|
||||
defineEmits(["add-cost"]);
|
||||
|
||||
const API_BASE = "https://api.yuany3721.site/carcost";
|
||||
|
||||
const loading = ref(false);
|
||||
const allRecords = ref([]);
|
||||
const fuelRecords = ref([]); // 加油记录,用于月度油费统计
|
||||
const total = ref(0);
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref(15);
|
||||
const showEditDialog = ref(false);
|
||||
const selectedRecord = ref(null);
|
||||
const selectedTypes = ref([]);
|
||||
const isMobile = ref(window.innerWidth <= 768);
|
||||
const costTimeRange = ref('past_year'); // 'past_year' | 'all'
|
||||
|
||||
// 类型筛选
|
||||
const typeFilters = ref({});
|
||||
const allTypes = ["保养", "维修", "保险", "停车", "过路费", "其他", "油费"];
|
||||
|
||||
// 初始化类型筛选
|
||||
allTypes.forEach(type => {
|
||||
typeFilters.value[type] = false;
|
||||
});
|
||||
|
||||
// 监听类型筛选变化
|
||||
watch(typeFilters.value, () => {
|
||||
selectedTypes.value = allTypes.filter(type => typeFilters.value[type]);
|
||||
currentPage.value = 1;
|
||||
}, { deep: true });
|
||||
|
||||
const clearTypeFilter = () => {
|
||||
allTypes.forEach(type => {
|
||||
typeFilters.value[type] = false;
|
||||
});
|
||||
selectedTypes.value = [];
|
||||
};
|
||||
|
||||
// 监听窗口大小变化
|
||||
const updateIsMobile = () => {
|
||||
isMobile.value = window.innerWidth <= 768;
|
||||
};
|
||||
window.addEventListener("resize", updateIsMobile);
|
||||
onMounted(() => {
|
||||
updateIsMobile();
|
||||
});
|
||||
|
||||
const typeTagMap = {
|
||||
保养: "success",
|
||||
维修: "warning",
|
||||
保险: "danger",
|
||||
停车: "info",
|
||||
过路费: "primary",
|
||||
其他: "info",
|
||||
};
|
||||
|
||||
const typeColorMap = {
|
||||
保养: "#67c23a", // 绿色
|
||||
维修: "#e6a23c", // 橙色
|
||||
保险: "#f56c6c", // 红色
|
||||
停车: "#909399", // 灰色
|
||||
过路费: "#9a60b4", // 紫色(改为紫色,区别于油费)
|
||||
其他: "#fc8452", // 珊瑚色(改为橙色系的珊瑚色)
|
||||
油费: "#409eff", // 蓝色
|
||||
};
|
||||
|
||||
const getTypeTag = (type) => typeTagMap[type] || "";
|
||||
|
||||
// 获取N个月前的日期
|
||||
const getMonthsAgo = (months) => {
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth() - months, now.getDate());
|
||||
};
|
||||
|
||||
// 计算费用分摊明细表
|
||||
const costInstallments = computed(() => {
|
||||
const installments = []
|
||||
|
||||
allRecords.value.forEach(cost => {
|
||||
if (cost.is_installment && cost.installment_months > 1) {
|
||||
// 分摊费用,生成多条记录
|
||||
const monthlyAmount = cost.amount / cost.installment_months
|
||||
const startMonth = cost.date.slice(0, 7)
|
||||
|
||||
for (let i = 0; i < cost.installment_months; i++) {
|
||||
const month = getNextMonth(startMonth, i)
|
||||
installments.push({
|
||||
month: month,
|
||||
type: cost.type,
|
||||
amount: monthlyAmount,
|
||||
originalCostId: cost.id
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// 一次性费用,生成一条记录
|
||||
installments.push({
|
||||
month: cost.date.slice(0, 7),
|
||||
type: cost.type,
|
||||
amount: cost.amount,
|
||||
originalCostId: cost.id
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return installments
|
||||
})
|
||||
|
||||
// 费用类型分布 - 环形图
|
||||
const pieChartOption = computed(() => {
|
||||
if (!allRecords.value.length && !fuelRecords.value.length) return null;
|
||||
|
||||
// 计算时间范围
|
||||
const now = new Date();
|
||||
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
|
||||
// 确定筛选的月份范围
|
||||
let targetMonths = [];
|
||||
if (costTimeRange.value === 'past_6months') {
|
||||
// 近6个月:从当前月往前推5个月(共6个月)
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
targetMonths.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`);
|
||||
}
|
||||
} else if (costTimeRange.value === 'past_year') {
|
||||
// 近一年:从当前月往前推11个月(共12个月)
|
||||
for (let i = 11; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
targetMonths.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`);
|
||||
}
|
||||
}
|
||||
// "全部"模式下不过滤月份
|
||||
|
||||
// 筛选油费记录
|
||||
let filteredFuelRecords = fuelRecords.value;
|
||||
if (costTimeRange.value !== 'all') {
|
||||
const cutoffDate = getMonthsAgo(costTimeRange.value === 'past_6months' ? 6 : 12).toISOString().slice(0, 10);
|
||||
filteredFuelRecords = fuelRecords.value.filter(r => r.date >= cutoffDate);
|
||||
}
|
||||
|
||||
const typeData = {};
|
||||
let totalAmount = 0;
|
||||
|
||||
// 从分摊明细表中统计费用
|
||||
costInstallments.value.forEach(item => {
|
||||
if (costTimeRange.value === 'all' || targetMonths.includes(item.month)) {
|
||||
typeData[item.type] = (typeData[item.type] || 0) + item.amount;
|
||||
totalAmount += item.amount;
|
||||
}
|
||||
});
|
||||
|
||||
// 统计油费
|
||||
let totalFuelCost = 0;
|
||||
filteredFuelRecords.forEach((r) => {
|
||||
totalFuelCost += r.total_cost;
|
||||
});
|
||||
if (totalFuelCost > 0) {
|
||||
typeData['油费'] = totalFuelCost;
|
||||
totalAmount += totalFuelCost;
|
||||
}
|
||||
|
||||
const data = Object.entries(typeData)
|
||||
.map(([name, value]) => ({ name, value: parseFloat(value.toFixed(0)) }))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
|
||||
if (data.length === 0) return null;
|
||||
|
||||
return {
|
||||
tooltip: { trigger: "item", formatter: "{b}: ¥{c} ({d}%)" },
|
||||
legend: {
|
||||
orient: "vertical",
|
||||
right: "5%",
|
||||
top: "center",
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
textStyle: { fontSize: 10 },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: "pie",
|
||||
radius: ["45%", "70%"],
|
||||
center: ["35%", "50%"],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: { borderRadius: 5, borderColor: "#fff", borderWidth: 2 },
|
||||
label: { show: false },
|
||||
emphasis: {
|
||||
label: { show: true, fontSize: 12, fontWeight: "bold" },
|
||||
scale: true,
|
||||
scaleSize: 5
|
||||
},
|
||||
data: data.map((d) => ({ ...d, itemStyle: { color: typeColorMap[d.name] || "#909399" } })),
|
||||
},
|
||||
],
|
||||
title: {
|
||||
text: `¥${totalAmount.toFixed(0)}`,
|
||||
left: '34.2%',
|
||||
top: '46%',
|
||||
textAlign: 'center',
|
||||
textStyle: {
|
||||
fontSize: 16,
|
||||
color: '#303133',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// 辅助函数:获取下一个月
|
||||
const getNextMonth = (month) => {
|
||||
const [year, mon] = month.split("-").map(Number);
|
||||
if (mon === 12) return `${year + 1}-01`;
|
||||
return `${year}-${String(mon + 1).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
// 辅助函数:获取前N个月
|
||||
const getPrevMonths = (month, n) => {
|
||||
const [year, mon] = month.split("-").map(Number);
|
||||
let result = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
let targetMon = mon - i;
|
||||
let targetYear = year;
|
||||
while (targetMon <= 0) {
|
||||
targetMon += 12;
|
||||
targetYear--;
|
||||
}
|
||||
result.push(`${targetYear}-${String(targetMon).padStart(2, "0")}`);
|
||||
}
|
||||
return result.reverse();
|
||||
};
|
||||
|
||||
// 月度费用统计
|
||||
const monthlyChartOption = computed(() => {
|
||||
if (!allRecords.value.length && !fuelRecords.value.length) return null;
|
||||
|
||||
// 获取当前月份
|
||||
const now = new Date();
|
||||
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||
|
||||
// 根据设备和时间范围决定显示月份
|
||||
let targetMonths;
|
||||
if (costTimeRange.value === 'past_year') {
|
||||
const monthsToShow = isMobile.value ? 6 : 12;
|
||||
targetMonths = getPrevMonths(currentMonth, monthsToShow);
|
||||
} else {
|
||||
// 全部时间:从分摊明细表中收集所有月份
|
||||
const allMonths = new Set();
|
||||
costInstallments.value.forEach(item => allMonths.add(item.month));
|
||||
fuelRecords.value.forEach(r => allMonths.add(r.date.slice(0, 7)));
|
||||
targetMonths = Array.from(allMonths).sort();
|
||||
}
|
||||
|
||||
if (targetMonths.length === 0) return null;
|
||||
|
||||
// 初始化月度数据
|
||||
const monthlyData = {};
|
||||
targetMonths.forEach((month) => {
|
||||
monthlyData[month] = {};
|
||||
allTypes.forEach((type) => {
|
||||
monthlyData[month][type] = 0;
|
||||
});
|
||||
});
|
||||
|
||||
// 使用分摊明细表统计费用(只统计目标月份)
|
||||
costInstallments.value.forEach(item => {
|
||||
if (monthlyData[item.month]) {
|
||||
monthlyData[item.month][item.type] += item.amount;
|
||||
}
|
||||
});
|
||||
|
||||
// 添加油费数据
|
||||
fuelRecords.value.forEach((r) => {
|
||||
const month = r.date.slice(0, 7);
|
||||
if (monthlyData[month]) {
|
||||
monthlyData[month]["油费"] += r.total_cost;
|
||||
}
|
||||
});
|
||||
|
||||
// 确定实际存在的类型(包括油费)
|
||||
const existingTypes = allTypes.filter(type => {
|
||||
return sortedMonths.some(month => monthlyData[month][type] > 0);
|
||||
});
|
||||
|
||||
// 计算每月总费用用于显示标签
|
||||
const monthlyTotals = sortedMonths.map(month => {
|
||||
const total = existingTypes.reduce((sum, type) => sum + monthlyData[month][type], 0);
|
||||
return total.toFixed(0);
|
||||
});
|
||||
|
||||
return {
|
||||
grid: { left: "3%", right: "3%", top: "15%", bottom: "15%", containLabel: true },
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
axisPointer: { type: "shadow" },
|
||||
formatter: function (params) {
|
||||
// 只显示有费用的类别
|
||||
const validParams = params.filter((p) => p.value > 0);
|
||||
if (validParams.length === 0) return "";
|
||||
|
||||
let html = `<div style="font-weight:bold;margin-bottom:5px;">${params[0].axisValue}</div>`;
|
||||
validParams.forEach((p) => {
|
||||
html += `<div style="display:flex;align-items:center;margin:3px 0;">
|
||||
<span style="display:inline-block;width:10px;height:10px;background:${p.color};border-radius:50%;margin-right:5px;"></span>
|
||||
<span>${p.seriesName}: ¥${p.value.toFixed(0)}</span>
|
||||
</div>`;
|
||||
});
|
||||
const total = validParams.reduce((sum, p) => sum + p.value, 0);
|
||||
html += `<div style="border-top:1px solid #ccc;margin-top:5px;padding-top:5px;font-weight:bold;">总计: ¥${total.toFixed(0)}</div>`;
|
||||
return html;
|
||||
},
|
||||
},
|
||||
legend: { top: "0%", textStyle: { fontSize: 9 }, itemWidth: 10, itemHeight: 10 },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: sortedMonths.map((m) => m.slice(5)),
|
||||
axisLabel: { fontSize: 10 },
|
||||
axisLine: { show: true },
|
||||
axisTick: { show: false }
|
||||
},
|
||||
yAxis: { type: "value", show: false },
|
||||
series: existingTypes.map((type, index) => ({
|
||||
name: type,
|
||||
type: "bar",
|
||||
stack: "total",
|
||||
data: sortedMonths.map((month) => parseFloat(monthlyData[month][type].toFixed(0))),
|
||||
itemStyle: {
|
||||
color: typeColorMap[type] || "#909399",
|
||||
borderRadius: index === existingTypes.length - 1 ? [4, 4, 0, 0] : [0, 0, 0, 0]
|
||||
},
|
||||
label: index === existingTypes.length - 1 ? {
|
||||
show: true,
|
||||
position: 'top',
|
||||
fontSize: 9,
|
||||
formatter: function(params) {
|
||||
return monthlyTotals[params.dataIndex];
|
||||
},
|
||||
color: '#606266'
|
||||
} : undefined
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const filteredRecords = computed(() => {
|
||||
let result = allRecords.value;
|
||||
if (selectedTypes.value.length > 0) {
|
||||
result = result.filter((r) => selectedTypes.value.includes(r.type));
|
||||
}
|
||||
const start = (currentPage.value - 1) * pageSize.value;
|
||||
const end = start + pageSize.value;
|
||||
return result.slice(start, end);
|
||||
});
|
||||
|
||||
const filteredTotal = computed(() => {
|
||||
if (selectedTypes.value.length > 0) {
|
||||
return allRecords.value.filter((r) => selectedTypes.value.includes(r.type)).length;
|
||||
}
|
||||
return total.value;
|
||||
});
|
||||
|
||||
const toggleType = (type) => {
|
||||
const index = selectedTypes.value.indexOf(type);
|
||||
if (index > -1) selectedTypes.value.splice(index, 1);
|
||||
else selectedTypes.value.push(type);
|
||||
currentPage.value = 1;
|
||||
};
|
||||
|
||||
const clearFilter = () => {
|
||||
selectedTypes.value = [];
|
||||
currentPage.value = 1;
|
||||
};
|
||||
|
||||
const editRecord = (record) => {
|
||||
selectedRecord.value = record;
|
||||
showEditDialog.value = true;
|
||||
};
|
||||
|
||||
const handlePageChange = () => {
|
||||
// 分页切换时不需要重新加载数据
|
||||
};
|
||||
|
||||
const loadRecords = async () => {
|
||||
if (!props.vehicleId) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
// 加载费用记录
|
||||
const costRes = await axios.get(`${API_BASE}/costs/list?vehicle_id=${props.vehicleId}&limit=1000`);
|
||||
allRecords.value = costRes.data || [];
|
||||
total.value = allRecords.value.length;
|
||||
|
||||
// 加载加油记录(用于月度油费统计)
|
||||
const fuelRes = await axios.get(`${API_BASE}/fuel-records/list?vehicle_id=${props.vehicleId}&limit=1000`);
|
||||
fuelRecords.value = fuelRes.data || [];
|
||||
} catch (error) {
|
||||
ElMessage.error("加载费用记录失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => props.vehicleId, () => {
|
||||
currentPage.value = 1;
|
||||
loadRecords();
|
||||
}, { immediate: true });
|
||||
|
||||
onMounted(() => {
|
||||
loadRecords();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cost-panel {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cost-panel :deep(.el-card) {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cost-panel :deep(.el-card__body) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.chart-container + .chart-container {
|
||||
margin-top: -5px;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 10px 10px 5px;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
padding: 0 20px;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.type-filter-header {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
line-height: 23px;
|
||||
}
|
||||
|
||||
.type-filter-header:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
</style>
|
||||
386
web/src/components/panels/FuelRecordsPanel.vue
Normal file
386
web/src/components/panels/FuelRecordsPanel.vue
Normal file
@ -0,0 +1,386 @@
|
||||
<template>
|
||||
<div class="fuel-panel">
|
||||
<el-card :body-style="{ padding: '0' }">
|
||||
<!-- 加油记录面板 -->
|
||||
|
||||
<!-- 油耗趋势图 -->
|
||||
<div v-if="showCharts" class="chart-container">
|
||||
<div class="chart-header">
|
||||
<div class="chart-title">油耗趋势</div>
|
||||
<el-radio-group v-model="fuelTimeRange" size="small">
|
||||
<el-radio-button value="past_year">过去一年</el-radio-button>
|
||||
<el-radio-button value="all">全部时间</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<v-chart v-if="consumptionChartOption" :option="consumptionChartOption" autoresize style="height: 200px;" />
|
||||
<el-empty v-else description="暂无数据" :image-size="60" />
|
||||
</div>
|
||||
|
||||
<!-- 月度油费图 -->
|
||||
<div v-if="showCharts" class="chart-container">
|
||||
<div class="chart-header">
|
||||
<div class="chart-title">月度油费</div>
|
||||
</div>
|
||||
<v-chart v-if="monthlyCostChartOption" :option="monthlyCostChartOption" autoresize style="height: 180px;" />
|
||||
<el-empty v-else description="暂无数据" :image-size="60" />
|
||||
</div>
|
||||
|
||||
<!-- 记录列表 -->
|
||||
<div v-if="showList" class="table-container">
|
||||
<el-table
|
||||
:data="records"
|
||||
style="width: 100%"
|
||||
v-loading="loading"
|
||||
size="small"
|
||||
highlight-current-row
|
||||
@row-click="editRecord"
|
||||
>
|
||||
<el-table-column prop="date" label="日期" width="100" />
|
||||
<el-table-column prop="mileage" label="里程" width="80" />
|
||||
<el-table-column prop="fuel_amount" label="油量" width="70" />
|
||||
<el-table-column prop="total_cost" label="金额" width="80">
|
||||
<template #default="scope">
|
||||
¥{{ scope.row.total_cost?.toFixed(0) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="fuel_consumption" label="油耗" width="70">
|
||||
<template #default="scope">
|
||||
{{ scope.row.fuel_consumption || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination
|
||||
v-if="showList"
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="loadRecords"
|
||||
style="margin-top: 15px; padding: 0 10px 10px;"
|
||||
size="small"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<EditFuelDialog
|
||||
v-model:visible="showEditDialog"
|
||||
:record="selectedRecord"
|
||||
@success="loadRecords"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, computed } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import EditFuelDialog from '../dialogs/fuel/EditFuelDialog.vue'
|
||||
|
||||
const props = defineProps({
|
||||
vehicleId: Number,
|
||||
showCharts: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showList: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['add-fuel'])
|
||||
|
||||
const API_BASE = 'https://api.yuany3721.site/carcost'
|
||||
|
||||
const loading = ref(false)
|
||||
const allRecords = ref([])
|
||||
const records = ref([])
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(15)
|
||||
const showEditDialog = ref(false)
|
||||
const selectedRecord = ref(null)
|
||||
const fuelTimeRange = ref('past_year') // 'past_year' | 'all'
|
||||
|
||||
// 获取一年前的日期
|
||||
const getOneYearAgo = () => {
|
||||
const now = new Date()
|
||||
return new Date(now.getFullYear() - 1, now.getMonth(), now.getDate())
|
||||
}
|
||||
|
||||
// 获取筛选后的月份列表
|
||||
const getFilteredMonths = () => {
|
||||
// 计算月度油费
|
||||
const monthlyData = {}
|
||||
allRecords.value.forEach(r => {
|
||||
const month = r.date.slice(0, 7)
|
||||
monthlyData[month] = (monthlyData[month] || 0) + r.total_cost
|
||||
})
|
||||
|
||||
// 获取所有月份(排序)
|
||||
let sortedMonths = Object.keys(monthlyData).sort()
|
||||
|
||||
// 根据时间范围筛选月份
|
||||
if (fuelTimeRange.value === 'past_year') {
|
||||
const oneYearAgo = getOneYearAgo()
|
||||
const cutoffMonth = `${oneYearAgo.getFullYear()}-${String(oneYearAgo.getMonth() + 1).padStart(2, '0')}`
|
||||
sortedMonths = sortedMonths.filter(m => m >= cutoffMonth)
|
||||
}
|
||||
|
||||
return { sortedMonths, monthlyData }
|
||||
}
|
||||
|
||||
// 油耗趋势图 - X轴为里程数
|
||||
const consumptionChartOption = computed(() => {
|
||||
if (!allRecords.value.length) return null
|
||||
|
||||
// 根据时间范围筛选记录
|
||||
let filteredRecords = allRecords.value.filter(r => r.fuel_consumption)
|
||||
|
||||
if (fuelTimeRange.value === 'past_year') {
|
||||
const oneYearAgo = getOneYearAgo()
|
||||
const cutoffDate = oneYearAgo.toISOString().slice(0, 10)
|
||||
filteredRecords = filteredRecords.filter(r => r.date >= cutoffDate)
|
||||
}
|
||||
|
||||
if (filteredRecords.length === 0) return null
|
||||
|
||||
// 准备油耗数据:按里程排序,用于连线
|
||||
const fuelConsumptionData = filteredRecords
|
||||
.sort((a, b) => a.mileage - b.mileage)
|
||||
.map(r => ({
|
||||
value: [r.mileage, r.fuel_consumption],
|
||||
date: r.date,
|
||||
mileage: r.mileage,
|
||||
fuelAmount: r.fuel_amount
|
||||
}))
|
||||
|
||||
if (fuelConsumptionData.length === 0) return null
|
||||
|
||||
// 计算X轴范围,增加边距
|
||||
const mileages = fuelConsumptionData.map(d => d.value[0])
|
||||
const minMileage = Math.min(...mileages)
|
||||
const maxMileage = Math.max(...mileages)
|
||||
const mileageRange = maxMileage - minMileage
|
||||
const padding = mileageRange * 0.05 // 5% 边距
|
||||
|
||||
// 找出最高3个和最低3个油耗点
|
||||
const sortedByConsumption = [...fuelConsumptionData].sort((a, b) => b.value[1] - a.value[1])
|
||||
const top3 = sortedByConsumption.slice(0, 3)
|
||||
const bottom3 = sortedByConsumption.slice(-3)
|
||||
|
||||
// 构建 markPoint 数据
|
||||
const markPointData = [
|
||||
...top3.map((d) => ({
|
||||
coord: d.value,
|
||||
value: d.value[1].toFixed(1),
|
||||
itemStyle: { color: '#f56c6c' },
|
||||
label: { color: '#f56c6c', position: 'top' }
|
||||
})),
|
||||
...bottom3.map((d) => ({
|
||||
coord: d.value,
|
||||
value: d.value[1].toFixed(1),
|
||||
itemStyle: { color: '#67c23a' },
|
||||
label: { color: '#67c23a', position: 'bottom' }
|
||||
}))
|
||||
]
|
||||
|
||||
return {
|
||||
grid: { left: '12%', right: '5%', top: '15%', bottom: '15%' },
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
name: 'km',
|
||||
min: Math.floor(minMileage - padding),
|
||||
max: Math.ceil(maxMileage + padding),
|
||||
nameTextStyle: { fontSize: 9 },
|
||||
axisLabel: {
|
||||
fontSize: 9
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: 'L/100km',
|
||||
nameTextStyle: { fontSize: 9 },
|
||||
axisLabel: { fontSize: 9 }
|
||||
},
|
||||
series: [{
|
||||
type: 'line',
|
||||
data: fuelConsumptionData.map(d => d.value),
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
lineStyle: { color: '#409eff', width: 2 },
|
||||
itemStyle: { color: '#409eff' },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(64, 158, 255, 0.3)' },
|
||||
{ offset: 1, color: 'rgba(64, 158, 255, 0.05)' }
|
||||
]
|
||||
}
|
||||
},
|
||||
markPoint: {
|
||||
data: markPointData,
|
||||
symbol: 'circle',
|
||||
symbolSize: 10,
|
||||
itemStyle: { borderColor: '#fff', borderWidth: 2 },
|
||||
label: { fontSize: 9, formatter: '{c}' }
|
||||
},
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
data: [
|
||||
{ type: 'average', name: '平均', label: { formatter: '{c}', fontSize: 8, position: 'insideEndTop' } }
|
||||
],
|
||||
lineStyle: { color: '#909399', type: 'dashed', width: 1 }
|
||||
}
|
||||
}],
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: function(params) {
|
||||
const data = fuelConsumptionData[params.dataIndex]
|
||||
return `${data.date}<br/>里程: ${data.mileage}km<br/>加油: ${data.fuelAmount}L<br/>油耗: <b>${params.value[1]} L/100km</b>`
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 月度油费图
|
||||
const monthlyCostChartOption = computed(() => {
|
||||
if (!allRecords.value.length) return null
|
||||
|
||||
const { sortedMonths, monthlyData } = getFilteredMonths()
|
||||
if (sortedMonths.length < 1) return null
|
||||
|
||||
const chartData = sortedMonths.map(m => ({
|
||||
month: m.slice(5),
|
||||
value: monthlyData[m].toFixed(0)
|
||||
}))
|
||||
|
||||
return {
|
||||
grid: { left: '5%', right: '5%', top: '15%', bottom: '15%', containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: chartData.map(d => d.month),
|
||||
axisLabel: { fontSize: 10 },
|
||||
axisLine: { show: true },
|
||||
axisTick: { show: false }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
show: false
|
||||
},
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: chartData.map(d => d.value),
|
||||
barWidth: '60%',
|
||||
itemStyle: {
|
||||
color: 'rgba(103, 194, 58, 0.6)',
|
||||
borderRadius: [4, 4, 0, 0]
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
fontSize: 9,
|
||||
formatter: '{c}'
|
||||
}
|
||||
}],
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const editRecord = (record) => {
|
||||
selectedRecord.value = record
|
||||
showEditDialog.value = true
|
||||
}
|
||||
|
||||
const loadRecords = async () => {
|
||||
if (!props.vehicleId) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await axios.get(`${API_BASE}/fuel-records/list?vehicle_id=${props.vehicleId}&limit=1000`)
|
||||
allRecords.value = res.data || []
|
||||
total.value = allRecords.value.length
|
||||
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
const end = start + pageSize.value
|
||||
records.value = allRecords.value.slice(start, end)
|
||||
} catch (error) {
|
||||
ElMessage.error('加载加油记录失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.vehicleId, () => {
|
||||
currentPage.value = 1
|
||||
loadRecords()
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(() => {
|
||||
loadRecords()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fuel-panel {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.fuel-panel :deep(.el-card) {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.fuel-panel :deep(.el-card__body) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.chart-container + .chart-container {
|
||||
margin-top: -5px;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 10px 10px 5px;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
padding: 0 20px;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
17
web/src/main.js
Normal file
17
web/src/main.js
Normal file
@ -0,0 +1,17 @@
|
||||
import { createApp } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import VueECharts from 'vue-echarts'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { LineChart, BarChart, PieChart, ScatterChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent, LegendComponent, TitleComponent, GraphicComponent, MarkPointComponent, MarkLineComponent } from 'echarts/components'
|
||||
import App from './App.vue'
|
||||
|
||||
// 注册 ECharts 组件
|
||||
use([CanvasRenderer, LineChart, BarChart, PieChart, ScatterChart, GridComponent, TooltipComponent, LegendComponent, TitleComponent, GraphicComponent, MarkPointComponent, MarkLineComponent])
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(ElementPlus)
|
||||
app.component('v-chart', VueECharts)
|
||||
app.mount('#app')
|
||||
296
web/src/style.css
Normal file
296
web/src/style.css
Normal file
@ -0,0 +1,296 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
10
web/vite.config.js
Normal file
10
web/vite.config.js
Normal file
@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
allowedHosts: ['yuany3721.site', '.yuany3721.site']
|
||||
}
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user