feat: Add OIDC authentication with Authentik and refactor project structure

Backend:
- Add auth.py for JWT token verification
- Update main.py to protect all routes with auth middleware
- Remove dashboard router (frontend handles aggregation)
- Add Docker support with Dockerfile and docker-compose.yml

Frontend:
- Add OIDC authentication using oidc-client-ts with PKCE flow
- Create router.js with auth guards for automatic login/logout
- Add api.js for unified Axios instance with auth headers
- Add composables: useAuth.js, useVehicleData.js for caching
- Add views/Main.vue as main application page
- Simplify App.vue to router-view container
- Add deploy-web.sh deployment script

Documentation:
- Update AGENTS.md with new architecture and auth flow
This commit is contained in:
yuany3721 2026-04-12 13:31:27 +08:00
parent 10890e515f
commit 71e11eaf30
37 changed files with 2517 additions and 1502 deletions

2
.gitignore vendored
View File

@ -300,3 +300,5 @@ dist
.yarn/install-state.gz .yarn/install-state.gz
.pnp.* .pnp.*
.ruff_cache/

189
AGENTS.md
View File

@ -1,70 +1,144 @@
# CarCost - Agent Instructions # CarCost - Agent Instructions
## Project Overview ## Project Overview
Full-stack vehicle expense tracking application. Full-stack vehicle expense tracking application with OIDC authentication.
- **Frontend**: Vue 3 + Vite + Element Plus + ECharts (`web/`) - **Frontend**: Vue 3 + Vite + Element Plus + ECharts (`web/`)
- **Backend**: FastAPI + SQLAlchemy + PostgreSQL (`api/`) - **Backend**: FastAPI + SQLAlchemy + PostgreSQL (`api/`)
- **Authentication**: Authentik OIDC with PKCE flow
## Architecture ## Architecture
### Monorepo Structure ### Monorepo Structure
``` ```
web/src/ carcost/
├── App.vue # Main app component ├── deploy-web.sh # Frontend deployment script
├── main.js # Entry with Element Plus + ECharts setup ├── web/
└── components/ # Reusable components │ ├── src/
├── panels/ # List panels with charts + table │ │ ├── App.vue # Root container (router-view only)
│ ├── FuelRecordsPanel.vue # Desktop & mobile shared │ │ ├── main.js # Entry with Element Plus + ECharts
│ └── CostRecordsPanel.vue # Desktop & mobile shared │ │ ├── router.js # Vue Router with OIDC auth guard
├── charts/ # Chart components │ │ ├── api.js # Unified Axios instance with auth
│ └── DashboardCharts.vue │ │ ├── composables/ # Vue composables
├── cards/ # Card components │ │ │ ├── useVehicleData.js
│ └── StatsCards.vue │ │ │ └── useAuth.js # OIDC configuration (userManager only)
└── dialogs/ # Dialog components │ │ ├── views/ # Page views
├── vehicle/ │ │ │ └── Main.vue # Main application page
├── fuel/ │ │ └── components/ # Reusable components
└── cost/ │ └── dist/ # Build output
├── api/
│ ├── main.py # FastAPI entry with auth middleware
│ ├── auth.py # JWT verification module
│ ├── config.py # Configuration
│ ├── database.py # Database connection
│ ├── models.py # SQLAlchemy models
│ ├── schemas.py # Pydantic schemas
│ └── routers/
│ ├── vehicles.py # Vehicle CRUD
│ ├── fuel_records.py # Fuel record CRUD
│ └── costs.py # Cost record CRUD
``` ```
**Responsive Design Pattern**: ### Authentication Flow
- `App.vue` uses CSS media queries to switch between desktop/mobile layouts
- `*Panel.vue` components handle both desktop and mobile via `showCharts`/`showList` props **Frontend (Vue Router Guard)**:
- Desktop: Dual-column layout with StatsCards + DashboardCharts + Panels side-by-side 1. `router.beforeEach` checks authentication status
- Mobile: Single-column with view switching (dashboard/fuel/cost tabs) 2. Unauthenticated → redirect to Authentik OIDC login
3. Callback `/auth/callback` → process code, exchange for token
4. Token stored in localStorage, set to Axios headers
5. All API requests include `Authorization: Bearer <token>`
**Backend (FastAPI JWT)**:
1. `auth.py` validates JWT token from Authentik
2. All routes protected by `get_current_user` dependency
3. Returns 401 for invalid/missing tokens
### API Routing Convention ### API Routing Convention
All API routes include `/carcost` prefix: All API routes include `/carcost` prefix:
- `/carcost/vehicles/*` - Vehicle CRUD - `GET/POST /carcost/vehicles/*` - Vehicle CRUD
- `/carcost/fuel_records/*` - Fuel records - `GET/POST /carcost/fuel-records/*` - Fuel records
- `/carcost/costs/*` - Cost records - `GET/POST /carcost/costs/*` - Cost records
- `/carcost/dashboard/*` - Dashboard data
Frontend uses: `API_BASE = 'https://api.yuany3721.site/carcost'` Frontend API base: `https://api.yuany3721.site/carcost`
### Key Files
**Frontend Auth**:
- `web/src/router.js` - Route guards handle OIDC flow
- `web/src/api.js` - Unified Axios instance with auth header management
- `web/src/composables/useAuth.js` - OIDC client configuration
**Backend Auth**:
- `api/auth.py` - JWT token verification
- `api/main.py` - Protected routes with auth dependency
### Database Models ### 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` #### Vehicle
- **Cost**: `id`, `vehicle_id`, `date`, `type`, `amount`, `mileage`, `is_installment`, `installment_months` - `id`, `name`, `purchase_date`, `initial_mileage`
- `is_deleted` - Soft delete flag
- `created_at`, `updated_at`
#### FuelRecord
- `id`, `vehicle_id`, `date`, `mileage`
- `fuel_amount` - Liters
- `fuel_price` - Price per liter
- `display_cost` - Machine display amount (机显总价)
- `actual_cost` - Actual paid amount (实付金额)
- `is_full_tank` - Boolean
- `notes` - Gas station name etc.
- `is_deleted` - Soft delete flag
#### Cost
- `id`, `vehicle_id`, `date`, `type`, `amount`
- `mileage` - Optional
- `notes`
- `is_installment` - Whether to spread across months
- `installment_months` - Number of months to spread
- `is_deleted` - Soft delete flag
**Cost Types**: 保养/维修/保险/停车/过路费/洗车/违章/其他
All models use soft delete (`is_deleted` boolean). All models use soft delete (`is_deleted` boolean).
## Authentication (Authentik OIDC)
### Authentik Configuration
**OIDC Discovery URL**: `https://auth.yuany3721.site/application/o/car-cost/.well-known/openid-configuration`
**Client ID**: `27UljrOp2LfCg3fjEo1n8vOiRyCFzqEcnBFiUK59`
**Endpoints**:
- **Authorization**: `https://auth.yuany3721.site/application/o/authorize/`
- **Token**: `https://auth.yuany3721.site/application/o/token/`
- **UserInfo**: `https://auth.yuany3721.site/application/o/userinfo/`
- **End Session**: `https://auth.yuany3721.site/application/o/car-cost/end-session/`
### Environment Variables
```bash
# api/.env
DATABASE_URL=postgresql://user:pass@host:port/carcost
DEBUG=true
AUTHENTIK_ISSUER=https://auth.yuany3721.site/application/o/car-cost/
AUTHENTIK_CLIENT_ID=27UljrOp2LfCg3fjEo1n8vOiRyCFzqEcnBFiUK59
```
## Development Commands ## Development Commands
### Backend (api/) ### Backend (api/)
```bash ```bash
# Setup (requires Python 3.x)
cd api cd api
python -m venv venv
source venv/bin/activate source venv/bin/activate
pip install -r requirements.txt
# Run dev server # Run dev server
python main.py python main.py
# or # or
uvicorn main:app --host 0.0.0.0 --port 7030 --reload uvicorn main:app --host 0.0.0.0 --port 7030 --reload
# Run tests # Import fuel records from CSV
python test_api.py python import_fuel_records.py
``` ```
### Frontend (web/) ### Frontend (web/)
@ -72,7 +146,7 @@ python test_api.py
cd web cd web
npm install npm install
# Dev server (exposes to all hosts) # Dev server
npm run dev npm run dev
# Build for production # Build for production
@ -82,39 +156,44 @@ npm run build
npm run preview npm run preview
``` ```
### Deployment
```bash
# Deploy frontend (from carcost root)
./deploy-web.sh
```
## Key Configuration Files ## Key Configuration Files
### api/.env ### api/.env
``` ```
DATABASE_URL=postgresql://user:pass@host:port/carcost DATABASE_URL=postgresql://user:pass@host:port/carcost
DEBUG=true DEBUG=true
AUTHENTIK_ISSUER=https://auth.yuany3721.site/application/o/car-cost/
AUTHENTIK_CLIENT_ID=27UljrOp2LfCg3fjEo1n8vOiRyCFzqEcnBFiUK59
``` ```
### web/vite.config.js ### web/vite.config.js
- `server.allowedHosts: ['yuany3721.site', '.yuany3721.site']` - Required for production domain access - `server.allowedHosts: ['yuany3721.site', '.yuany3721.site']`
## Code Patterns ## Code Patterns
### Frontend (Vue 3 Composition API) ### Frontend
- Uses `<script setup>` syntax - Vue 3 Composition API with `<script setup>`
- Element Plus for UI components (with Chinese text labels) - Element Plus for UI components
- ECharts registered globally via `vue-echarts` - Unified Axios instance (`api.js`) with automatic auth header
- Axios for API calls - Vue Router guards handle all auth logic before component render
- Responsive design with mobile/desktop layouts - `useVehicleData` composable for global data caching
### Backend (FastAPI) ### Backend
- Routers auto-create tables via `Base.metadata.create_all()` - FastAPI with JWT token verification
- CORS configured for all origins (dev-friendly, restrict in production) - All routes protected by default
- SQLAlchemy 2.0 style with `declarative_base()` - Manual session management
- Database connection pooling: `pool_size=5, max_overflow=10` - SQLAlchemy 2.0 style
## Testing
- `api/test_api.py`: Integration tests against live API (api.yuany3721.site)
- Tests cover costs CRUD operations and type validation
## Important Notes ## Important Notes
- **Port**: Backend runs on 7030, frontend dev server typically on 5173
- **CORS**: Backend allows all origins (`["*"]`) - change for production - **Port**: Backend runs on 7030, frontend dev server on 5173
- **Soft Deletes**: All entities use `is_deleted` flag; queries should filter it - **CORS**: Backend allows all origins (`["*"]`) for dev
- **Cost Types**: 保养/维修/保险/停车/洗车/违章/过路费/其他 - **Soft Deletes**: All entities use `is_deleted` flag
- **Frontend Max Width**: Content constrained to 900px centered - **Frontend Max Width**: Content constrained to 900px centered
- **Authentication**: Fully automatic, no login/logout buttons in UI

14
api/Dockerfile Normal file
View File

@ -0,0 +1,14 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt -i https://mirrors.ustc.edu.cn/pypi/web/simple
COPY *.py .
COPY routers ./routers
COPY .env .
EXPOSE 7030
CMD ["python3", "main.py"]

184
api/auth.py Normal file
View File

@ -0,0 +1,184 @@
"""
JWT 认证模块
用于验证 Authentik 颁发的 JWT Token
"""
import httpx
from fastapi import HTTPException, Depends, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import jwt
from jose.exceptions import JWTError
from typing import Optional, Dict, Any
from functools import lru_cache
import os
# 从环境变量获取配置
AUTHENTIK_ISSUER = os.getenv(
"AUTHENTIK_ISSUER", "https://auth.yuany3721.site/application/o/car-cost/"
)
AUTHENTIK_CLIENT_ID = os.getenv(
"AUTHENTIK_CLIENT_ID", "27UljrOp2LfCg3fjEo1n8vOiRyCFzqEcnBFiUK59"
)
print(f"[Auth] AUTHENTIK_ISSUER: {AUTHENTIK_ISSUER}")
print(f"[Auth] AUTHENTIK_CLIENT_ID: {AUTHENTIK_CLIENT_ID}")
# JWKS 缓存
_jwks_cache = None
_jwks_cache_time = None
# Bearer token 验证器
security = HTTPBearer(auto_error=False)
async def fetch_jwks() -> Dict[str, Any]:
"""获取 Authentik 的 JWKS公钥集"""
global _jwks_cache, _jwks_cache_time
# JWKS URL
jwks_url = f"{AUTHENTIK_ISSUER.rstrip('/')}/jwks/"
print(f"[Auth] Fetching JWKS from: {jwks_url}")
try:
async with httpx.AsyncClient() as client:
response = await client.get(jwks_url, timeout=10.0)
response.raise_for_status()
jwks = response.json()
print(
f"[Auth] JWKS fetched successfully, keys: {list(jwks.get('keys', []))}"
)
return jwks
except Exception as e:
print(f"[Auth] 获取 JWKS 失败: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="无法获取认证公钥"
)
def get_public_key(jwks: Dict[str, Any], kid: str) -> Optional[str]:
"""从 JWKS 中获取指定 kid 的公钥"""
for key in jwks.get("keys", []):
if key.get("kid") == kid:
return key
return None
async def verify_token(token: str) -> Dict[str, Any]:
"""
验证 JWT Token
"""
try:
print(f"[Auth] Verifying token (first 50 chars): {token[:50]}...")
# 1. 获取 JWKS
jwks = await fetch_jwks()
# 2. 获取 token header 中的 kid
header = jwt.get_unverified_header(token)
kid = header.get("kid")
print(f"[Auth] Token header: {header}")
if not kid:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token 格式无效",
headers={"WWW-Authenticate": "Bearer"},
)
# 3. 获取对应的公钥
public_key = get_public_key(jwks, kid)
if not public_key:
print(f"[Auth] Public key not found for kid: {kid}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token 签名密钥无效",
headers={"WWW-Authenticate": "Bearer"},
)
print(f"[Auth] Found public key for kid: {kid}")
# 4. 验证 token
print(
f"[Auth] Decoding token with issuer: {AUTHENTIK_ISSUER}, audience: {AUTHENTIK_CLIENT_ID}"
)
payload = jwt.decode(
token,
public_key,
algorithms=["RS256"],
issuer=AUTHENTIK_ISSUER,
audience=AUTHENTIK_CLIENT_ID,
)
print(f"[Auth] Token verified successfully, payload: {payload}")
return payload
except JWTError as e:
print(f"[Auth] JWT verification failed: {e}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Token 验证失败: {str(e)}",
headers={"WWW-Authenticate": "Bearer"},
)
except Exception as e:
print(f"[Auth] 验证 token 时出错: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="服务器内部错误"
)
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> Dict[str, Any]:
"""
FastAPI 依赖项获取当前用户
"""
print(f"[Auth] get_current_user called, credentials: {credentials}")
if not credentials:
print("[Auth] No credentials provided")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="缺少认证信息",
headers={"WWW-Authenticate": "Bearer"},
)
token = credentials.credentials
print(f"[Auth] Token received (first 30 chars): {token[:30]}...")
return await verify_token(token)
async def get_current_user_optional(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> Optional[Dict[str, Any]]:
"""
FastAPI 依赖项获取当前用户可选
"""
if not credentials:
return None
try:
token = credentials.credentials
return await verify_token(token)
except HTTPException:
return None
class AuthRequired:
"""
用于在路由中要求认证的装饰器类视图方式
"""
async def __call__(
self, credentials: HTTPAuthorizationCredentials = Depends(security)
):
await get_current_user(credentials)
return True
# 便捷函数:检查 token 是否有效(不抛出异常)
async def is_token_valid(token: str) -> bool:
"""检查 token 是否有效,返回布尔值"""
try:
await verify_token(token)
return True
except HTTPException:
return False

View File

@ -17,9 +17,17 @@ SessionLocal = sessionmaker(bind=engine)
def get_session() -> Session: def get_session() -> Session:
"""获取新的数据库会话 """获取新的数据库会话
使用示例: 使用示例:
with get_session() as session: session = get_session()
try:
result = session.query(Model).all() result = session.query(Model).all()
session.commit()
return result
except Exception:
session.rollback()
raise
finally:
session.close()
""" """
return SessionLocal() return SessionLocal()

11
api/docker-compose.yml Normal file
View File

@ -0,0 +1,11 @@
services:
api_carcost:
build: .
container_name: api_carcost
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "7030:7030"
environment:
- TZ=Asia/Shanghai
restart: unless-stopped

View File

@ -1,8 +1,9 @@
from fastapi import FastAPI from fastapi import FastAPI, Depends
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from config import settings from config import settings
from routers import vehicles_router, fuel_records_router, costs_router, dashboard_router from routers import vehicles_router, fuel_records_router, costs_router
from auth import get_current_user
# 创建 FastAPI 应用 # 创建 FastAPI 应用
app = FastAPI(title=settings.TITLE, version=settings.VERSION, debug=settings.DEBUG) app = FastAPI(title=settings.TITLE, version=settings.VERSION, debug=settings.DEBUG)
@ -10,17 +11,19 @@ app = FastAPI(title=settings.TITLE, version=settings.VERSION, debug=settings.DEB
# 配置 CORS # 配置 CORS
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], # 生产环境应该限制具体域名
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_origin_regex=r"http[s]?://172\.30\.0\..*:5173|http[s]?://.*\.yuany3721\.site|http[s]?://yuany3721\.site",
allow_methods=["GET", "POST"],
allow_headers=["*"], allow_headers=["*"],
) )
# 注册路由 - 添加 /carcost 前缀 # 注册路由 - 添加 /carcost 前缀,并启用认证保护
app.include_router(vehicles_router, prefix="/carcost") # 所有路由都需要认证
app.include_router(fuel_records_router, prefix="/carcost") dependencies = [Depends(get_current_user)]
app.include_router(costs_router, prefix="/carcost")
app.include_router(dashboard_router, prefix="/carcost") app.include_router(vehicles_router, prefix="/carcost", dependencies=dependencies)
app.include_router(fuel_records_router, prefix="/carcost", dependencies=dependencies)
app.include_router(costs_router, prefix="/carcost", dependencies=dependencies)
@app.get("/") @app.get("/")
@ -30,6 +33,7 @@ def root():
"message": "Welcome to CarCost API", "message": "Welcome to CarCost API",
"version": settings.VERSION, "version": settings.VERSION,
"docs": "/docs", "docs": "/docs",
"auth": "Protected by Authentik OIDC",
} }

