initial_commit

This commit is contained in:
yuany3721 2026-04-10 23:24:51 +08:00
parent 35112d356b
commit 10890e515f
40 changed files with 6663 additions and 0 deletions

120
AGENTS.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
web/README.md Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

22
web/package.json Normal file
View 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

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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

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
View 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

View 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>

View 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;
// 33
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") {
// 656
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") {
// 1112
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") {
// 656
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") {
// 1112
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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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') {
// 656
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') {
// 1112
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>

View 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%
// 33
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
View 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
View 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
View 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']
}
})