View File

@ -1,7 +1,18 @@
""" """
SQLAlchemy 数据库模型定义 SQLAlchemy 数据库模型定义
""" """
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, Numeric, Date, ForeignKey
from sqlalchemy import (
Column,
Integer,
String,
DateTime,
Boolean,
Text,
Numeric,
Date,
ForeignKey,
)
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from datetime import datetime from datetime import datetime
@ -12,6 +23,7 @@ Base = declarative_base()
class Vehicle(Base): class Vehicle(Base):
"""车辆表""" """车辆表"""
__tablename__ = "vehicles" __tablename__ = "vehicles"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
@ -29,6 +41,7 @@ class Vehicle(Base):
class FuelRecord(Base): class FuelRecord(Base):
"""加油记录表""" """加油记录表"""
__tablename__ = "fuel_records" __tablename__ = "fuel_records"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
@ -37,7 +50,8 @@ class FuelRecord(Base):
mileage = Column(Integer, nullable=False) mileage = Column(Integer, nullable=False)
fuel_amount = Column(Numeric(10, 2), nullable=False) fuel_amount = Column(Numeric(10, 2), nullable=False)
fuel_price = Column(Numeric(10, 2), nullable=True) fuel_price = Column(Numeric(10, 2), nullable=True)
total_cost = Column(Numeric(10, 2), nullable=False) display_cost = Column(Numeric(10, 2), nullable=True) # 机显总价(加油机显示金额)
actual_cost = Column(Numeric(10, 2), nullable=False) # 实付金额(优惠后实际支付)
is_full_tank = Column(Boolean, default=True) is_full_tank = Column(Boolean, default=True)
notes = Column(Text, default="") notes = Column(Text, default="")
is_deleted = Column(Boolean, default=False) # 软删除标记 is_deleted = Column(Boolean, default=False) # 软删除标记
@ -50,12 +64,15 @@ class FuelRecord(Base):
class Cost(Base): class Cost(Base):
"""费用记录表""" """费用记录表"""
__tablename__ = "costs" __tablename__ = "costs"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
vehicle_id = Column(Integer, ForeignKey("vehicles.id"), nullable=False) vehicle_id = Column(Integer, ForeignKey("vehicles.id"), nullable=False)
date = Column(Date, nullable=False) date = Column(Date, nullable=False)
type = Column(String(20), nullable=False) # 保养/维修/保险/停车/洗车/违章/过路费/其他 type = Column(
String(20), nullable=False
) # 保养/维修/保险/停车/洗车/违章/过路费/其他
amount = Column(Numeric(10, 2), nullable=False) amount = Column(Numeric(10, 2), nullable=False)
mileage = Column(Integer, nullable=True) mileage = Column(Integer, nullable=True)
notes = Column(Text, default="") notes = Column(Text, default="")

View File

@ -1,8 +1,33 @@
annotated-types==0.7.0
anyio==4.13.0
certifi==2026.2.25
cffi==2.0.0
click==8.3.2
cryptography==46.0.7
ecdsa==0.19.2
fastapi==0.115.0 fastapi==0.115.0
uvicorn[standard]==0.32.0 greenlet==3.4.0
sqlalchemy==2.0.36 h11==0.16.0
httpcore==1.0.9
httptools==0.7.1
httpx==0.28.1
idna==3.11
psycopg2-binary==2.9.10 psycopg2-binary==2.9.10
pyasn1==0.6.3
pycparser==3.0
pydantic==2.9.2 pydantic==2.9.2
pydantic-settings==2.6.1 pydantic-settings==2.6.1
pydantic_core==2.23.4
python-dotenv==1.0.1 python-dotenv==1.0.1
python-jose==3.5.0
python-multipart==0.0.17 python-multipart==0.0.17
PyYAML==6.0.3
rsa==4.9.1
six==1.17.0
SQLAlchemy==2.0.36
starlette==0.38.6
typing_extensions==4.15.0
uvicorn==0.32.0
uvloop==0.22.1
watchfiles==1.1.1
websockets==16.0

View File

@ -2,19 +2,18 @@
路由模块统一导出 路由模块统一导出
使用示例: 使用示例:
from routers import vehicles_router, fuel_records_router, costs_router, dashboard_router from routers import vehicles_router, fuel_records_router, costs_router
app.include_router(vehicles_router, prefix="/carcost") app.include_router(vehicles_router, prefix="/carcost")
app.include_router(fuel_records_router, prefix="/carcost") app.include_router(fuel_records_router, prefix="/carcost")
""" """
from .vehicles import router as vehicles_router from .vehicles import router as vehicles_router
from .fuel_records import router as fuel_records_router from .fuel_records import router as fuel_records_router
from .costs import router as costs_router from .costs import router as costs_router
from .dashboard import router as dashboard_router
__all__ = [ __all__ = [
"vehicles_router", "vehicles_router",
"fuel_records_router", "fuel_records_router",
"costs_router", "costs_router",
"dashboard_router",
] ]

View File

@ -1,7 +1,6 @@
from fastapi import APIRouter, HTTPException, Body from fastapi import APIRouter, HTTPException
from typing import Optional from typing import Optional
from decimal import Decimal from decimal import Decimal
from datetime import date as date_module
from database import get_session from database import get_session
from models import Cost, Vehicle from models import Cost, Vehicle
@ -10,7 +9,6 @@ from schemas import (
CostUpdate, CostUpdate,
CostDelete, CostDelete,
CostResponse, CostResponse,
COST_TYPES,
) )
@ -37,36 +35,34 @@ def _to_cost_response(cost: Cost) -> CostResponse:
@router.get("/list", response_model=list[CostResponse]) @router.get("/list", response_model=list[CostResponse])
def get_costs( def get_costs(
vehicle_id: Optional[int] = None, cost_type: Optional[str] = None, limit: int = 50 vehicle_id: Optional[int] = None,
cost_type: Optional[str] = None,
): ):
"""获取费用记录列表(排除已删除)""" """获取费用记录列表(排除已删除),返回全部数据"""
with get_session() as session: session = get_session()
try:
query = session.query(Cost).filter(Cost.is_deleted == False) query = session.query(Cost).filter(Cost.is_deleted == False)
if vehicle_id: if vehicle_id:
query = query.filter(Cost.vehicle_id == vehicle_id) query = query.filter(Cost.vehicle_id == vehicle_id)
if cost_type: if cost_type:
query = query.filter(Cost.type == cost_type) query = query.filter(Cost.type == cost_type)
costs = query.order_by(Cost.date.desc()).limit(limit).all() costs = query.order_by(Cost.date.desc()).all()
return [_to_cost_response(c) for c in costs] return [_to_cost_response(c) for c in costs]
finally:
session.close()
@router.post("/create", response_model=CostResponse) @router.post("/create", response_model=CostResponse)
def create_cost(cost: CostCreate): def create_cost(cost: CostCreate):
"""添加费用记录""" """添加费用记录"""
with get_session() as session: session = get_session()
try:
# 验证车辆存在 # 验证车辆存在
vehicle = session.query(Vehicle).filter(Vehicle.id == cost.vehicle_id).first() vehicle = session.query(Vehicle).filter(Vehicle.id == cost.vehicle_id).first()
if not vehicle: if not vehicle:
raise HTTPException(status_code=404, detail="Vehicle not found") 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( db_cost = Cost(
vehicle_id=cost.vehicle_id, vehicle_id=cost.vehicle_id,
date=cost.date, date=cost.date,
@ -82,59 +78,47 @@ def create_cost(cost: CostCreate):
session.refresh(db_cost) session.refresh(db_cost)
return _to_cost_response(db_cost) return _to_cost_response(db_cost)
finally:
session.close()
@router.post("/update") @router.post("/update", response_model=CostResponse)
def update_cost( def update_cost(cost_update: CostUpdate):
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: session = get_session()
cost = session.query(Cost).filter(Cost.id == id).first() try:
cost = session.query(Cost).filter(Cost.id == cost_update.id).first()
if not cost: if not cost:
raise HTTPException(status_code=404, detail="Cost record not found") raise HTTPException(status_code=404, detail="Cost record not found")
# 验证费用类型 if cost_update.date is not None:
if type is not None: cost.date = cost_update.date
if type not in COST_TYPES: if cost_update.type is not None:
raise HTTPException( cost.type = cost_update.type
status_code=400, if cost_update.amount is not None:
detail=f"Invalid cost type. Must be one of: {', '.join(COST_TYPES)}", cost.amount = Decimal(str(cost_update.amount))
) if cost_update.mileage is not None:
cost.type = type cost.mileage = cost_update.mileage
if cost_update.notes is not None:
# 处理日期转换 cost.notes = cost_update.notes or ""
if date is not None: if cost_update.is_installment is not None:
cost.date = date_module.fromisoformat(date) cost.is_installment = cost_update.is_installment
if cost_update.installment_months is not None:
if amount is not None: cost.installment_months = cost_update.installment_months
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.commit()
session.refresh(cost) session.refresh(cost)
return _to_cost_response(cost) return _to_cost_response(cost)
finally:
session.close()
@router.post("/delete") @router.post("/delete")
def delete_cost(cost: CostDelete): def delete_cost(cost: CostDelete):
"""软删除费用记录""" """软删除费用记录"""
with get_session() as session: session = get_session()
try:
db_cost = ( db_cost = (
session.query(Cost) session.query(Cost)
.filter(Cost.id == cost.id, Cost.is_deleted == False) .filter(Cost.id == cost.id, Cost.is_deleted == False)
@ -146,9 +130,5 @@ def delete_cost(cost: CostDelete):
db_cost.is_deleted = True db_cost.is_deleted = True
session.commit() session.commit()
return {"message": "Cost record deleted successfully"} return {"message": "Cost record deleted successfully"}
finally:
session.close()
@router.get("/types")
def get_cost_types():
"""获取费用类型列表"""
return {"types": COST_TYPES}

View File

@ -1,128 +0,0 @@
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,
)

View File

@ -15,135 +15,61 @@ from schemas import (
router = APIRouter(prefix="/fuel-records", tags=["fuel_records"]) 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]) @router.get("/list", response_model=list[FuelRecordResponse])
def get_fuel_records(vehicle_id: Optional[int] = None, limit: int = 50): def get_fuel_records(vehicle_id: Optional[int] = None):
"""获取加油记录列表(排除已删除)""" """获取加油记录列表(排除已删除),返回原始数据不计算油耗"""
with get_session() as session: session = get_session()
try:
query = session.query(FuelRecord).filter(FuelRecord.is_deleted == False) query = session.query(FuelRecord).filter(FuelRecord.is_deleted == False)
if vehicle_id: if vehicle_id:
query = query.filter(FuelRecord.vehicle_id == vehicle_id) query = query.filter(FuelRecord.vehicle_id == vehicle_id)
records = ( records = query.order_by(
query.order_by(FuelRecord.date.desc(), FuelRecord.mileage.desc()) FuelRecord.date.desc(), FuelRecord.mileage.desc()
.limit(limit) ).all()
.all()
) # 直接返回原始数据,不计算油耗
return [get_record_with_consumption(session, r) for r in records] return [
FuelRecordResponse(
id=r.id,
vehicle_id=r.vehicle_id,
date=r.date,
mileage=r.mileage,
fuel_amount=float(r.fuel_amount),
fuel_price=float(r.fuel_price) if r.fuel_price else None,
display_cost=float(r.display_cost) if r.display_cost else None,
actual_cost=float(r.actual_cost),
is_full_tank=r.is_full_tank,
notes=r.notes,
fuel_consumption=None, # 油耗由前端计算
is_deleted=r.is_deleted,
created_at=r.created_at,
updated_at=r.updated_at,
)
for r in records
]
finally:
session.close()
@router.post("/create", response_model=FuelRecordResponse) @router.post("/create", response_model=FuelRecordResponse)
def create_fuel_record(record: FuelRecordCreate): def create_fuel_record(record: FuelRecordCreate):
"""添加加油记录""" """添加加油记录"""
with get_session() as session: session = get_session()
try:
# 验证车辆存在 # 验证车辆存在
vehicle = session.query(Vehicle).filter(Vehicle.id == record.vehicle_id).first() vehicle = session.query(Vehicle).filter(Vehicle.id == record.vehicle_id).first()
if not vehicle: if not vehicle:
raise HTTPException(status_code=404, detail="Vehicle not found") raise HTTPException(status_code=404, detail="Vehicle not found")
# 自动计算总价或单价 # 自动计算价格
fuel_price = record.fuel_price fuel_price = record.fuel_price
total_cost = record.total_cost display_cost = record.display_cost
actual_cost = record.actual_cost
if fuel_price is None and total_cost is not None: # 如果没有单价,根据总价计算
# 只有总价,计算单价 if fuel_price is None and actual_cost is not None and record.fuel_amount > 0:
fuel_price = total_cost / record.fuel_amount fuel_price = actual_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( db_record = FuelRecord(
vehicle_id=record.vehicle_id, vehicle_id=record.vehicle_id,
@ -151,20 +77,43 @@ def create_fuel_record(record: FuelRecordCreate):
mileage=record.mileage, mileage=record.mileage,
fuel_amount=Decimal(str(record.fuel_amount)), fuel_amount=Decimal(str(record.fuel_amount)),
fuel_price=Decimal(str(fuel_price)) if fuel_price else None, fuel_price=Decimal(str(fuel_price)) if fuel_price else None,
total_cost=Decimal(str(total_cost)), display_cost=Decimal(str(display_cost)) if display_cost else None,
actual_cost=Decimal(str(actual_cost)),
is_full_tank=record.is_full_tank, is_full_tank=record.is_full_tank,
notes=record.notes or "", notes=record.notes or "",
) )
session.add(db_record) session.add(db_record)
session.commit() session.commit()
session.refresh(db_record) session.refresh(db_record)
return get_record_with_consumption(session, db_record)
# 返回原始数据,不计算油耗
return FuelRecordResponse(
id=db_record.id,
vehicle_id=db_record.vehicle_id,
date=db_record.date,
mileage=db_record.mileage,
fuel_amount=float(db_record.fuel_amount),
fuel_price=float(db_record.fuel_price) if db_record.fuel_price else None,
display_cost=float(db_record.display_cost)
if db_record.display_cost
else None,
actual_cost=float(db_record.actual_cost),
is_full_tank=db_record.is_full_tank,
notes=db_record.notes,
fuel_consumption=None, # 油耗由前端计算
is_deleted=db_record.is_deleted,
created_at=db_record.created_at,
updated_at=db_record.updated_at,
)
finally:
session.close()
@router.post("/update", response_model=FuelRecordResponse) @router.post("/update", response_model=FuelRecordResponse)
def update_fuel_record(record_update: FuelRecordUpdate): def update_fuel_record(record_update: FuelRecordUpdate):
"""更新加油记录""" """更新加油记录"""
with get_session() as session: session = get_session()
try:
record = ( record = (
session.query(FuelRecord).filter(FuelRecord.id == record_update.id).first() session.query(FuelRecord).filter(FuelRecord.id == record_update.id).first()
) )
@ -178,36 +127,46 @@ def update_fuel_record(record_update: FuelRecordUpdate):
record.mileage = record_update.mileage record.mileage = record_update.mileage
if record_update.fuel_amount is not None: if record_update.fuel_amount is not None:
record.fuel_amount = Decimal(str(record_update.fuel_amount)) record.fuel_amount = Decimal(str(record_update.fuel_amount))
if record_update.fuel_price is not None:
record.fuel_price = Decimal(str(record_update.fuel_price))
if record_update.display_cost is not None:
record.display_cost = Decimal(str(record_update.display_cost))
if record_update.actual_cost is not None:
record.actual_cost = Decimal(str(record_update.actual_cost))
if record_update.is_full_tank is not None: if record_update.is_full_tank is not None:
record.is_full_tank = record_update.is_full_tank record.is_full_tank = record_update.is_full_tank
if record_update.notes is not None: if record_update.notes is not None:
record.notes = record_update.notes 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.commit()
session.refresh(record) session.refresh(record)
return get_record_with_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,
display_cost=float(record.display_cost) if record.display_cost else None,
actual_cost=float(record.actual_cost),
is_full_tank=record.is_full_tank,
notes=record.notes,
fuel_consumption=None, # 油耗由前端计算
is_deleted=record.is_deleted,
created_at=record.created_at,
updated_at=record.updated_at,
)
finally:
session.close()
@router.post("/delete") @router.post("/delete")
def delete_fuel_record(record: FuelRecordDelete): def delete_fuel_record(record: FuelRecordDelete):
"""软删除加油记录""" """软删除加油记录"""
with get_session() as session: session = get_session()
try:
db_record = ( db_record = (
session.query(FuelRecord) session.query(FuelRecord)
.filter(FuelRecord.id == record.id, FuelRecord.is_deleted == False) .filter(FuelRecord.id == record.id, FuelRecord.is_deleted == False)
@ -219,3 +178,5 @@ def delete_fuel_record(record: FuelRecordDelete):
db_record.is_deleted = True db_record.is_deleted = True
session.commit() session.commit()
return {"message": "Fuel record deleted successfully"} return {"message": "Fuel record deleted successfully"}
finally:
session.close()

View File

@ -9,7 +9,6 @@ from schemas import (
VehicleUpdate, VehicleUpdate,
VehicleDelete, VehicleDelete,
VehicleResponse, VehicleResponse,
VehicleStats,
) )
@ -19,7 +18,8 @@ router = APIRouter(prefix="/vehicles", tags=["vehicles"])
@router.get("/list", response_model=list[VehicleResponse]) @router.get("/list", response_model=list[VehicleResponse])
def get_vehicles(): def get_vehicles():
"""获取所有车辆列表(排除已删除)""" """获取所有车辆列表(排除已删除)"""
with get_session() as session: session = get_session()
try:
vehicles = ( vehicles = (
session.query(Vehicle) session.query(Vehicle)
.filter(Vehicle.is_deleted == False) .filter(Vehicle.is_deleted == False)
@ -27,12 +27,15 @@ def get_vehicles():
.all() .all()
) )
return vehicles return vehicles
finally:
session.close()
@router.post("/create", response_model=VehicleResponse) @router.post("/create", response_model=VehicleResponse)
def create_vehicle(vehicle: VehicleCreate): def create_vehicle(vehicle: VehicleCreate):
"""添加新车辆""" """添加新车辆"""
with get_session() as session: session = get_session()
try:
db_vehicle = Vehicle( db_vehicle = Vehicle(
name=vehicle.name, name=vehicle.name,
purchase_date=vehicle.purchase_date, purchase_date=vehicle.purchase_date,
@ -42,12 +45,15 @@ def create_vehicle(vehicle: VehicleCreate):
session.commit() session.commit()
session.refresh(db_vehicle) session.refresh(db_vehicle)
return db_vehicle return db_vehicle
finally:
session.close()
@router.post("/update", response_model=VehicleResponse) @router.post("/update", response_model=VehicleResponse)
def update_vehicle(vehicle: VehicleUpdate): def update_vehicle(vehicle: VehicleUpdate):
"""更新车辆信息""" """更新车辆信息"""
with get_session() as session: session = get_session()
try:
db_vehicle = session.query(Vehicle).filter(Vehicle.id == vehicle.id).first() db_vehicle = session.query(Vehicle).filter(Vehicle.id == vehicle.id).first()
if not db_vehicle: if not db_vehicle:
raise HTTPException(status_code=404, detail="Vehicle not found") raise HTTPException(status_code=404, detail="Vehicle not found")
@ -62,12 +68,15 @@ def update_vehicle(vehicle: VehicleUpdate):
session.commit() session.commit()
session.refresh(db_vehicle) session.refresh(db_vehicle)
return db_vehicle return db_vehicle
finally:
session.close()
@router.post("/delete") @router.post("/delete")
def delete_vehicle(vehicle: VehicleDelete): def delete_vehicle(vehicle: VehicleDelete):
"""软删除车辆""" """软删除车辆"""
with get_session() as session: session = get_session()
try:
db_vehicle = ( db_vehicle = (
session.query(Vehicle) session.query(Vehicle)
.filter(Vehicle.id == vehicle.id, Vehicle.is_deleted == False) .filter(Vehicle.id == vehicle.id, Vehicle.is_deleted == False)
@ -79,49 +88,5 @@ def delete_vehicle(vehicle: VehicleDelete):
db_vehicle.is_deleted = True db_vehicle.is_deleted = True
session.commit() session.commit()
return {"message": "Vehicle deleted successfully"} return {"message": "Vehicle deleted successfully"}
finally:
session.close()
@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,
)

View File

@ -4,7 +4,7 @@
from pydantic import BaseModel, field_validator, ConfigDict from pydantic import BaseModel, field_validator, ConfigDict
from typing import Optional, List, Union from typing import Optional, List, Union
from datetime import date, datetime from datetime import date as date_cls, datetime as datetime_cls
# ==================== 车辆相关模型 ==================== # ==================== 车辆相关模型 ====================
@ -14,7 +14,7 @@ class VehicleBase(BaseModel):
"""车辆基础模型""" """车辆基础模型"""
name: str name: str
purchase_date: Optional[date] = None purchase_date: Optional[date_cls] = None
initial_mileage: int = 0 initial_mileage: int = 0
@ -29,7 +29,7 @@ class VehicleUpdate(BaseModel):
id: int id: int
name: Optional[str] = None name: Optional[str] = None
purchase_date: Optional[date] = None purchase_date: Optional[date_cls] = None
initial_mileage: Optional[int] = None initial_mileage: Optional[int] = None
@ -44,8 +44,8 @@ class VehicleResponse(VehicleBase):
id: int id: int
is_deleted: bool = False is_deleted: bool = False
created_at: Optional[datetime] = None created_at: Optional[datetime_cls] = None
updated_at: Optional[datetime] = None updated_at: Optional[datetime_cls] = None
class Config: class Config:
from_attributes = True from_attributes = True
@ -67,11 +67,12 @@ class FuelRecordBase(BaseModel):
"""加油记录基础模型""" """加油记录基础模型"""
vehicle_id: int vehicle_id: int
date: date date: date_cls
mileage: int mileage: int
fuel_amount: float fuel_amount: float
fuel_price: Optional[float] = None fuel_price: Optional[float] = None
total_cost: Optional[float] = None display_cost: Optional[float] = None # 机显总价
actual_cost: float # 实付金额
is_full_tank: bool = True is_full_tank: bool = True
notes: Optional[str] = "" notes: Optional[str] = ""
@ -79,7 +80,7 @@ class FuelRecordBase(BaseModel):
class FuelRecordCreate(FuelRecordBase): class FuelRecordCreate(FuelRecordBase):
"""创建加油记录请求模型""" """创建加油记录请求模型"""
@field_validator("total_cost", "fuel_price", mode="before") @field_validator("fuel_price", "display_cost", "actual_cost", mode="before")
@classmethod @classmethod
def validate_costs(cls, v): def validate_costs(cls, v):
if v is not None: if v is not None:
@ -93,11 +94,12 @@ class FuelRecordUpdate(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True) model_config = ConfigDict(arbitrary_types_allowed=True)
id: int id: int
date: Union[date, str, None] = None date: Union[date_cls, str, None] = None
mileage: Optional[int] = None mileage: Optional[int] = None
fuel_amount: Optional[float] = None fuel_amount: Optional[float] = None
fuel_price: Optional[float] = None fuel_price: Optional[float] = None
total_cost: Optional[float] = None display_cost: Optional[float] = None # 机显总价
actual_cost: Optional[float] = None # 实付金额
is_full_tank: Optional[bool] = None is_full_tank: Optional[bool] = None
notes: Optional[str] = None notes: Optional[str] = None
@ -106,10 +108,10 @@ class FuelRecordUpdate(BaseModel):
def parse_date(cls, v): def parse_date(cls, v):
if v is None: if v is None:
return None return None
if isinstance(v, date): if isinstance(v, date_cls):
return v return v
if isinstance(v, str): if isinstance(v, str):
return date.fromisoformat(v) return date_cls.fromisoformat(v)
raise ValueError("Invalid date format") raise ValueError("Invalid date format")
@ -124,17 +126,18 @@ class FuelRecordResponse(BaseModel):
id: int id: int
vehicle_id: int vehicle_id: int
date: date date: date_cls
mileage: int mileage: int
fuel_amount: float fuel_amount: float
fuel_price: Optional[float] fuel_price: Optional[float]
total_cost: float display_cost: Optional[float] # 机显总价
actual_cost: float # 实付金额
is_full_tank: bool is_full_tank: bool
notes: str notes: str
fuel_consumption: Optional[float] = None # 单次油耗,计算得出 fuel_consumption: Optional[float] = None # 单次油耗,计算得出
is_deleted: bool = False is_deleted: bool = False
created_at: Optional[datetime] = None created_at: Optional[datetime_cls] = None
updated_at: Optional[datetime] = None updated_at: Optional[datetime_cls] = None
class Config: class Config:
from_attributes = True from_attributes = True
@ -150,7 +153,7 @@ class CostBase(BaseModel):
"""费用记录基础模型""" """费用记录基础模型"""
vehicle_id: int vehicle_id: int
date: date date: date_cls
type: str type: str
amount: float amount: float
mileage: Optional[int] = None mileage: Optional[int] = None
@ -171,7 +174,7 @@ class CostUpdate(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True) model_config = ConfigDict(arbitrary_types_allowed=True)
id: int id: int
date: Union[date, str, None] = None date: Union[date_cls, str, None] = None
type: Optional[str] = None type: Optional[str] = None
amount: Optional[float] = None amount: Optional[float] = None
mileage: Optional[int] = None mileage: Optional[int] = None
@ -184,10 +187,10 @@ class CostUpdate(BaseModel):
def parse_date(cls, v): def parse_date(cls, v):
if v is None: if v is None:
return None return None
if isinstance(v, date): if isinstance(v, date_cls):
return v return v
if isinstance(v, str): if isinstance(v, str):
return date.fromisoformat(v) return date_cls.fromisoformat(v)
raise ValueError("Invalid date format") raise ValueError("Invalid date format")
@ -202,7 +205,7 @@ class CostResponse(BaseModel):
id: int id: int
vehicle_id: int vehicle_id: int
date: date date: date_cls
type: str type: str
amount: float amount: float
mileage: Optional[int] mileage: Optional[int]
@ -210,8 +213,8 @@ class CostResponse(BaseModel):
is_installment: bool is_installment: bool
installment_months: int installment_months: int
is_deleted: bool = False is_deleted: bool = False
created_at: Optional[datetime] = None created_at: Optional[datetime_cls] = None
updated_at: Optional[datetime] = None updated_at: Optional[datetime_cls] = None
class Config: class Config:
from_attributes = True from_attributes = True
@ -224,10 +227,10 @@ class FuelRecordItem(BaseModel):
"""仪表板中使用的加油记录项""" """仪表板中使用的加油记录项"""
id: int id: int
date: date date: date_cls
mileage: int mileage: int
fuel_amount: float fuel_amount: float
total_cost: float actual_cost: float # 实付金额(用于统计)
fuel_consumption: Optional[float] fuel_consumption: Optional[float]
@ -236,7 +239,7 @@ class DashboardData(BaseModel):
vehicle_id: int vehicle_id: int
vehicle_name: str vehicle_name: str
purchase_date: Optional[date] purchase_date: Optional[date_cls]
total_mileage: int total_mileage: int
total_fuel_cost: float total_fuel_cost: float
avg_fuel_consumption: Optional[float] avg_fuel_consumption: Optional[float]

86
deploy-web.sh Executable file
View File

@ -0,0 +1,86 @@
#!/bin/bash
# CarCost Web 前端部署脚本
# 功能:构建 Vue 项目并部署到服务器指定目录
set -e # 遇到错误立即退出
# 配置
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
WEB_DIR="${SCRIPT_DIR}/web"
SOURCE_DIR="${WEB_DIR}/dist"
TARGET_DIR="/home/yuany3721/webpages/carcost"
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${YELLOW}========================================${NC}"
echo -e "${YELLOW} 开始部署 CarCost Web 前端${NC}"
echo -e "${YELLOW}========================================${NC}"
echo ""
# 检查 web 目录是否存在
if [ ! -d "$WEB_DIR" ]; then
echo -e "${RED}错误:找不到 web 目录: $WEB_DIR${NC}"
exit 1
fi
# 切换到 web 目录
cd "$WEB_DIR"
# 检查 package.json
if [ ! -f "package.json" ]; then
echo -e "${RED}错误:无法找到 package.json${NC}"
exit 1
fi
# 步骤 1: 构建项目
echo -e "${YELLOW}[1/3] 正在构建项目...${NC}"
npm run build
if [ $? -ne 0 ]; then
echo -e "${RED}构建失败!${NC}"
exit 1
fi
echo -e "${GREEN}✓ 构建成功${NC}"
echo ""
# 步骤 2: 检查源目录是否存在
if [ ! -d "$SOURCE_DIR" ]; then
echo -e "${RED}错误:构建输出目录不存在: $SOURCE_DIR${NC}"
exit 1
fi
# 步骤 3: 清空目标目录
echo -e "${YELLOW}[2/3] 正在清空目标目录...${NC}"
if [ -d "$TARGET_DIR" ]; then
rm -rf "${TARGET_DIR:?}/"*
echo -e "${GREEN}✓ 已清空: $TARGET_DIR${NC}"
else
echo -e "${YELLOW}目标目录不存在,正在创建...${NC}"
mkdir -p "$TARGET_DIR"
echo -e "${GREEN}✓ 已创建: $TARGET_DIR${NC}"
fi
echo ""
# 步骤 4: 复制文件
echo -e "${YELLOW}[3/3] 正在复制文件到目标目录...${NC}"
cp -r "${SOURCE_DIR}/"* "$TARGET_DIR/"
if [ $? -eq 0 ]; then
echo -e "${GREEN}✓ 文件复制成功${NC}"
else
echo -e "${RED}文件复制失败!${NC}"
exit 1
fi
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN} 部署完成!${NC}"
echo -e "${GREEN}========================================${NC}"
echo -e "目标目录: ${TARGET_DIR}"

474
web/package-lock.json generated
View File

@ -11,14 +11,32 @@
"axios": "^1.14.0", "axios": "^1.14.0",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"element-plus": "^2.13.6", "element-plus": "^2.13.6",
"oidc-client-ts": "^3.5.0",
"vue": "^3.5.32", "vue": "^3.5.32",
"vue-echarts": "^8.0.1" "vue-echarts": "^8.0.1",
"vue-router": "^5.0.4"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.5", "@vitejs/plugin-vue": "^6.0.5",
"vite": "^8.0.4" "vite": "^8.0.4"
} }
}, },
"node_modules/@babel/generator": {
"version": "7.29.1",
"resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz",
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-string-parser": { "node_modules/@babel/helper-string-parser": {
"version": "7.27.1", "version": "7.27.1",
"resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
@ -142,12 +160,51 @@
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5", "version": "1.5.5",
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", "resolved": "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz",
@ -501,6 +558,33 @@
"vue": "^3.2.25" "vue": "^3.2.25"
} }
}, },
"node_modules/@vue-macros/common": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/@vue-macros/common/-/common-3.1.2.tgz",
"integrity": "sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==",
"license": "MIT",
"dependencies": {
"@vue/compiler-sfc": "^3.5.22",
"ast-kit": "^2.1.2",
"local-pkg": "^1.1.2",
"magic-string-ast": "^1.0.2",
"unplugin-utils": "^0.3.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/vue-macros"
},
"peerDependencies": {
"vue": "^2.7.0 || ^3.2.25"
},
"peerDependenciesMeta": {
"vue": {
"optional": true
}
}
},
"node_modules/@vue/compiler-core": { "node_modules/@vue/compiler-core": {
"version": "3.5.32", "version": "3.5.32",
"resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.32.tgz", "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.32.tgz",
@ -551,6 +635,33 @@
"@vue/shared": "3.5.32" "@vue/shared": "3.5.32"
} }
}, },
"node_modules/@vue/devtools-api": {
"version": "8.1.1",
"resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-8.1.1.tgz",
"integrity": "sha512-bsDMJ07b3GN1puVwJb/fyFnj/U2imyswK5UQVLZwVl7O05jDrt6BHxeG5XffmOOdasOj/bOmIjxJvGPxU7pcqw==",
"license": "MIT",
"dependencies": {
"@vue/devtools-kit": "^8.1.1"
}
},
"node_modules/@vue/devtools-kit": {
"version": "8.1.1",
"resolved": "https://registry.npmmirror.com/@vue/devtools-kit/-/devtools-kit-8.1.1.tgz",
"integrity": "sha512-gVBaBv++i+adg4JpH71k9ppl4soyR7Y2McEqO5YNgv0BI1kMZ7BDX5gnwkZ5COYgiCyhejZG+yGNrBAjj6Coqg==",
"license": "MIT",
"dependencies": {
"@vue/devtools-shared": "^8.1.1",
"birpc": "^2.6.1",
"hookable": "^5.5.3",
"perfect-debounce": "^2.0.0"
}
},
"node_modules/@vue/devtools-shared": {
"version": "8.1.1",
"resolved": "https://registry.npmmirror.com/@vue/devtools-shared/-/devtools-shared-8.1.1.tgz",
"integrity": "sha512-+h4ttmJYl/txpxHKaoZcaKpC+pvckgLzIDiSQlaQ7kKthKh8KuwoLW2D8hPJEnqKzXOvu15UHEoGyngAXCz0EQ==",
"license": "MIT"
},
"node_modules/@vue/reactivity": { "node_modules/@vue/reactivity": {
"version": "3.5.32", "version": "3.5.32",
"resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.32.tgz", "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.32.tgz",
@ -637,6 +748,50 @@
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
} }
}, },
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/ast-kit": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/ast-kit/-/ast-kit-2.2.0.tgz",
"integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
"pathe": "^2.0.3"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/ast-walker-scope": {
"version": "0.8.3",
"resolved": "https://registry.npmmirror.com/ast-walker-scope/-/ast-walker-scope-0.8.3.tgz",
"integrity": "sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.4",
"ast-kit": "^2.1.3"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/async-validator": { "node_modules/async-validator": {
"version": "4.2.5", "version": "4.2.5",
"resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz",
@ -660,6 +815,15 @@
"proxy-from-env": "^2.1.0" "proxy-from-env": "^2.1.0"
} }
}, },
"node_modules/birpc": {
"version": "2.9.0",
"resolved": "https://registry.npmmirror.com/birpc/-/birpc-2.9.0.tgz",
"integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/call-bind-apply-helpers": { "node_modules/call-bind-apply-helpers": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@ -673,6 +837,21 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"license": "MIT",
"dependencies": {
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/combined-stream": { "node_modules/combined-stream": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
@ -685,6 +864,12 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/confbox": {
"version": "0.2.4",
"resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.2.4.tgz",
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
"license": "MIT"
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
@ -835,11 +1020,16 @@
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/exsolve": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.0.8.tgz",
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
"license": "MIT"
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"
@ -1001,6 +1191,45 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/hookable": {
"version": "5.5.3",
"resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz",
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"license": "MIT"
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
},
"engines": {
"node": ">=6"
}
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/jwt-decode": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/jwt-decode/-/jwt-decode-4.0.0.tgz",
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz",
@ -1262,6 +1491,23 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/local-pkg": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-1.1.2.tgz",
"integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==",
"license": "MIT",
"dependencies": {
"mlly": "^1.7.4",
"pkg-types": "^2.3.0",
"quansync": "^0.2.11"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.18.1", "version": "4.18.1",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
@ -1294,6 +1540,21 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/magic-string-ast": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/magic-string-ast/-/magic-string-ast-1.0.3.tgz",
"integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==",
"license": "MIT",
"dependencies": {
"magic-string": "^0.30.19"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -1330,6 +1591,41 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/mlly": {
"version": "1.8.2",
"resolved": "https://registry.npmmirror.com/mlly/-/mlly-1.8.2.tgz",
"integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
"license": "MIT",
"dependencies": {
"acorn": "^8.16.0",
"pathe": "^2.0.3",
"pkg-types": "^1.3.1",
"ufo": "^1.6.3"
}
},
"node_modules/mlly/node_modules/confbox": {
"version": "0.1.8",
"resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz",
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
"license": "MIT"
},
"node_modules/mlly/node_modules/pkg-types": {
"version": "1.3.1",
"resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-1.3.1.tgz",
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
"license": "MIT",
"dependencies": {
"confbox": "^0.1.8",
"mlly": "^1.7.4",
"pathe": "^2.0.1"
}
},
"node_modules/muggle-string": {
"version": "0.4.1",
"resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz",
"integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
"license": "MIT"
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz",
@ -1354,6 +1650,30 @@
"integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/oidc-client-ts": {
"version": "3.5.0",
"resolved": "https://registry.npmmirror.com/oidc-client-ts/-/oidc-client-ts-3.5.0.tgz",
"integrity": "sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==",
"license": "Apache-2.0",
"dependencies": {
"jwt-decode": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"license": "MIT"
},
"node_modules/perfect-debounce": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
"license": "MIT"
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
@ -1364,7 +1684,6 @@
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@ -1373,6 +1692,17 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/pkg-types": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-2.3.0.tgz",
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
"license": "MIT",
"dependencies": {
"confbox": "^0.2.2",
"exsolve": "^1.0.7",
"pathe": "^2.0.3"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.9", "version": "8.5.9",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.9.tgz", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.9.tgz",
@ -1410,6 +1740,35 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/quansync": {
"version": "0.2.11",
"resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.11.tgz",
"integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/antfu"
},
{
"type": "individual",
"url": "https://github.com/sponsors/sxzz"
}
],
"license": "MIT"
},
"node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.0.0-rc.13", "version": "1.0.0-rc.13",
"resolved": "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-rc.13.tgz", "resolved": "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-rc.13.tgz",
@ -1451,6 +1810,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/scule": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/scule/-/scule-1.3.0.tgz",
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
"license": "MIT"
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
@ -1464,7 +1829,6 @@
"version": "0.2.16", "version": "0.2.16",
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz", "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fdir": "^6.5.0", "fdir": "^6.5.0",
@ -1485,6 +1849,42 @@
"license": "0BSD", "license": "0BSD",
"optional": true "optional": true
}, },
"node_modules/ufo": {
"version": "1.6.3",
"resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.3.tgz",
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
"license": "MIT"
},
"node_modules/unplugin": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/unplugin/-/unplugin-3.0.0.tgz",
"integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"picomatch": "^4.0.3",
"webpack-virtual-modules": "^0.6.2"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/unplugin-utils": {
"version": "0.3.1",
"resolved": "https://registry.npmmirror.com/unplugin-utils/-/unplugin-utils-0.3.1.tgz",
"integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==",
"license": "MIT",
"dependencies": {
"pathe": "^2.0.3",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "8.0.7", "version": "8.0.7",
"resolved": "https://registry.npmmirror.com/vite/-/vite-8.0.7.tgz", "resolved": "https://registry.npmmirror.com/vite/-/vite-8.0.7.tgz",
@ -1600,6 +2000,72 @@
"vue": "^3.3.0" "vue": "^3.3.0"
} }
}, },
"node_modules/vue-router": {
"version": "5.0.4",
"resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-5.0.4.tgz",
"integrity": "sha512-lCqDLCI2+fKVRl2OzXuzdSWmxXFLQRxQbmHugnRpTMyYiT+hNaycV0faqG5FBHDXoYrZ6MQcX87BvbY8mQ20Bg==",
"license": "MIT",
"dependencies": {
"@babel/generator": "^7.28.6",
"@vue-macros/common": "^3.1.1",
"@vue/devtools-api": "^8.0.6",
"ast-walker-scope": "^0.8.3",
"chokidar": "^5.0.0",
"json5": "^2.2.3",
"local-pkg": "^1.1.2",
"magic-string": "^0.30.21",
"mlly": "^1.8.0",
"muggle-string": "^0.4.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"scule": "^1.3.0",
"tinyglobby": "^0.2.15",
"unplugin": "^3.0.0",
"unplugin-utils": "^0.3.1",
"yaml": "^2.8.2"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"@pinia/colada": ">=0.21.2",
"@vue/compiler-sfc": "^3.5.17",
"pinia": "^3.0.4",
"vue": "^3.5.0"
},
"peerDependenciesMeta": {
"@pinia/colada": {
"optional": true
},
"@vue/compiler-sfc": {
"optional": true
},
"pinia": {
"optional": true
}
}
},
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"license": "MIT"
},
"node_modules/yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/zrender": { "node_modules/zrender": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.0.0.tgz", "resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.0.0.tgz",

View File

@ -12,11 +12,13 @@
"axios": "^1.14.0", "axios": "^1.14.0",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"element-plus": "^2.13.6", "element-plus": "^2.13.6",
"oidc-client-ts": "^3.5.0",
"vue": "^3.5.32", "vue": "^3.5.32",
"vue-echarts": "^8.0.1" "vue-echarts": "^8.0.1",
"vue-router": "^5.0.4"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.5", "@vitejs/plugin-vue": "^6.0.5",
"vite": "^8.0.4" "vite": "^8.0.4"
} }
} }

View File

@ -1,265 +1,9 @@
<template> <template>
<div class="app"> <router-view />
<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> </template>
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' // App.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> </script>
<style> <style>
@ -277,175 +21,3 @@ html, body {
overflow-x: hidden; overflow-x: hidden;
} }
</style> </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>

16
web/src/api.js Normal file
View File

@ -0,0 +1,16 @@
import axios from 'axios'
// 创建 axios 实例并导出
const api = axios.create({
baseURL: 'https://api.yuany3721.site/carcost',
headers: {
'Content-Type': 'application/json'
}
})
// 导出设置 token 的函数
export function setAuthToken(token) {
api.defaults.headers.common['Authorization'] = token ? `Bearer ${token}` : ''
}
export default api

View File

@ -1,13 +1,13 @@
<template> <template>
<el-card class="stats-card" v-if="dashboardData"> <el-card class="stats-card" v-if="hasData">
<el-row :gutter="20"> <el-row :gutter="20">
<el-col :xs="12" :sm="8" :md="4"> <el-col :xs="12" :sm="8" :md="4">
<el-statistic :value="dashboardData.total_mileage || 0" title="总里程"> <el-statistic :value="stats.total_mileage || 0" title="总里程">
<template #suffix>km</template> <template #suffix>km</template>
</el-statistic> </el-statistic>
</el-col> </el-col>
<el-col :xs="12" :sm="8" :md="4"> <el-col :xs="12" :sm="8" :md="4">
<el-statistic :value="dashboardData.total_fuel_cost || 0" title="总油费"> <el-statistic :value="stats.total_fuel_cost || 0" title="总油费">
<template #prefix>¥</template> <template #prefix>¥</template>
</el-statistic> </el-statistic>
</el-col> </el-col>
@ -27,7 +27,7 @@
</el-statistic> </el-statistic>
</el-col> </el-col>
<el-col :xs="12" :sm="8" :md="4"> <el-col :xs="12" :sm="8" :md="4">
<el-statistic :value="dashboardData?.avg_daily_km || 0" title="日均行程"> <el-statistic :value="stats.avg_daily_km || 0" title="日均行程">
<template #suffix>km</template> <template #suffix>km</template>
</el-statistic> </el-statistic>
</el-col> </el-col>
@ -38,26 +38,34 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { useVehicleData } from '../../composables/useVehicleData.js'
const props = defineProps({ const props = defineProps({
dashboardData: Object, vehicleId: {
totalCost: {
type: Number, type: Number,
default: 0 default: null
} }
}) })
// 使
const { stats, totalCost, state } = useVehicleData()
//
const hasData = computed(() => {
return props.vehicleId && state.vehicleId == props.vehicleId && state.lastLoaded
})
// //
const fuelCostPerKm = computed(() => { const fuelCostPerKm = computed(() => {
if (!props.dashboardData?.total_mileage || props.dashboardData.total_mileage === 0) return 0 if (!stats.value?.total_mileage || stats.value.total_mileage === 0) return 0
return (props.dashboardData.total_fuel_cost || 0) / props.dashboardData.total_mileage return (stats.value.total_fuel_cost || 0) / stats.value.total_mileage
}) })
// //
const totalCostPerKm = computed(() => { const totalCostPerKm = computed(() => {
if (!props.dashboardData?.total_mileage || props.dashboardData.total_mileage === 0) return 0 if (!stats.value?.total_mileage || stats.value.total_mileage === 0) return 0
const total = (props.dashboardData.total_fuel_cost || 0) + props.totalCost const total = (stats.value.total_fuel_cost || 0) + totalCost.value
return total / props.dashboardData.total_mileage return total / stats.value.total_mileage
}) })
</script> </script>

View File

@ -12,7 +12,7 @@
<!-- 四个图表 --> <!-- 四个图表 -->
<div class="charts-grid"> <div class="charts-grid">
<!-- 费用类型分布 --> <!-- 费用类型分布 -->
<div class="chart-item" v-if="pieChartOption && allRecords.length > 0"> <div class="chart-item" v-if="pieChartOption && costRecords.length > 0">
<div class="chart-title">📊 费用类型分布</div> <div class="chart-title">📊 费用类型分布</div>
<div class="chart-content"> <div class="chart-content">
<v-chart <v-chart
@ -28,7 +28,7 @@
</div> </div>
<!-- 油耗趋势 --> <!-- 油耗趋势 -->
<div class="chart-item" v-if="consumptionChartOption && allRecords.length > 0"> <div class="chart-item" v-if="consumptionChartOption && fuelRecords.length > 0">
<div class="chart-title"> 油耗趋势</div> <div class="chart-title"> 油耗趋势</div>
<div class="chart-content"> <div class="chart-content">
<v-chart <v-chart
@ -44,7 +44,7 @@
</div> </div>
<!-- 月度费用统计 --> <!-- 月度费用统计 -->
<div class="chart-item" v-if="monthlyChartOption && allRecords.length > 0"> <div class="chart-item" v-if="monthlyChartOption && costRecords.length > 0">
<div class="chart-title">📈 月度费用统计</div> <div class="chart-title">📈 月度费用统计</div>
<div class="chart-content"> <div class="chart-content">
<v-chart <v-chart
@ -79,110 +79,88 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, watch, onMounted } from "vue"; import { ref, computed, onMounted } from "vue";
import axios from "axios"; import { useVehicleData } from "../../composables/useVehicleData.js";
import {
filterRecordsByTimeRange,
calculateCostInstallments,
calculateMonthlyFuelCost,
calculateMonthlyStats,
calculateCostTypeDistribution,
getRecentMonths
} from "../../utils/calculations.js";
import { COST_TYPE_COLORS } from "../../constants/index.js";
const props = defineProps({ const props = defineProps({
vehicleId: Number, vehicleId: Number,
}); });
const API_BASE = "https://api.yuany3721.site/carcost"; // 使
const {
fuelRecords: allFuelRecords,
costRecords: allCostRecords,
state
} = useVehicleData();
//
const timeRange = ref("past_year");
// //
const isMobile = ref(false); const isMobile = ref(false);
//
const timeRange = ref("past_year");
onMounted(() => { onMounted(() => {
isMobile.value = window.innerWidth <= 768; isMobile.value = window.innerWidth <= 768;
timeRange.value = isMobile.value ? "past_6months" : "past_year"; timeRange.value = isMobile.value ? "past_6months" : "past_year";
}); });
// //
const allRecords = ref([]); const costRecords = computed(() => {
const fuelRecords = ref([]); if (!state.vehicleId || state.vehicleId !== props.vehicleId) return [];
return allCostRecords.value;
//
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 fuelRecords = computed(() => {
const now = new Date(); if (!state.vehicleId || state.vehicleId !== props.vehicleId) return [];
return new Date(now.getFullYear(), now.getMonth() - months, now.getDate()); return filterRecordsByTimeRange(timeRange.value, allFuelRecords.value);
}; });
// // - 使
const getFilteredRecords = () => { //
let filteredFuel = fuelRecords.value; const costInstallments = computed(() => {
let filteredCost = allRecords.value; if (!state.vehicleId || state.vehicleId !== props.vehicleId) return [];
return calculateCostInstallments(allCostRecords.value);
});
//
const filteredCostInstallments = computed(() => {
if (!targetMonths.value) return costInstallments.value;
return costInstallments.value.filter(item => targetMonths.value.includes(item.month));
});
//
const monthlyFuelCost = computed(() => {
return calculateMonthlyFuelCost(fuelRecords.value);
});
//
const monthlyStats = computed(() => {
return calculateMonthlyStats(fuelRecords.value, filteredCostInstallments.value);
});
//
const targetMonths = computed(() => {
if (timeRange.value === "past_6months") { if (timeRange.value === "past_6months") {
const sixMonthsAgo = getMonthsAgo(6); return getRecentMonths(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") { } else if (timeRange.value === "past_year") {
const oneYearAgo = getMonthsAgo(12); return getRecentMonths(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 null;
return { filteredFuel, filteredCost }; });
};
// //
const consumptionChartOption = computed(() => { const consumptionChartOption = computed(() => {
const { filteredFuel } = getFilteredRecords(); const validRecords = fuelRecords.value.filter((r) => r.fuel_consumption);
const validRecords = filteredFuel.filter((r) => r.fuel_consumption);
if (validRecords.length === 0) return null; if (validRecords.length === 0) return null;
@ -292,15 +270,9 @@ const consumptionChartOption = computed(() => {
// //
const monthlyCostChartOption = computed(() => { const monthlyCostChartOption = computed(() => {
const { filteredFuel } = getFilteredRecords(); const monthlyData = monthlyFuelCost.value;
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(); const sortedMonths = Object.keys(monthlyData).sort();
if (sortedMonths.length === 0) return null; if (sortedMonths.length === 0) return null;
return { return {
@ -338,65 +310,13 @@ const monthlyCostChartOption = computed(() => {
}; };
}); });
//
const typeColorMap = {
保养: "#67c23a",
维修: "#e6a23c",
保险: "#f56c6c",
停车: "#909399",
过路费: "#9a60b4",
其他: "#fc8452",
油费: "#409eff",
};
// //
const pieChartOption = computed(() => { const pieChartOption = computed(() => {
const { filteredFuel } = getFilteredRecords(); const { totalAmount, data } = calculateCostTypeDistribution(
costInstallments.value,
// fuelRecords.value,
const now = new Date(); targetMonths.value
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; if (data.length === 0) return null;
@ -423,7 +343,10 @@ const pieChartOption = computed(() => {
scale: true, scale: true,
scaleSize: 5, scaleSize: 5,
}, },
data: data.map((d) => ({ ...d, itemStyle: { color: typeColorMap[d.name] || "#909399" } })), data: data.map((d) => ({
...d,
itemStyle: { color: COST_TYPE_COLORS[d.name] || "#909399" }
})),
}, },
], ],
title: { title: {
@ -442,62 +365,22 @@ const pieChartOption = computed(() => {
// //
const monthlyChartOption = computed(() => { const monthlyChartOption = computed(() => {
const { filteredFuel } = getFilteredRecords(); const monthlyData = monthlyStats.value;
const allTypes = ["保养", "维修", "保险", "停车", "过路费", "洗车", "违章", "其他", "油费"];
const monthlyData = {};
const allTypes = ["保养", "维修", "保险", "停车", "过路费", "其他", "油费"]; //
let targetMonthsList = Object.keys(monthlyData).sort();
// if (targetMonths.value) {
const now = new Date(); targetMonthsList = targetMonthsList.filter(m => targetMonths.value.includes(m));
//
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 existingTypes = allTypes.filter((type) =>
targetMonthsList.some((month) => monthlyData[month][type] > 0)
);
// //
const monthlyTotals = targetMonths.map((month) => { const monthlyTotals = targetMonthsList.map((month) => {
const total = existingTypes.reduce((sum, type) => sum + monthlyData[month][type], 0); const total = existingTypes.reduce((sum, type) => sum + monthlyData[month][type], 0);
return total.toFixed(0); return total.toFixed(0);
}); });
@ -532,7 +415,7 @@ const monthlyChartOption = computed(() => {
}, },
xAxis: { xAxis: {
type: "category", type: "category",
data: targetMonths.map((m) => m.slice(5)), data: targetMonthsList.map((m) => m.slice(5)),
axisLabel: { fontSize: 10 }, axisLabel: { fontSize: 10 },
axisLine: { show: true }, axisLine: { show: true },
axisTick: { show: false }, axisTick: { show: false },
@ -543,14 +426,14 @@ const monthlyChartOption = computed(() => {
name: type, name: type,
type: "bar", type: "bar",
stack: "total", stack: "total",
data: targetMonths.map((month) => parseFloat(monthlyData[month][type].toFixed(0))), data: targetMonthsList.map((month) => parseFloat(monthlyData[month][type].toFixed(0))),
itemStyle: { color: typeColorMap[type] || "#909399" }, itemStyle: { color: COST_TYPE_COLORS[type] || "#909399" },
})), })),
{ {
name: "总计", name: "总计",
type: "bar", type: "bar",
stack: "total", stack: "total",
data: targetMonths.map(() => 0), data: targetMonthsList.map(() => 0),
label: { label: {
show: true, show: true,
position: "top", position: "top",
@ -568,18 +451,6 @@ const monthlyChartOption = computed(() => {
], ],
}; };
}); });
// 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> </script>
<style scoped> <style scoped>

View File

@ -102,7 +102,7 @@
<script setup> <script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import axios from 'axios' import api from '../../../api.js'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
const props = defineProps(['visible', 'vehicleId']) const props = defineProps(['visible', 'vehicleId'])
@ -161,7 +161,7 @@ const submit = async () => {
loading.value = true loading.value = true
try { try {
await axios.post(`${API_BASE}/costs/create`, { await api.post(`${API_BASE}/costs/create`, {
vehicle_id: props.vehicleId, vehicle_id: props.vehicleId,
type: form.value.type, type: form.value.type,
date: form.value.date, date: form.value.date,

View File

@ -45,7 +45,7 @@
</el-form-item> </el-form-item>
<el-form-item label="备注"> <el-form-item label="备注">
<el-input v-model="form.notes" type="textarea" rows="2" placeholder="可选" /> <el-input v-model="form.notes" type="textarea" :rows="2" placeholder="可选" />
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
@ -58,7 +58,7 @@
<script setup> <script setup>
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import axios from 'axios' import api from '../../../api.js'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
const props = defineProps(['visible', 'record']) const props = defineProps(['visible', 'record'])
@ -137,7 +137,7 @@ const submit = async () => {
requestBody.installment_months = form.value.installment_months requestBody.installment_months = form.value.installment_months
} }
await axios.post(`${API_BASE}/costs/update`, requestBody) await api.post(`${API_BASE}/costs/update`, requestBody)
ElMessage.success('保存成功') ElMessage.success('保存成功')
visible.value = false visible.value = false
emit('success') emit('success')
@ -166,7 +166,7 @@ const deleteRecord = async () => {
}) })
deleting.value = true deleting.value = true
await axios.post(`${API_BASE}/costs/delete`, { await api.post(`${API_BASE}/costs/delete`, {
id: form.value.id id: form.value.id
}) })
ElMessage.success('删除成功') ElMessage.success('删除成功')

View File

@ -130,7 +130,7 @@
<script setup> <script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import axios from 'axios' import api from '../../../api.js'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
const props = defineProps(['visible', 'vehicleId']) const props = defineProps(['visible', 'vehicleId'])
@ -214,7 +214,7 @@ const submit = async () => {
return return
} }
// //
const hasPrice = form.value.fuel_price !== null && form.value.fuel_price > 0 const hasPrice = form.value.fuel_price !== null && form.value.fuel_price > 0
const hasAmount = form.value.fuel_amount !== null && form.value.fuel_amount > 0 const hasAmount = form.value.fuel_amount !== null && form.value.fuel_amount > 0
@ -230,19 +230,20 @@ const submit = async () => {
} }
// 使 // 使
const totalCost = form.value.actual_cost !== null && form.value.actual_cost > 0 const actualCost = form.value.actual_cost !== null && form.value.actual_cost > 0
? form.value.actual_cost ? form.value.actual_cost
: displayCost : displayCost
loading.value = true loading.value = true
try { try {
await axios.post(`${API_BASE}/fuel-records/create`, { await api.post(`${API_BASE}/fuel-records/create`, {
vehicle_id: props.vehicleId, vehicle_id: props.vehicleId,
date: form.value.date, date: form.value.date,
mileage: form.value.mileage, mileage: form.value.mileage,
fuel_amount: form.value.fuel_amount, fuel_amount: form.value.fuel_amount,
fuel_price: form.value.fuel_price, fuel_price: form.value.fuel_price,
total_cost: totalCost, display_cost: displayCost, //
actual_cost: actualCost, //
is_full_tank: form.value.is_full_tank, is_full_tank: form.value.is_full_tank,
notes: form.value.notes || '' notes: form.value.notes || ''
}) })
@ -263,6 +264,7 @@ const submit = async () => {
emit('success') emit('success')
} catch (error) { } catch (error) {
ElMessage.error('添加失败') ElMessage.error('添加失败')
console.error(error)
} finally { } finally {
loading.value = false loading.value = false
} }

View File

@ -2,65 +2,33 @@
<el-dialog <el-dialog
v-model="visible" v-model="visible"
title="编辑加油记录" title="编辑加油记录"
width="450px" width="400px"
> >
<el-form :model="form" label-width="100px"> <el-form :model="form" label-width="80px">
<el-form-item label="日期" required> <el-form-item label="日期">
<el-date-picker <el-date-picker v-model="form.date" type="date" placeholder="选择日期" style="width: 100%" />
v-model="form.date"
type="date"
placeholder="选择日期"
style="width: 100%"
value-format="YYYY-MM-DD"
/>
</el-form-item> </el-form-item>
<el-form-item label="里程 (km)" required> <el-form-item label="里程">
<el-input-number v-model="form.mileage" :min="0" style="width: 100%" /> <el-input-number v-model="form.mileage" :min="0" style="width: 100%" />
</el-form-item> </el-form-item>
<el-form-item label="加油量">
<!-- 三个核心字段 --> <el-input-number v-model="form.fuel_amount" :min="0" :precision="2" style="width: 100%" @change="onAmountChange" />
<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>
<el-form-item label="加油量 (L)"> <el-form-item label="单价">
<el-input-number <el-input-number v-model="form.fuel_price" :min="0" :precision="2" style="width: 100%" @change="onPriceChange" />
v-model="form.fuel_amount"
:min="0"
:precision="2"
style="width: 100%"
placeholder="加油量"
@change="onAmountChange"
/>
</el-form-item> </el-form-item>
<el-form-item label="机显总价 (¥)"> <el-form-item label="机显总价">
<el-input-number <el-input-number v-model="form.display_cost" :min="0" :precision="2" style="width: 100%" @change="onDisplayCostChange" />
v-model="form.display_cost"
:min="0"
:precision="2"
style="width: 100%"
placeholder="机显金额(选填,可自动计算)"
@change="onDisplayCostChange"
/>
</el-form-item> </el-form-item>
<el-form-item label="实付金额">
<!-- 实付金额 --> <el-input-number v-model="form.actual_cost" :min="0" :precision="2" style="width: 100%" placeholder="优惠后金额(可选)" />
<el-form-item label="实付金额 (¥)">
<el-input-number v-model="form.actual_cost" :min="0" :precision="2" style="width: 100%" placeholder="实际支付金额(优惠后)" />
<div class="hint">用于统计实际花费不填则使用机显总价</div> <div class="hint">用于统计实际花费不填则使用机显总价</div>
</el-form-item> </el-form-item>
<el-form-item label="是否加满"> <el-form-item label="是否加满">
<el-switch v-model="form.is_full_tank" active-text="是" inactive-text="否" /> <el-switch v-model="form.is_full_tank" active-text="是" inactive-text="否" />
</el-form-item> </el-form-item>
<el-form-item label="备注"> <el-form-item label="备注">
<el-input v-model="form.notes" type="textarea" rows="2" placeholder="备注(可选)" /> <el-input v-model="form.notes" type="textarea" :rows="2" placeholder="备注(可选)" />
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
@ -73,7 +41,7 @@
<script setup> <script setup>
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import axios from 'axios' import api from '../../../api.js'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
const props = defineProps(['visible', 'record']) const props = defineProps(['visible', 'record'])
@ -95,8 +63,8 @@ const form = ref({
mileage: 0, mileage: 0,
fuel_amount: null, fuel_amount: null,
fuel_price: null, fuel_price: null,
display_cost: null, display_cost: null, //
actual_cost: null, actual_cost: null, //
is_full_tank: true, is_full_tank: true,
notes: '' notes: ''
}) })
@ -110,15 +78,15 @@ watch(() => props.record, (newRecord) => {
mileage: newRecord.mileage, mileage: newRecord.mileage,
fuel_amount: newRecord.fuel_amount, fuel_amount: newRecord.fuel_amount,
fuel_price: newRecord.fuel_price, fuel_price: newRecord.fuel_price,
display_cost: newRecord.total_cost, // display_cost: newRecord.display_cost, //
actual_cost: newRecord.total_cost, // actual_cost: newRecord.actual_cost, //
is_full_tank: newRecord.is_full_tank, is_full_tank: newRecord.is_full_tank,
notes: newRecord.notes || '' notes: newRecord.notes || ''
} }
} }
}, { immediate: true }) }, { immediate: true })
// //
const onPriceChange = () => { const onPriceChange = () => {
if (form.value.fuel_price && form.value.fuel_amount) { 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 form.value.display_cost = Math.round(form.value.fuel_price * form.value.fuel_amount * 100) / 100
@ -134,11 +102,9 @@ const onAmountChange = () => {
} }
} }
// //
const onDisplayCostChange = () => { const onDisplayCostChange = () => {
if (form.value.display_cost && form.value.fuel_price) { if (form.value.display_cost && form.value.fuel_amount) {
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 form.value.fuel_price = Math.round(form.value.display_cost / form.value.fuel_amount * 100) / 100
} }
} }
@ -153,35 +119,30 @@ const submit = async () => {
return 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 const hasAmount = form.value.fuel_amount !== null && form.value.fuel_amount > 0
const hasDisplayCost = form.value.display_cost !== null && form.value.display_cost > 0
if (!hasPrice || !hasAmount) { if (!hasAmount || !hasDisplayCost) {
ElMessage.warning('请填写单价和加油量') ElMessage.warning('请填写加油量和机显总价')
return return
} }
// // 使
let displayCost = form.value.display_cost const actualCost = form.value.actual_cost !== null && form.value.actual_cost > 0
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 ? form.value.actual_cost
: displayCost : form.value.display_cost
loading.value = true loading.value = true
try { try {
await axios.post(`${API_BASE}/fuel-records/update`, { await api.post(`${API_BASE}/fuel-records/update`, {
id: form.value.id, id: form.value.id,
date: form.value.date, date: form.value.date,
mileage: form.value.mileage, mileage: form.value.mileage,
fuel_amount: form.value.fuel_amount, fuel_amount: form.value.fuel_amount,
fuel_price: form.value.fuel_price, fuel_price: form.value.fuel_price,
total_cost: totalCost, display_cost: form.value.display_cost, //
actual_cost: actualCost, //
is_full_tank: form.value.is_full_tank, is_full_tank: form.value.is_full_tank,
notes: form.value.notes notes: form.value.notes
}) })
@ -190,6 +151,7 @@ const submit = async () => {
emit('success') emit('success')
} catch (error) { } catch (error) {
ElMessage.error('保存失败') ElMessage.error('保存失败')
console.error(error)
} finally { } finally {
loading.value = false loading.value = false
} }
@ -206,7 +168,7 @@ const deleteRecord = async () => {
}) })
deleting.value = true deleting.value = true
await axios.post(`${API_BASE}/fuel-records/delete`, { await api.post(`${API_BASE}/fuel-records/delete`, {
id: form.value.id id: form.value.id
}) })
ElMessage.success('删除成功') ElMessage.success('删除成功')

View File

@ -30,7 +30,7 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import axios from 'axios' import api from '../../../api.js'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
const props = defineProps(['visible']) const props = defineProps(['visible'])
@ -58,7 +58,7 @@ const submit = async () => {
loading.value = true loading.value = true
try { try {
await axios.post(`${API_BASE}/vehicles/create`, { await api.post(`${API_BASE}/vehicles/create`, {
name: form.value.name, name: form.value.name,
purchase_date: form.value.purchase_date, purchase_date: form.value.purchase_date,
initial_mileage: form.value.initial_mileage initial_mileage: form.value.initial_mileage

View File

@ -30,7 +30,7 @@
<script setup> <script setup>
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import axios from 'axios' import api from '../../../api.js'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
const props = defineProps(['visible', 'vehicle']) const props = defineProps(['visible', 'vehicle'])
@ -71,7 +71,7 @@ const submit = async () => {
loading.value = true loading.value = true
try { try {
await axios.post(`${API_BASE}/vehicles/update`, { await api.post(`${API_BASE}/vehicles/update`, {
id: form.value.id, id: form.value.id,
name: form.value.name, name: form.value.name,
purchase_date: form.value.purchase_date, purchase_date: form.value.purchase_date,

View File

@ -34,7 +34,7 @@
<el-table <el-table
:data="filteredRecords" :data="filteredRecords"
style="width: 100%" style="width: 100%"
v-loading="loading" v-loading="isLoading"
size="small" size="small"
highlight-current-row highlight-current-row
@row-click="editRecord" @row-click="editRecord"
@ -49,7 +49,7 @@
</span> </span>
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item v-for="type in allTypes" :key="type"> <el-dropdown-item v-for="type in COST_TYPES" :key="type">
<el-checkbox v-model="typeFilters[type]">{{ type }}</el-checkbox> <el-checkbox v-model="typeFilters[type]">{{ type }}</el-checkbox>
</el-dropdown-item> </el-dropdown-item>
<el-dropdown-item divided @click="clearTypeFilter"> <el-dropdown-item divided @click="clearTypeFilter">
@ -78,22 +78,30 @@
:page-sizes="[10, 20, 50]" :page-sizes="[10, 20, 50]"
:total="filteredTotal" :total="filteredTotal"
layout="total, prev, pager, next" layout="total, prev, pager, next"
@current-change="handlePageChange"
style="margin-top: 15px; padding: 0 10px 10px" style="margin-top: 15px; padding: 0 10px 10px"
size="small" size="small"
/> />
</el-card> </el-card>
<EditCostDialog v-model:visible="showEditDialog" :record="selectedRecord" @success="loadRecords" /> <EditCostDialog v-model:visible="showEditDialog" :record="selectedRecord" @success="onRecordUpdated" />
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, watch, onMounted, computed } from "vue"; import { ref, computed, onMounted, watch } from "vue";
import axios from "axios";
import { ElMessage } from "element-plus";
import { ArrowDown } from '@element-plus/icons-vue' import { ArrowDown } from '@element-plus/icons-vue'
import EditCostDialog from "../dialogs/cost/EditCostDialog.vue"; import EditCostDialog from "../dialogs/cost/EditCostDialog.vue";
import { useVehicleData } from "../../composables/useVehicleData.js";
import {
COST_TYPES,
COST_TYPE_COLORS
} from "../../constants/index.js";
import {
calculateCostInstallments,
calculateMonthlyStats,
filterRecordsByTimeRange,
getRecentMonths
} from "../../utils/calculations.js";
const props = defineProps({ const props = defineProps({
vehicleId: Number, vehicleId: Number,
@ -107,14 +115,17 @@ const props = defineProps({
} }
}); });
defineEmits(["add-cost"]); const emit = defineEmits(["add-cost"]);
const API_BASE = "https://api.yuany3721.site/carcost"; // 使
const {
fuelRecords: allFuelRecords,
costRecords: allCostRecords,
isLoading,
refreshVehicleData,
state
} = useVehicleData();
const loading = ref(false);
const allRecords = ref([]);
const fuelRecords = ref([]); //
const total = ref(0);
const currentPage = ref(1); const currentPage = ref(1);
const pageSize = ref(15); const pageSize = ref(15);
const showEditDialog = ref(false); const showEditDialog = ref(false);
@ -125,21 +136,20 @@ const costTimeRange = ref('past_year'); // 'past_year' | 'all'
// //
const typeFilters = ref({}); const typeFilters = ref({});
const allTypes = ["保养", "维修", "保险", "停车", "过路费", "其他", "油费"];
// //
allTypes.forEach(type => { COST_TYPES.forEach(type => {
typeFilters.value[type] = false; typeFilters.value[type] = false;
}); });
// //
watch(typeFilters.value, () => { watch(typeFilters.value, () => {
selectedTypes.value = allTypes.filter(type => typeFilters.value[type]); selectedTypes.value = COST_TYPES.filter(type => typeFilters.value[type]);
currentPage.value = 1; currentPage.value = 1;
}, { deep: true }); }, { deep: true });
const clearTypeFilter = () => { const clearTypeFilter = () => {
allTypes.forEach(type => { COST_TYPES.forEach(type => {
typeFilters.value[type] = false; typeFilters.value[type] = false;
}); });
selectedTypes.value = []; selectedTypes.value = [];
@ -149,9 +159,10 @@ const clearTypeFilter = () => {
const updateIsMobile = () => { const updateIsMobile = () => {
isMobile.value = window.innerWidth <= 768; isMobile.value = window.innerWidth <= 768;
}; };
window.addEventListener("resize", updateIsMobile);
onMounted(() => { onMounted(() => {
updateIsMobile(); updateIsMobile();
window.addEventListener("resize", updateIsMobile);
}); });
const typeTagMap = { const typeTagMap = {
@ -160,98 +171,55 @@ const typeTagMap = {
保险: "danger", 保险: "danger",
停车: "info", 停车: "info",
过路费: "primary", 过路费: "primary",
洗车: "info",
违章: "danger",
其他: "info", 其他: "info",
}; };
const typeColorMap = {
保养: "#67c23a", // 绿
维修: "#e6a23c", //
保险: "#f56c6c", //
停车: "#909399", //
过路费: "#9a60b4", //
其他: "#fc8452", //
油费: "#409eff", //
};
const getTypeTag = (type) => typeTagMap[type] || ""; const getTypeTag = (type) => typeTagMap[type] || "";
// N //
const getMonthsAgo = (months) => { const fuelRecords = computed(() => {
const now = new Date(); if (!state.vehicleId || state.vehicleId !== props.vehicleId) return [];
return new Date(now.getFullYear(), now.getMonth() - months, now.getDate()); return filterRecordsByTimeRange(costTimeRange.value, allFuelRecords.value);
}; });
// // - 使
//
const costInstallments = computed(() => { const costInstallments = computed(() => {
const installments = [] if (!state.vehicleId || state.vehicleId !== props.vehicleId) return [];
return calculateCostInstallments(allCostRecords.value);
allRecords.value.forEach(cost => { });
if (cost.is_installment && cost.installment_months > 1) {
// //
const monthlyAmount = cost.amount / cost.installment_months const filteredCostInstallments = computed(() => {
const startMonth = cost.date.slice(0, 7) if (!targetMonths.value) return costInstallments.value;
return costInstallments.value.filter(item => targetMonths.value.includes(item.month));
for (let i = 0; i < cost.installment_months; i++) { });
const month = getNextMonth(startMonth, i)
installments.push({ //
month: month, const monthlyStats = computed(() => {
type: cost.type, return calculateMonthlyStats(fuelRecords.value, filteredCostInstallments.value);
amount: monthlyAmount, });
originalCostId: cost.id
}) //
} const targetMonths = computed(() => {
} else { if (costTimeRange.value === 'past_year') {
// return getRecentMonths(isMobile.value ? 6 : 12);
installments.push({ }
month: cost.date.slice(0, 7), return null;
type: cost.type, });
amount: cost.amount,
originalCostId: cost.id
})
}
})
return installments
})
// - // -
const pieChartOption = computed(() => { const pieChartOption = computed(() => {
if (!allRecords.value.length && !fuelRecords.value.length) return null; if (!allCostRecords.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 = {}; const typeData = {};
let totalAmount = 0; let totalAmount = 0;
// //
costInstallments.value.forEach(item => { costInstallments.value.forEach(item => {
if (costTimeRange.value === 'all' || targetMonths.includes(item.month)) { if (!targetMonths.value || targetMonths.value.includes(item.month)) {
typeData[item.type] = (typeData[item.type] || 0) + item.amount; typeData[item.type] = (typeData[item.type] || 0) + item.amount;
totalAmount += item.amount; totalAmount += item.amount;
} }
@ -259,8 +227,8 @@ const pieChartOption = computed(() => {
// //
let totalFuelCost = 0; let totalFuelCost = 0;
filteredFuelRecords.forEach((r) => { fuelRecords.value.forEach((r) => {
totalFuelCost += r.total_cost; totalFuelCost += r.actual_cost;
}); });
if (totalFuelCost > 0) { if (totalFuelCost > 0) {
typeData['油费'] = totalFuelCost; typeData['油费'] = totalFuelCost;
@ -296,7 +264,10 @@ const pieChartOption = computed(() => {
scale: true, scale: true,
scaleSize: 5 scaleSize: 5
}, },
data: data.map((d) => ({ ...d, itemStyle: { color: typeColorMap[d.name] || "#909399" } })), data: data.map((d) => ({
...d,
itemStyle: { color: COST_TYPE_COLORS[d.name] || "#909399" }
})),
}, },
], ],
title: { title: {
@ -313,84 +284,35 @@ const pieChartOption = computed(() => {
}; };
}); });
//
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(() => { const monthlyChartOption = computed(() => {
if (!allRecords.value.length && !fuelRecords.value.length) return null; if (!costRecords.value.length && !fuelRecords.value.length) return null;
//
const now = new Date();
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
// //
let targetMonths; let sortedMonths;
if (costTimeRange.value === 'past_year') { if (costTimeRange.value === 'past_year') {
const monthsToShow = isMobile.value ? 6 : 12; sortedMonths = targetMonths.value;
targetMonths = getPrevMonths(currentMonth, monthsToShow);
} else { } else {
// //
const allMonths = new Set(); const allMonths = new Set();
costInstallments.value.forEach(item => allMonths.add(item.month)); costInstallments.value.forEach(item => allMonths.add(item.month));
fuelRecords.value.forEach(r => allMonths.add(r.date.slice(0, 7))); fuelRecords.value.forEach(r => allMonths.add(r.date.slice(0, 7)));
targetMonths = Array.from(allMonths).sort(); sortedMonths = Array.from(allMonths).sort();
} }
if (targetMonths.length === 0) return null; if (!sortedMonths || sortedMonths.length === 0) return null;
// const monthlyData = monthlyStats.value;
const monthlyData = {}; const allTypes = [...COST_TYPES, '油费'];
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 => { const existingTypes = allTypes.filter(type => {
return sortedMonths.some(month => monthlyData[month][type] > 0); return sortedMonths.some(month => monthlyData[month] && monthlyData[month][type] > 0);
}); });
// //
const monthlyTotals = sortedMonths.map(month => { const monthlyTotals = sortedMonths.map(month => {
const total = existingTypes.reduce((sum, type) => sum + monthlyData[month][type], 0); const total = existingTypes.reduce((sum, type) => sum + (monthlyData[month]?.[type] || 0), 0);
return total.toFixed(0); return total.toFixed(0);
}); });
@ -416,7 +338,13 @@ const monthlyChartOption = computed(() => {
return html; return html;
}, },
}, },
legend: { top: "0%", textStyle: { fontSize: 9 }, itemWidth: 10, itemHeight: 10 }, legend: {
top: "0%",
textStyle: { fontSize: 9 },
itemWidth: 10,
itemHeight: 10,
data: existingTypes
},
xAxis: { xAxis: {
type: "category", type: "category",
data: sortedMonths.map((m) => m.slice(5)), data: sortedMonths.map((m) => m.slice(5)),
@ -425,30 +353,42 @@ const monthlyChartOption = computed(() => {
axisTick: { show: false } axisTick: { show: false }
}, },
yAxis: { type: "value", show: false }, yAxis: { type: "value", show: false },
series: existingTypes.map((type, index) => ({ series: [
name: type, ...existingTypes.map((type, index) => ({
type: "bar", name: type,
stack: "total", type: "bar",
data: sortedMonths.map((month) => parseFloat(monthlyData[month][type].toFixed(0))), stack: "total",
itemStyle: { data: sortedMonths.map((month) => parseFloat((monthlyData[month]?.[type] || 0).toFixed(0))),
color: typeColorMap[type] || "#909399", itemStyle: {
borderRadius: index === existingTypes.length - 1 ? [4, 4, 0, 0] : [0, 0, 0, 0] color: COST_TYPE_COLORS[type] || "#909399"
}, }
label: index === existingTypes.length - 1 ? { })),
show: true, {
position: 'top', name: "总计",
fontSize: 9, type: "bar",
formatter: function(params) { stack: "total",
return monthlyTotals[params.dataIndex]; data: sortedMonths.map(() => 0),
label: {
show: true,
position: 'top',
fontSize: 9,
formatter: function(params) {
return monthlyTotals[params.dataIndex];
},
color: '#606266'
}, },
color: '#606266' itemStyle: { color: 'transparent' },
} : undefined tooltip: { show: false },
})), showInLegend: false
}
],
}; };
}); });
const filteredRecords = computed(() => { const filteredRecords = computed(() => {
let result = allRecords.value; if (!state.vehicleId || state.vehicleId !== props.vehicleId) return [];
let result = allCostRecords.value;
if (selectedTypes.value.length > 0) { if (selectedTypes.value.length > 0) {
result = result.filter((r) => selectedTypes.value.includes(r.type)); result = result.filter((r) => selectedTypes.value.includes(r.type));
} }
@ -458,61 +398,29 @@ const filteredRecords = computed(() => {
}); });
const filteredTotal = computed(() => { const filteredTotal = computed(() => {
if (!state.vehicleId || state.vehicleId !== props.vehicleId) return 0;
if (selectedTypes.value.length > 0) { if (selectedTypes.value.length > 0) {
return allRecords.value.filter((r) => selectedTypes.value.includes(r.type)).length; return allCostRecords.value.filter((r) => selectedTypes.value.includes(r.type)).length;
} }
return total.value; return allCostRecords.value.length;
}); });
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) => { const editRecord = (record) => {
selectedRecord.value = record; selectedRecord.value = record;
showEditDialog.value = true; showEditDialog.value = true;
}; };
const handlePageChange = () => { const onRecordUpdated = async () => {
// if (props.vehicleId) {
}; await refreshVehicleData(props.vehicleId);
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, () => { watch(() => props.vehicleId, () => {
currentPage.value = 1; currentPage.value = 1;
loadRecords();
}, { immediate: true }); }, { immediate: true });
onMounted(() => {
loadRecords();
});
</script> </script>
<style scoped> <style scoped>

View File

@ -28,9 +28,9 @@
<!-- 记录列表 --> <!-- 记录列表 -->
<div v-if="showList" class="table-container"> <div v-if="showList" class="table-container">
<el-table <el-table
:data="records" :data="paginatedRecords"
style="width: 100%" style="width: 100%"
v-loading="loading" v-loading="isLoading"
size="small" size="small"
highlight-current-row highlight-current-row
@row-click="editRecord" @row-click="editRecord"
@ -38,9 +38,9 @@
<el-table-column prop="date" label="日期" width="100" /> <el-table-column prop="date" label="日期" width="100" />
<el-table-column prop="mileage" label="里程" width="80" /> <el-table-column prop="mileage" label="里程" width="80" />
<el-table-column prop="fuel_amount" label="油量" width="70" /> <el-table-column prop="fuel_amount" label="油量" width="70" />
<el-table-column prop="total_cost" label="金额" width="80"> <el-table-column prop="actual_cost" label="金额" width="80">
<template #default="scope"> <template #default="scope">
¥{{ scope.row.total_cost?.toFixed(0) }} ¥{{ scope.row.actual_cost?.toFixed(0) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="fuel_consumption" label="油耗" width="70"> <el-table-column prop="fuel_consumption" label="油耗" width="70">
@ -57,9 +57,8 @@
v-model:current-page="currentPage" v-model:current-page="currentPage"
v-model:page-size="pageSize" v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]" :page-sizes="[10, 20, 50]"
:total="total" :total="filteredRecords.length"
layout="total, prev, pager, next" layout="total, prev, pager, next"
@current-change="loadRecords"
style="margin-top: 15px; padding: 0 10px 10px;" style="margin-top: 15px; padding: 0 10px 10px;"
size="small" size="small"
/> />
@ -69,16 +68,16 @@
<EditFuelDialog <EditFuelDialog
v-model:visible="showEditDialog" v-model:visible="showEditDialog"
:record="selectedRecord" :record="selectedRecord"
@success="loadRecords" @success="onRecordUpdated"
/> />
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, watch, onMounted, computed } from 'vue' import { ref, computed, watch } from 'vue'
import axios from 'axios'
import { ElMessage } from 'element-plus'
import EditFuelDialog from '../dialogs/fuel/EditFuelDialog.vue' import EditFuelDialog from '../dialogs/fuel/EditFuelDialog.vue'
import { useVehicleData } from '../../composables/useVehicleData.js'
import { filterRecordsByTimeRange } from '../../utils/calculations.js'
const props = defineProps({ const props = defineProps({
vehicleId: Number, vehicleId: Number,
@ -92,20 +91,30 @@ const props = defineProps({
} }
}) })
defineEmits(['add-fuel']) const emit = defineEmits(['add-fuel'])
const API_BASE = 'https://api.yuany3721.site/carcost' // 使
const { fuelRecords: allFuelRecords, isLoading, refreshVehicleData, state } = useVehicleData()
const loading = ref(false)
const allRecords = ref([])
const records = ref([])
const total = ref(0)
const currentPage = ref(1) const currentPage = ref(1)
const pageSize = ref(15) const pageSize = ref(15)
const showEditDialog = ref(false) const showEditDialog = ref(false)
const selectedRecord = ref(null) const selectedRecord = ref(null)
const fuelTimeRange = ref('past_year') // 'past_year' | 'all' const fuelTimeRange = ref('past_year') // 'past_year' | 'all'
//
const filteredRecords = computed(() => {
if (!state.vehicleId || state.vehicleId !== props.vehicleId) return []
return filterRecordsByTimeRange(fuelTimeRange.value, allFuelRecords.value)
})
//
const paginatedRecords = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return filteredRecords.value.slice(start, end)
})
// //
const getOneYearAgo = () => { const getOneYearAgo = () => {
const now = new Date() const now = new Date()
@ -116,41 +125,25 @@ const getOneYearAgo = () => {
const getFilteredMonths = () => { const getFilteredMonths = () => {
// //
const monthlyData = {} const monthlyData = {}
allRecords.value.forEach(r => { filteredRecords.value.forEach(r => {
const month = r.date.slice(0, 7) const month = r.date.slice(0, 7)
monthlyData[month] = (monthlyData[month] || 0) + r.total_cost monthlyData[month] = (monthlyData[month] || 0) + r.actual_cost
}) })
// //
let sortedMonths = Object.keys(monthlyData).sort() 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 } return { sortedMonths, monthlyData }
} }
// - X // - X
const consumptionChartOption = computed(() => { const consumptionChartOption = computed(() => {
if (!allRecords.value.length) return null const records = filteredRecords.value.filter(r => r.fuel_consumption)
// if (records.length === 0) 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 const fuelConsumptionData = records
.sort((a, b) => a.mileage - b.mileage) .sort((a, b) => a.mileage - b.mileage)
.map(r => ({ .map(r => ({
value: [r.mileage, r.fuel_consumption], value: [r.mileage, r.fuel_consumption],
@ -159,8 +152,6 @@ const consumptionChartOption = computed(() => {
fuelAmount: r.fuel_amount fuelAmount: r.fuel_amount
})) }))
if (fuelConsumptionData.length === 0) return null
// X // X
const mileages = fuelConsumptionData.map(d => d.value[0]) const mileages = fuelConsumptionData.map(d => d.value[0])
const minMileage = Math.min(...mileages) const minMileage = Math.min(...mileages)
@ -197,9 +188,7 @@ const consumptionChartOption = computed(() => {
min: Math.floor(minMileage - padding), min: Math.floor(minMileage - padding),
max: Math.ceil(maxMileage + padding), max: Math.ceil(maxMileage + padding),
nameTextStyle: { fontSize: 9 }, nameTextStyle: { fontSize: 9 },
axisLabel: { axisLabel: { fontSize: 9 }
fontSize: 9
}
}, },
yAxis: { yAxis: {
type: 'value', type: 'value',
@ -253,7 +242,7 @@ const consumptionChartOption = computed(() => {
// //
const monthlyCostChartOption = computed(() => { const monthlyCostChartOption = computed(() => {
if (!allRecords.value.length) return null if (!filteredRecords.value.length) return null
const { sortedMonths, monthlyData } = getFilteredMonths() const { sortedMonths, monthlyData } = getFilteredMonths()
if (sortedMonths.length < 1) return null if (sortedMonths.length < 1) return null
@ -303,33 +292,16 @@ const editRecord = (record) => {
showEditDialog.value = true showEditDialog.value = true
} }
const loadRecords = async () => { const onRecordUpdated = async () => {
if (!props.vehicleId) return if (props.vehicleId) {
await refreshVehicleData(props.vehicleId)
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, () => { watch(() => props.vehicleId, () => {
currentPage.value = 1 currentPage.value = 1
loadRecords()
}, { immediate: true }) }, { immediate: true })
onMounted(() => {
loadRecords()
})
</script> </script>
<style scoped> <style scoped>
@ -383,4 +355,4 @@ onMounted(() => {
flex: 1; flex: 1;
overflow: auto; overflow: auto;
} }
</style> </style>

View File

@ -0,0 +1,35 @@
import { UserManager, WebStorageStateStore } from 'oidc-client-ts'
// Authentik OIDC 配置
const oidcConfig = {
authority: 'https://auth.yuany3721.site/application/o/car-cost/',
client_id: '27UljrOp2LfCg3fjEo1n8vOiRyCFzqEcnBFiUK59',
redirect_uri: window.location.origin + '/auth/callback',
response_type: 'code',
scope: 'openid profile email',
userStore: new WebStorageStateStore({
store: window.localStorage,
prefix: 'oidc.car-cost.'
}),
metadata: {
issuer: 'https://auth.yuany3721.site/application/o/car-cost/',
authorization_endpoint: 'https://auth.yuany3721.site/application/o/authorize/',
token_endpoint: 'https://auth.yuany3721.site/application/o/token/',
userinfo_endpoint: 'https://auth.yuany3721.site/application/o/userinfo/',
end_session_endpoint: 'https://auth.yuany3721.site/application/o/car-cost/end-session/',
jwks_uri: 'https://auth.yuany3721.site/application/o/car-cost/jwks/',
}
}
// 创建并导出 UserManager 实例
export const userManager = new UserManager(oidcConfig)
// 清理旧的存储(在跳转登录前调用)
export function cleanupOldStorage() {
const keys = Object.keys(localStorage)
keys.forEach(key => {
if (key.startsWith('oidc.') && !key.includes('car-cost')) {
localStorage.removeItem(key)
}
})
}

View File

@ -0,0 +1,147 @@
import { reactive, computed } from 'vue'
import api from '../api.js'
import { ElMessage } from 'element-plus'
import {
calculateFuelConsumption,
calculateVehicleStats,
calculateCostInstallments,
calculateAvgDailyKm
} from '../utils/calculations.js'
const API_BASE = 'https://api.yuany3721.site/carcost'
// 使用 reactive 创建全局状态(比 ref 更适合对象)
const state = reactive({
vehicleId: null,
vehicleInfo: null,
fuelRecords: [],
costRecords: [],
isLoading: false,
lastLoaded: null
})
/**
* 加载车辆数据
*/
async function loadVehicleData(vehicleId, vehicleInfo = null) {
if (!vehicleId) return false
// 如果已有该车辆的数据,直接返回
if (state.vehicleId === vehicleId && state.lastLoaded) {
return true
}
state.isLoading = true
try {
const [fuelRes, costRes] = await Promise.all([
api.get(`/fuel-records/list?vehicle_id=${vehicleId}`),
api.get(`/costs/list?vehicle_id=${vehicleId}`)
])
// 为每条记录计算油耗
const fuelRecords = fuelRes.data || []
const fuelRecordsWithConsumption = fuelRecords.map(record => ({
...record,
fuel_consumption: calculateFuelConsumption(fuelRecords, record)
}))
// 更新状态
state.vehicleId = vehicleId
state.vehicleInfo = vehicleInfo
state.fuelRecords = fuelRecordsWithConsumption
state.costRecords = costRes.data || []
state.lastLoaded = Date.now()
return true
} catch (error) {
console.error('加载车辆数据失败:', error)
ElMessage.error('加载数据失败')
return false
} finally {
state.isLoading = false
}
}
/**
* 刷新数据
*/
async function refreshVehicleData(vehicleId, vehicleInfo = null) {
state.lastLoaded = null
return await loadVehicleData(vehicleId, vehicleInfo)
}
/**
* useVehicleData Composable
*/
export function useVehicleData() {
// 统计数据
const stats = computed(() => {
if (!state.vehicleInfo) {
return {
total_mileage: 0,
total_fuel_cost: 0,
total_fuel_amount: 0,
avg_fuel_consumption: null,
avg_daily_km: null
}
}
const vehicleStats = calculateVehicleStats(
state.vehicleInfo,
state.fuelRecords
)
const avgDailyKm = calculateAvgDailyKm(
vehicleStats.total_mileage,
state.fuelRecords
)
return {
...vehicleStats,
avg_daily_km: avgDailyKm
}
})
// 总费用
const totalCost = computed(() => {
const fuelCost = state.fuelRecords.reduce((sum, r) => sum + r.actual_cost, 0)
const otherCost = state.costRecords.reduce((sum, r) => sum + r.amount, 0)
return Math.round((fuelCost + otherCost) * 100) / 100
})
// 费用分摊
const costInstallments = computed(() => {
return calculateCostInstallments(state.costRecords)
})
// 月度油费
const monthlyFuelCost = computed(() => {
const monthlyData = {}
state.fuelRecords.forEach((r) => {
const month = r.date.slice(0, 7)
monthlyData[month] = (monthlyData[month] || 0) + r.actual_cost
})
return monthlyData
})
return {
// 状态(直接暴露响应式对象)
state,
// 方法
loadVehicleData,
refreshVehicleData,
// 计算属性
stats,
totalCost,
fuelRecords: computed(() => state.fuelRecords),
costRecords: computed(() => state.costRecords),
costInstallments,
monthlyFuelCost
}
}
// 导出状态供全局使用
export { state, loadVehicleData, refreshVehicleData }

View File

@ -0,0 +1,15 @@
// 费用类型常量
export const COST_TYPES = ['保养', '维修', '保险', '停车', '过路费', '洗车', '违章', '其他']
// 费用类型颜色映射
export const COST_TYPE_COLORS = {
保养: '#67c23a',
维修: '#e6a23c',
保险: '#f56c6c',
停车: '#909399',
过路费: '#9a60b4',
洗车: '#409eff',
违章: '#ff6b6b',
其他: '#fc8452',
油费: '#409eff'
}

View File

@ -7,11 +7,13 @@ import { CanvasRenderer } from 'echarts/renderers'
import { LineChart, BarChart, PieChart, ScatterChart } from 'echarts/charts' import { LineChart, BarChart, PieChart, ScatterChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent, TitleComponent, GraphicComponent, MarkPointComponent, MarkLineComponent } from 'echarts/components' import { GridComponent, TooltipComponent, LegendComponent, TitleComponent, GraphicComponent, MarkPointComponent, MarkLineComponent } from 'echarts/components'
import App from './App.vue' import App from './App.vue'
import router from './router.js'
// 注册 ECharts 组件 // 注册 ECharts 组件
use([CanvasRenderer, LineChart, BarChart, PieChart, ScatterChart, GridComponent, TooltipComponent, LegendComponent, TitleComponent, GraphicComponent, MarkPointComponent, MarkLineComponent]) use([CanvasRenderer, LineChart, BarChart, PieChart, ScatterChart, GridComponent, TooltipComponent, LegendComponent, TitleComponent, GraphicComponent, MarkPointComponent, MarkLineComponent])
const app = createApp(App) const app = createApp(App)
app.use(ElementPlus) app.use(ElementPlus)
app.use(router)
app.component('v-chart', VueECharts) app.component('v-chart', VueECharts)
app.mount('#app') app.mount('#app')

65
web/src/router.js Normal file
View File

@ -0,0 +1,65 @@
import { createRouter, createWebHistory } from "vue-router";
import Main from "./views/Main.vue";
import { userManager } from "./composables/useAuth.js";
import api, { setAuthToken } from "./api.js";
const routes = [
{ path: "/", component: Main },
{ path: "/auth/callback", component: Main },
];
const router = createRouter({
history: createWebHistory(),
routes,
});
// 路由守卫 - 检查登录状态并处理回调
router.beforeEach(async (to) => {
// 如果是回调路径,处理登录
if (to.path === "/auth/callback" && to.query.code) {
try {
const user = await userManager.signinRedirectCallback();
if (user?.access_token) {
setAuthToken(user.access_token);
}
// 跳转到首页
return { path: "/", replace: true };
} catch (err) {
console.error("[Router] 登录失败:", err);
}
}
// 检查登录状态
try {
const user = await userManager.getUser();
if (!user || user.expired) {
await userManager.signinRedirect();
return false;
}
setAuthToken(user.access_token);
} catch (error) {
console.error("[Router] 认证失败:", error);
await userManager.signinRedirect();
return false;
}
return true;
});
// Axios 拦截器 - 处理 401
api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
console.warn("[Axios] 401 Unauthorized");
await userManager.signinRedirect();
}
return Promise.reject(error);
}
);
export default router;

View File

@ -0,0 +1,357 @@
/**
* 计算单次油耗
*
* 油耗计算逻辑复制自后端
* 1. 找到上一条加满油的记录
* 2. 计算两次加满之间的里程差
* 3. 计算两次加满之间的累计加油量包括中间未加满的记录
* 4. 油耗 = (累计加油量 / 里程差) * 100
*
* @param {Array} allRecords - 该车辆的所有加油记录按日期降序排列
* @param {Object} currentRecord - 当前要计算油耗的记录
* @returns {number|null} - 油耗(L/100km)或null
*/
export function calculateFuelConsumption(allRecords, currentRecord) {
if (!currentRecord.is_full_tank) {
return null
}
// 找到上一条加满油的记录(排除已删除,且在当前记录之前)
const prevRecord = findPrevFullTankRecord(allRecords, currentRecord)
if (!prevRecord) {
return null
}
const mileageDiff = currentRecord.mileage - prevRecord.mileage
if (mileageDiff <= 0) {
return null
}
// 计算两次加满之间的累计加油量(包括中间未加满的记录)
// 从上一次加满之后(不包括)到当前这次(包括)的所有加油记录
const intermediateRecords = allRecords.filter(r => {
// 只考虑同车、未删除的记录
if (r.vehicle_id !== currentRecord.vehicle_id || r.is_deleted) {
return false
}
// 里程在上一次加满之后到当前这次之间
return r.mileage > prevRecord.mileage && r.mileage <= currentRecord.mileage
})
const totalFuel = intermediateRecords.reduce((sum, r) => sum + r.fuel_amount, 0)
const fuelConsumption = (totalFuel / mileageDiff) * 100
return Math.round(fuelConsumption * 100) / 100
}
/**
* 查找上一条加满油的记录
* @param {Array} allRecords - 所有记录
* @param {Object} currentRecord - 当前记录
* @returns {Object|null}
*/
function findPrevFullTankRecord(allRecords, currentRecord) {
// 先找更早日期的加满记录
const prevByDate = allRecords.find(r => {
return r.vehicle_id === currentRecord.vehicle_id &&
!r.is_deleted &&
r.is_full_tank &&
r.date < currentRecord.date
})
if (prevByDate) {
// 找到同一天更早的加满记录(按里程排序找最近的)
const sameDateRecords = allRecords.filter(r => {
return r.vehicle_id === currentRecord.vehicle_id &&
!r.is_deleted &&
r.is_full_tank &&
r.date === currentRecord.date &&
r.mileage < currentRecord.mileage
})
if (sameDateRecords.length > 0) {
// 按里程降序取第一个(最近的)
sameDateRecords.sort((a, b) => b.mileage - a.mileage)
return sameDateRecords[0]
}
return prevByDate
}
// 如果没有更早日期,找同一天更早的记录
const sameDateEarlier = allRecords.filter(r => {
return r.vehicle_id === currentRecord.vehicle_id &&
!r.is_deleted &&
r.is_full_tank &&
r.date === currentRecord.date &&
r.mileage < currentRecord.mileage
})
if (sameDateEarlier.length > 0) {
sameDateEarlier.sort((a, b) => b.mileage - a.mileage)
return sameDateEarlier[0]
}
return null
}
/**
* 计算车辆统计数据
* @param {Object} vehicle - 车辆信息
* @param {Array} fuelRecords - 加油记录
* @returns {Object} 统计数据
*/
export function calculateVehicleStats(vehicle, fuelRecords) {
if (!vehicle || !fuelRecords || fuelRecords.length === 0) {
return {
total_mileage: 0,
total_fuel_cost: 0,
total_fuel_amount: 0,
avg_fuel_consumption: null
}
}
// 计算总里程
const latestMileage = Math.max(...fuelRecords.map(r => r.mileage))
const totalMileage = latestMileage - vehicle.initial_mileage
// 计算总油费(使用实付金额)
const totalFuelCost = fuelRecords.reduce((sum, r) => sum + r.actual_cost, 0)
// 计算总加油量(只算加满的记录)
const totalFuelAmount = fuelRecords
.filter(r => r.is_full_tank)
.reduce((sum, r) => sum + r.fuel_amount, 0)
// 计算平均油耗
let avgFuelConsumption = null
if (totalMileage > 0 && totalFuelAmount > 0) {
avgFuelConsumption = Math.round((totalFuelAmount / totalMileage) * 100 * 100) / 100
}
return {
total_mileage: totalMileage,
total_fuel_cost: Math.round(totalFuelCost * 100) / 100,
total_fuel_amount: Math.round(totalFuelAmount * 100) / 100,
avg_fuel_consumption: avgFuelConsumption
}
}
/**
* 计算费用分摊明细
* @param {Array} costRecords - 费用记录
* @returns {Array} 分摊后的费用明细
*/
export function calculateCostInstallments(costRecords) {
const installments = []
costRecords.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个月
* @param {string} month - 月份字符串 (YYYY-MM)
* @param {number} n - 月数
* @returns {string} 目标月份
*/
function 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')}`
}
/**
* 计算日均行程
* @param {number} totalMileage - 总里程
* @param {Array} fuelRecords - 加油记录按日期升序排列
* @returns {number|null}
*/
export function calculateAvgDailyKm(totalMileage, fuelRecords) {
if (totalMileage <= 0 || !fuelRecords || fuelRecords.length === 0) {
return null
}
// 按日期排序
const sortedRecords = [...fuelRecords].sort((a, b) =>
new Date(a.date) - new Date(b.date)
)
const firstDate = new Date(sortedRecords[0].date)
const lastDate = new Date(sortedRecords[sortedRecords.length - 1].date)
const days = (lastDate - firstDate) / (1000 * 60 * 60 * 24)
if (days <= 0) {
return null
}
return Math.round((totalMileage / days) * 10) / 10
}
/**
* 计算月度油费统计
* @param {Array} fuelRecords - 加油记录
* @returns {Object} 月度油费数据 { month: amount }
*/
export function calculateMonthlyFuelCost(fuelRecords) {
const monthlyData = {}
fuelRecords.forEach((r) => {
const month = r.date.slice(0, 7)
monthlyData[month] = (monthlyData[month] || 0) + r.actual_cost
})
return monthlyData
}
/**
* 计算月度费用统计包含油费
* @param {Array} fuelRecords - 加油记录
* @param {Array} costInstallments - 费用分摊明细
* @returns {Object} 月度统计 { month: { type: amount } }
*/
export function calculateMonthlyStats(fuelRecords, costInstallments) {
const monthlyData = {}
const allTypes = ['保养', '维修', '保险', '停车', '过路费', '洗车', '违章', '其他', '油费']
// 收集所有月份
const allMonthsSet = new Set()
costInstallments.forEach((item) => allMonthsSet.add(item.month))
fuelRecords.forEach((r) => allMonthsSet.add(r.date.slice(0, 7)))
const targetMonths = Array.from(allMonthsSet).sort()
// 初始化月度数据
targetMonths.forEach((month) => {
monthlyData[month] = {}
allTypes.forEach((type) => (monthlyData[month][type] = 0))
})
// 添加费用分摊
costInstallments.forEach((item) => {
if (monthlyData[item.month]) {
monthlyData[item.month][item.type] += item.amount
}
})
// 添加油费
fuelRecords.forEach((r) => {
const month = r.date.slice(0, 7)
if (monthlyData[month]) {
monthlyData[month]['油费'] += r.actual_cost
}
})
return monthlyData
}
/**
* 计算费用类型分布
* @param {Array} costInstallments - 费用分摊明细
* @param {Array} fuelRecords - 加油记录
* @param {Array} targetMonths - 目标月份列表可选不过滤则传null
* @returns {Object} { totalAmount, data: [{ name, value }] }
*/
export function calculateCostTypeDistribution(costInstallments, fuelRecords, targetMonths = null) {
const typeData = {}
let totalAmount = 0
// 统计费用
costInstallments.forEach((item) => {
if (!targetMonths || targetMonths.includes(item.month)) {
typeData[item.type] = (typeData[item.type] || 0) + item.amount
totalAmount += item.amount
}
})
// 统计油费
let totalFuelCost = 0
fuelRecords.forEach((r) => {
totalFuelCost += r.actual_cost
})
if (totalFuelCost > 0) {
typeData['油费'] = totalFuelCost
totalAmount += totalFuelCost
}
const data = Object.entries(typeData)
.map(([name, value]) => ({ name, value: Math.round(value) }))
.sort((a, b) => b.value - a.value)
return { totalAmount, data }
}
/**
* 计算最近N个月的月份列表
* @param {number} n - 月数
* @returns {Array} 月份列表 [YYYY-MM, ...]
*/
export function getRecentMonths(n) {
const months = []
const now = new Date()
for (let i = n - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`)
}
return months
}
/**
* 根据时间范围筛选记录
* @param {string} timeRange - 'past_6months' | 'past_year' | 'all'
* @param {Array} records - 记录列表需有date字段
* @returns {Array} 筛选后的记录
*/
export function filterRecordsByTimeRange(timeRange, records) {
if (timeRange === 'all') {
return records
}
const now = new Date()
let cutoffDate
if (timeRange === 'past_6months') {
cutoffDate = new Date(now.getFullYear(), now.getMonth() - 5, 1)
} else if (timeRange === 'past_year') {
cutoffDate = new Date(now.getFullYear(), now.getMonth() - 11, 1)
} else {
return records
}
const cutoffStr = cutoffDate.toISOString().slice(0, 10)
return records.filter(r => r.date >= cutoffStr)
}

405
web/src/views/Main.vue Normal file
View File

@ -0,0 +1,405 @@
<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 :vehicle-id="selectedVehicle" />
<!-- 四个图表 -->
<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 :vehicle-id="selectedVehicle" />
<!-- 移动端图表 -->
<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 { 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";
import { useVehicleData } from "../composables/useVehicleData.js";
import api from "../api.js";
const vehicles = ref([]);
const selectedVehicle = ref(null);
const mobileView = ref("dashboard");
// 使
const { loadVehicleData, refreshVehicleData } = useVehicleData();
//
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 api.get("/vehicles/list");
vehicles.value = res.data;
if (vehicles.value.length > 0 && !selectedVehicle.value) {
const vehicle = vehicles.value[0];
selectedVehicle.value = vehicle.id;
await loadVehicleData(selectedVehicle.value, vehicle);
}
} catch (error) {
console.error("加载车辆失败:", error);
ElMessage.error("加载车辆失败");
}
};
//
const onVehicleChange = async () => {
if (selectedVehicle.value) {
const vehicle = vehicles.value.find((v) => v.id === selectedVehicle.value);
await loadVehicleData(selectedVehicle.value, vehicle);
}
};
//
const selectVehicle = async (id) => {
selectedVehicle.value = id;
await onVehicleChange();
};
//
const editCurrentVehicle = () => {
if (!selectedVehicle.value) return;
currentVehicle.value = vehicles.value.find((v) => v.id === selectedVehicle.value);
showEditVehicle.value = true;
};
//
const onRecordAdded = async () => {
if (selectedVehicle.value) {
const vehicle = vehicles.value.find((v) => v.id === selectedVehicle.value);
await refreshVehicleData(selectedVehicle.value, vehicle);
}
};
onMounted(() => {
loadVehicles();
});
</script>
<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;
}
:deep(.el-main) {
padding: 20px !important;
}
:deep(.el-container) {
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) {
:deep(.el-main) {
padding: 10px !important;
}
.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>