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
.pnp.*
.ruff_cache/

189
AGENTS.md
View File

@ -1,70 +1,144 @@
# CarCost - Agent Instructions
## 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/`)
- **Backend**: FastAPI + SQLAlchemy + PostgreSQL (`api/`)
- **Authentication**: Authentik OIDC with PKCE flow
## Architecture
### Monorepo Structure
```
web/src/
├── App.vue # Main app component
├── main.js # Entry with Element Plus + ECharts setup
└── components/ # Reusable components
├── panels/ # List panels with charts + table
│ ├── FuelRecordsPanel.vue # Desktop & mobile shared
│ └── CostRecordsPanel.vue # Desktop & mobile shared
├── charts/ # Chart components
│ └── DashboardCharts.vue
├── cards/ # Card components
│ └── StatsCards.vue
└── dialogs/ # Dialog components
├── vehicle/
├── fuel/
└── cost/
carcost/
├── deploy-web.sh # Frontend deployment script
├── web/
│ ├── src/
│ │ ├── App.vue # Root container (router-view only)
│ │ ├── main.js # Entry with Element Plus + ECharts
│ │ ├── router.js # Vue Router with OIDC auth guard
│ │ ├── api.js # Unified Axios instance with auth
│ │ ├── composables/ # Vue composables
│ │ │ ├── useVehicleData.js
│ │ │ └── useAuth.js # OIDC configuration (userManager only)
│ │ ├── views/ # Page views
│ │ │ └── Main.vue # Main application page
│ │ └── components/ # Reusable components
│ └── 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**:
- `App.vue` uses CSS media queries to switch between desktop/mobile layouts
- `*Panel.vue` components handle both desktop and mobile via `showCharts`/`showList` props
- Desktop: Dual-column layout with StatsCards + DashboardCharts + Panels side-by-side
- Mobile: Single-column with view switching (dashboard/fuel/cost tabs)
### Authentication Flow
**Frontend (Vue Router Guard)**:
1. `router.beforeEach` checks authentication status
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
All API routes include `/carcost` prefix:
- `/carcost/vehicles/*` - Vehicle CRUD
- `/carcost/fuel_records/*` - Fuel records
- `/carcost/costs/*` - Cost records
- `/carcost/dashboard/*` - Dashboard data
- `GET/POST /carcost/vehicles/*` - Vehicle CRUD
- `GET/POST /carcost/fuel-records/*` - Fuel records
- `GET/POST /carcost/costs/*` - Cost records
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
- **Vehicle**: `id`, `name`, `purchase_date`, `initial_mileage`, `is_deleted`
- **FuelRecord**: `id`, `vehicle_id`, `date`, `mileage`, `fuel_amount`, `fuel_price`, `total_cost`, `is_full_tank`
- **Cost**: `id`, `vehicle_id`, `date`, `type`, `amount`, `mileage`, `is_installment`, `installment_months`
#### Vehicle
- `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).
## 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
### Backend (api/)
```bash
# Setup (requires Python 3.x)
cd api
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
# Run dev server
python main.py
# or
uvicorn main:app --host 0.0.0.0 --port 7030 --reload
# Run tests
python test_api.py
# Import fuel records from CSV
python import_fuel_records.py
```
### Frontend (web/)
@ -72,7 +146,7 @@ python test_api.py
cd web
npm install
# Dev server (exposes to all hosts)
# Dev server
npm run dev
# Build for production
@ -82,39 +156,44 @@ npm run build
npm run preview
```
### Deployment
```bash
# Deploy frontend (from carcost root)
./deploy-web.sh
```
## Key Configuration Files
### 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
```
### web/vite.config.js
- `server.allowedHosts: ['yuany3721.site', '.yuany3721.site']` - Required for production domain access
- `server.allowedHosts: ['yuany3721.site', '.yuany3721.site']`
## Code Patterns
### Frontend (Vue 3 Composition API)
- Uses `<script setup>` syntax
- Element Plus for UI components (with Chinese text labels)
- ECharts registered globally via `vue-echarts`
- Axios for API calls
- Responsive design with mobile/desktop layouts
### Frontend
- Vue 3 Composition API with `<script setup>`
- Element Plus for UI components
- Unified Axios instance (`api.js`) with automatic auth header
- Vue Router guards handle all auth logic before component render
- `useVehicleData` composable for global data caching
### Backend (FastAPI)
- Routers auto-create tables via `Base.metadata.create_all()`
- CORS configured for all origins (dev-friendly, restrict in production)
- SQLAlchemy 2.0 style with `declarative_base()`
- Database connection pooling: `pool_size=5, max_overflow=10`
## Testing
- `api/test_api.py`: Integration tests against live API (api.yuany3721.site)
- Tests cover costs CRUD operations and type validation
### Backend
- FastAPI with JWT token verification
- All routes protected by default
- Manual session management
- SQLAlchemy 2.0 style
## Important Notes
- **Port**: Backend runs on 7030, frontend dev server typically on 5173
- **CORS**: Backend allows all origins (`["*"]`) - change for production
- **Soft Deletes**: All entities use `is_deleted` flag; queries should filter it
- **Cost Types**: 保养/维修/保险/停车/洗车/违章/过路费/其他
- **Port**: Backend runs on 7030, frontend dev server on 5173
- **CORS**: Backend allows all origins (`["*"]`) for dev
- **Soft Deletes**: All entities use `is_deleted` flag
- **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

@ -19,7 +19,15 @@ def get_session() -> Session:
"""获取新的数据库会话
使用示例:
with get_session() as session:
session = get_session()
try:
result = session.query(Model).all()
session.commit()
return result
except Exception:
session.rollback()
raise
finally:
session.close()
"""
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 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 应用
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
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 生产环境应该限制具体域名
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=["*"],
)
# 注册路由 - 添加 /carcost 前缀
app.include_router(vehicles_router, prefix="/carcost")
app.include_router(fuel_records_router, prefix="/carcost")
app.include_router(costs_router, prefix="/carcost")
app.include_router(dashboard_router, prefix="/carcost")
# 注册路由 - 添加 /carcost 前缀,并启用认证保护
# 所有路由都需要认证
dependencies = [Depends(get_current_user)]
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("/")
@ -30,6 +33,7 @@ def root():
"message": "Welcome to CarCost API",
"version": settings.VERSION,
"docs": "/docs",
"auth": "Protected by Authentik OIDC",
}

View File

@ -1,7 +1,18 @@
"""
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.orm import relationship
from datetime import datetime
@ -12,6 +23,7 @@ Base = declarative_base()
class Vehicle(Base):
"""车辆表"""
__tablename__ = "vehicles"
id = Column(Integer, primary_key=True, autoincrement=True)
@ -29,6 +41,7 @@ class Vehicle(Base):
class FuelRecord(Base):
"""加油记录表"""
__tablename__ = "fuel_records"
id = Column(Integer, primary_key=True, autoincrement=True)
@ -37,7 +50,8 @@ class FuelRecord(Base):
mileage = Column(Integer, nullable=False)
fuel_amount = Column(Numeric(10, 2), nullable=False)
fuel_price = Column(Numeric(10, 2), nullable=True)
total_cost = Column(Numeric(10, 2), nullable=False)
display_cost = Column(Numeric(10, 2), nullable=True) # 机显总价(加油机显示金额)
actual_cost = Column(Numeric(10, 2), nullable=False) # 实付金额(优惠后实际支付)
is_full_tank = Column(Boolean, default=True)
notes = Column(Text, default="")
is_deleted = Column(Boolean, default=False) # 软删除标记
@ -50,12 +64,15 @@ class FuelRecord(Base):
class Cost(Base):
"""费用记录表"""
__tablename__ = "costs"
id = Column(Integer, primary_key=True, autoincrement=True)
vehicle_id = Column(Integer, ForeignKey("vehicles.id"), nullable=False)
date = Column(Date, nullable=False)
type = Column(String(20), nullable=False) # 保养/维修/保险/停车/洗车/违章/过路费/其他
type = Column(
String(20), nullable=False
) # 保养/维修/保险/停车/洗车/违章/过路费/其他
amount = Column(Numeric(10, 2), nullable=False)
mileage = Column(Integer, nullable=True)
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
uvicorn[standard]==0.32.0
sqlalchemy==2.0.36
greenlet==3.4.0
h11==0.16.0
httpcore==1.0.9
httptools==0.7.1
httpx==0.28.1
idna==3.11
psycopg2-binary==2.9.10
pyasn1==0.6.3
pycparser==3.0
pydantic==2.9.2
pydantic-settings==2.6.1
pydantic_core==2.23.4
python-dotenv==1.0.1
python-jose==3.5.0
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(fuel_records_router, prefix="/carcost")
"""
from .vehicles import router as vehicles_router
from .fuel_records import router as fuel_records_router
from .costs import router as costs_router
from .dashboard import router as dashboard_router
__all__ = [
"vehicles_router",
"fuel_records_router",
"costs_router",
"dashboard_router",
]

View File

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

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"])
def calculate_fuel_consumption(session, record: FuelRecord) -> Optional[float]:
"""计算单次油耗
油耗计算逻辑
1. 找到上一条加满油的记录
2. 计算两次加满之间的里程差
3. 计算两次加满之间的累计加油量包括中间未加满的记录
4. 油耗 = (累计加油量 / 里程差) * 100
"""
if not record.is_full_tank:
return None
# 找到上一条加满油的记录(排除已删除)
prev_record = (
session.query(FuelRecord)
.filter(
FuelRecord.vehicle_id == record.vehicle_id,
FuelRecord.date < record.date,
FuelRecord.is_full_tank == True,
FuelRecord.is_deleted == False,
)
.order_by(FuelRecord.date.desc(), FuelRecord.mileage.desc())
.first()
)
# 如果同一天有更早的记录,也考虑进去
if not prev_record:
prev_record = (
session.query(FuelRecord)
.filter(
FuelRecord.vehicle_id == record.vehicle_id,
FuelRecord.date == record.date,
FuelRecord.mileage < record.mileage,
FuelRecord.is_full_tank == True,
FuelRecord.is_deleted == False,
)
.order_by(FuelRecord.mileage.desc())
.first()
)
if not prev_record:
return None
mileage_diff = record.mileage - prev_record.mileage
if mileage_diff <= 0:
return None
# 计算两次加满之间的累计加油量(包括中间未加满的记录)
# 从上一次加满之后(不包括)到当前这次(包括)的所有加油记录
intermediate_records = (
session.query(FuelRecord)
.filter(
FuelRecord.vehicle_id == record.vehicle_id,
FuelRecord.date >= prev_record.date,
FuelRecord.date <= record.date,
FuelRecord.mileage > prev_record.mileage,
FuelRecord.mileage <= record.mileage,
FuelRecord.is_deleted == False,
)
.all()
)
total_fuel = sum(float(r.fuel_amount) for r in intermediate_records)
fuel_consumption = (total_fuel / mileage_diff) * 100
return round(fuel_consumption, 2)
def get_record_with_consumption(session, record: FuelRecord) -> FuelRecordResponse:
"""获取记录并计算油耗"""
fuel_consumption = calculate_fuel_consumption(session, record)
return FuelRecordResponse(
id=record.id,
vehicle_id=record.vehicle_id,
date=record.date,
mileage=record.mileage,
fuel_amount=float(record.fuel_amount),
fuel_price=float(record.fuel_price) if record.fuel_price else None,
total_cost=float(record.total_cost),
is_full_tank=record.is_full_tank,
notes=record.notes,
fuel_consumption=fuel_consumption,
is_deleted=record.is_deleted,
created_at=record.created_at,
updated_at=record.updated_at,
)
@router.get("/list", response_model=list[FuelRecordResponse])
def get_fuel_records(vehicle_id: Optional[int] = None, limit: int = 50):
"""获取加油记录列表(排除已删除)"""
with get_session() as session:
def get_fuel_records(vehicle_id: Optional[int] = None):
"""获取加油记录列表(排除已删除),返回原始数据不计算油耗"""
session = get_session()
try:
query = session.query(FuelRecord).filter(FuelRecord.is_deleted == False)
if vehicle_id:
query = query.filter(FuelRecord.vehicle_id == vehicle_id)
records = (
query.order_by(FuelRecord.date.desc(), FuelRecord.mileage.desc())
.limit(limit)
.all()
)
return [get_record_with_consumption(session, r) for r in records]
records = query.order_by(
FuelRecord.date.desc(), FuelRecord.mileage.desc()
).all()
# 直接返回原始数据,不计算油耗
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)
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()
if not vehicle:
raise HTTPException(status_code=404, detail="Vehicle not found")
# 自动计算总价或单价
# 自动计算价格
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:
# 只有总价,计算单价
fuel_price = total_cost / record.fuel_amount
elif total_cost is None and fuel_price is not None:
# 只有单价,计算总价
total_cost = fuel_price * record.fuel_amount
elif total_cost is None and fuel_price is None:
raise HTTPException(
status_code=400,
detail="Either fuel_price or total_cost must be provided",
)
# 如果两者都有,使用传入的值(不重新计算,保留实付金额)
# 如果没有单价,根据总价计算
if fuel_price is None and actual_cost is not None and record.fuel_amount > 0:
fuel_price = actual_cost / record.fuel_amount
db_record = FuelRecord(
vehicle_id=record.vehicle_id,
@ -151,20 +77,43 @@ def create_fuel_record(record: FuelRecordCreate):
mileage=record.mileage,
fuel_amount=Decimal(str(record.fuel_amount)),
fuel_price=Decimal(str(fuel_price)) if fuel_price else None,
total_cost=Decimal(str(total_cost)),
display_cost=Decimal(str(display_cost)) if display_cost else None,
actual_cost=Decimal(str(actual_cost)),
is_full_tank=record.is_full_tank,
notes=record.notes or "",
)
session.add(db_record)
session.commit()
session.refresh(db_record)
return get_record_with_consumption(session, db_record)
# 返回原始数据,不计算油耗
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)
def update_fuel_record(record_update: FuelRecordUpdate):
"""更新加油记录"""
with get_session() as session:
session = get_session()
try:
record = (
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
if record_update.fuel_amount is not None:
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:
record.is_full_tank = record_update.is_full_tank
if record_update.notes is not None:
record.notes = record_update.notes
# 处理价格和总价
fuel_amount = float(record.fuel_amount)
if (
record_update.fuel_price is not None
and record_update.total_cost is not None
):
record.fuel_price = Decimal(str(record_update.fuel_price))
record.total_cost = Decimal(str(record_update.total_cost))
elif record_update.fuel_price is not None:
record.fuel_price = Decimal(str(record_update.fuel_price))
record.total_cost = Decimal(str(record_update.fuel_price * fuel_amount))
elif record_update.total_cost is not None:
record.total_cost = Decimal(str(record_update.total_cost))
if fuel_amount > 0:
record.fuel_price = Decimal(str(record_update.total_cost / fuel_amount))
session.commit()
session.refresh(record)
return get_record_with_consumption(session, record)
# 返回原始数据,不计算油耗
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")
def delete_fuel_record(record: FuelRecordDelete):
"""软删除加油记录"""
with get_session() as session:
session = get_session()
try:
db_record = (
session.query(FuelRecord)
.filter(FuelRecord.id == record.id, FuelRecord.is_deleted == False)
@ -219,3 +178,5 @@ def delete_fuel_record(record: FuelRecordDelete):
db_record.is_deleted = True
session.commit()
return {"message": "Fuel record deleted successfully"}
finally:
session.close()

View File

@ -9,7 +9,6 @@ from schemas import (
VehicleUpdate,
VehicleDelete,
VehicleResponse,
VehicleStats,
)
@ -19,7 +18,8 @@ router = APIRouter(prefix="/vehicles", tags=["vehicles"])
@router.get("/list", response_model=list[VehicleResponse])
def get_vehicles():
"""获取所有车辆列表(排除已删除)"""
with get_session() as session:
session = get_session()
try:
vehicles = (
session.query(Vehicle)
.filter(Vehicle.is_deleted == False)
@ -27,12 +27,15 @@ def get_vehicles():
.all()
)
return vehicles
finally:
session.close()
@router.post("/create", response_model=VehicleResponse)
def create_vehicle(vehicle: VehicleCreate):
"""添加新车辆"""
with get_session() as session:
session = get_session()
try:
db_vehicle = Vehicle(
name=vehicle.name,
purchase_date=vehicle.purchase_date,
@ -42,12 +45,15 @@ def create_vehicle(vehicle: VehicleCreate):
session.commit()
session.refresh(db_vehicle)
return db_vehicle
finally:
session.close()
@router.post("/update", response_model=VehicleResponse)
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()
if not db_vehicle:
raise HTTPException(status_code=404, detail="Vehicle not found")
@ -62,12 +68,15 @@ def update_vehicle(vehicle: VehicleUpdate):
session.commit()
session.refresh(db_vehicle)
return db_vehicle
finally:
session.close()
@router.post("/delete")
def delete_vehicle(vehicle: VehicleDelete):
"""软删除车辆"""
with get_session() as session:
session = get_session()
try:
db_vehicle = (
session.query(Vehicle)
.filter(Vehicle.id == vehicle.id, Vehicle.is_deleted == False)
@ -79,49 +88,5 @@ def delete_vehicle(vehicle: VehicleDelete):
db_vehicle.is_deleted = True
session.commit()
return {"message": "Vehicle deleted successfully"}
@router.get("/stats", response_model=VehicleStats)
def get_vehicle_stats(vehicle_id: int):
"""获取车辆统计信息"""
with get_session() as session:
vehicle = (
session.query(Vehicle)
.filter(Vehicle.id == vehicle_id, Vehicle.is_deleted == False)
.first()
)
if not vehicle:
raise HTTPException(status_code=404, detail="Vehicle not found")
# 获取所有未删除的加油记录
fuel_records = (
session.query(FuelRecord)
.filter(FuelRecord.vehicle_id == vehicle_id, FuelRecord.is_deleted == False)
.order_by(FuelRecord.date.asc())
.all()
)
# 计算总里程
if fuel_records:
latest_mileage = max(r.mileage for r in fuel_records)
total_mileage = latest_mileage - vehicle.initial_mileage
else:
total_mileage = 0
# 计算总油费和总加油量(只算加满的记录)
total_fuel_cost = sum(float(r.total_cost) for r in fuel_records)
total_fuel_amount = sum(
float(r.fuel_amount) for r in fuel_records if r.is_full_tank
)
# 计算平均油耗
avg_fuel_consumption = None
if total_mileage > 0 and total_fuel_amount > 0:
avg_fuel_consumption = round((total_fuel_amount / total_mileage) * 100, 2)
return VehicleStats(
total_mileage=total_mileage,
total_fuel_cost=round(total_fuel_cost, 2),
total_fuel_amount=round(total_fuel_amount, 2),
avg_fuel_consumption=avg_fuel_consumption,
)
finally:
session.close()

View File

@ -4,7 +4,7 @@
from pydantic import BaseModel, field_validator, ConfigDict
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
purchase_date: Optional[date] = None
purchase_date: Optional[date_cls] = None
initial_mileage: int = 0
@ -29,7 +29,7 @@ class VehicleUpdate(BaseModel):
id: int
name: Optional[str] = None
purchase_date: Optional[date] = None
purchase_date: Optional[date_cls] = None
initial_mileage: Optional[int] = None
@ -44,8 +44,8 @@ class VehicleResponse(VehicleBase):
id: int
is_deleted: bool = False
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
created_at: Optional[datetime_cls] = None
updated_at: Optional[datetime_cls] = None
class Config:
from_attributes = True
@ -67,11 +67,12 @@ class FuelRecordBase(BaseModel):
"""加油记录基础模型"""
vehicle_id: int
date: date
date: date_cls
mileage: int
fuel_amount: float
fuel_price: Optional[float] = None
total_cost: Optional[float] = None
display_cost: Optional[float] = None # 机显总价
actual_cost: float # 实付金额
is_full_tank: bool = True
notes: Optional[str] = ""
@ -79,7 +80,7 @@ class FuelRecordBase(BaseModel):
class FuelRecordCreate(FuelRecordBase):
"""创建加油记录请求模型"""
@field_validator("total_cost", "fuel_price", mode="before")
@field_validator("fuel_price", "display_cost", "actual_cost", mode="before")
@classmethod
def validate_costs(cls, v):
if v is not None:
@ -93,11 +94,12 @@ class FuelRecordUpdate(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
id: int
date: Union[date, str, None] = None
date: Union[date_cls, str, None] = None
mileage: Optional[int] = None
fuel_amount: 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
notes: Optional[str] = None
@ -106,10 +108,10 @@ class FuelRecordUpdate(BaseModel):
def parse_date(cls, v):
if v is None:
return None
if isinstance(v, date):
if isinstance(v, date_cls):
return v
if isinstance(v, str):
return date.fromisoformat(v)
return date_cls.fromisoformat(v)
raise ValueError("Invalid date format")
@ -124,17 +126,18 @@ class FuelRecordResponse(BaseModel):
id: int
vehicle_id: int
date: date
date: date_cls
mileage: int
fuel_amount: float
fuel_price: Optional[float]
total_cost: float
display_cost: Optional[float] # 机显总价
actual_cost: float # 实付金额
is_full_tank: bool
notes: str
fuel_consumption: Optional[float] = None # 单次油耗,计算得出
is_deleted: bool = False
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
created_at: Optional[datetime_cls] = None
updated_at: Optional[datetime_cls] = None
class Config:
from_attributes = True
@ -150,7 +153,7 @@ class CostBase(BaseModel):
"""费用记录基础模型"""
vehicle_id: int
date: date
date: date_cls
type: str
amount: float
mileage: Optional[int] = None
@ -171,7 +174,7 @@ class CostUpdate(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
id: int
date: Union[date, str, None] = None
date: Union[date_cls, str, None] = None
type: Optional[str] = None
amount: Optional[float] = None
mileage: Optional[int] = None
@ -184,10 +187,10 @@ class CostUpdate(BaseModel):
def parse_date(cls, v):
if v is None:
return None
if isinstance(v, date):
if isinstance(v, date_cls):
return v
if isinstance(v, str):
return date.fromisoformat(v)
return date_cls.fromisoformat(v)
raise ValueError("Invalid date format")
@ -202,7 +205,7 @@ class CostResponse(BaseModel):
id: int
vehicle_id: int
date: date
date: date_cls
type: str
amount: float
mileage: Optional[int]
@ -210,8 +213,8 @@ class CostResponse(BaseModel):
is_installment: bool
installment_months: int
is_deleted: bool = False
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
created_at: Optional[datetime_cls] = None
updated_at: Optional[datetime_cls] = None
class Config:
from_attributes = True
@ -224,10 +227,10 @@ class FuelRecordItem(BaseModel):
"""仪表板中使用的加油记录项"""
id: int
date: date
date: date_cls
mileage: int
fuel_amount: float
total_cost: float
actual_cost: float # 实付金额(用于统计)
fuel_consumption: Optional[float]
@ -236,7 +239,7 @@ class DashboardData(BaseModel):
vehicle_id: int
vehicle_name: str
purchase_date: Optional[date]
purchase_date: Optional[date_cls]
total_mileage: int
total_fuel_cost: 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",
"echarts": "^6.0.0",
"element-plus": "^2.13.6",
"oidc-client-ts": "^3.5.0",
"vue": "^3.5.32",
"vue-echarts": "^8.0.1"
"vue-echarts": "^8.0.1",
"vue-router": "^5.0.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.5",
"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": {
"version": "7.27.1",
"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==",
"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": {
"version": "1.5.5",
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"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": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz",
@ -501,6 +558,33 @@
"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": {
"version": "3.5.32",
"resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.32.tgz",
@ -551,6 +635,33 @@
"@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": {
"version": "3.5.32",
"resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.32.tgz",
@ -637,6 +748,50 @@
"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": {
"version": "4.2.5",
"resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz",
@ -660,6 +815,15 @@
"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": {
"version": "1.0.2",
"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_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": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
@ -685,6 +864,12 @@
"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": {
"version": "3.2.3",
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
@ -835,11 +1020,16 @@
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"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": {
"version": "6.5.0",
"resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
@ -1001,6 +1191,45 @@
"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": {
"version": "1.32.0",
"resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz",
@ -1262,6 +1491,23 @@
"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": {
"version": "4.18.1",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
@ -1294,6 +1540,21 @@
"@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": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -1330,6 +1591,41 @@
"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": {
"version": "3.3.11",
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz",
@ -1354,6 +1650,30 @@
"integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
@ -1364,7 +1684,6 @@
"version": "4.0.4",
"resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@ -1373,6 +1692,17 @@
"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": {
"version": "8.5.9",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.9.tgz",
@ -1410,6 +1740,35 @@
"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": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-rc.13.tgz",
@ -1451,6 +1810,12 @@
"dev": true,
"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": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
@ -1464,7 +1829,6 @@
"version": "0.2.16",
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
@ -1485,6 +1849,42 @@
"license": "0BSD",
"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": {
"version": "8.0.7",
"resolved": "https://registry.npmmirror.com/vite/-/vite-8.0.7.tgz",
@ -1600,6 +2000,72 @@
"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": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.0.0.tgz",

View File

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

View File

@ -1,265 +1,9 @@
<template>
<div class="app">
<el-container>
<!-- 头部 -->
<el-header class="header">
<div class="header-inner">
<div class="header-content">
<h1 class="app-title" @click="mobileView = 'dashboard'">🚗 CarCost</h1>
<!-- 桌面端车辆选择 + 添加按钮 -->
<div class="desktop-nav">
<el-select
v-model="selectedVehicle"
placeholder="选择车辆"
style="width: 180px"
@change="onVehicleChange"
>
<el-option
v-for="vehicle in vehicles"
:key="vehicle.id"
:label="vehicle.name"
:value="vehicle.id"
/>
</el-select>
<el-divider direction="vertical" />
<el-button @click="showAddVehicle = true" title="添加车辆">
<el-icon><Plus /></el-icon> 车辆
</el-button>
<el-button v-if="selectedVehicle" @click="editCurrentVehicle" title="编辑车辆">
<el-icon><Edit /></el-icon> 编辑
</el-button>
<el-divider direction="vertical" />
<el-button @click="showAddFuel = true" title="添加加油记录">
<el-icon><Plus /></el-icon> 加油
</el-button>
<el-button @click="showAddCost = true" title="添加费用记录">
<el-icon><Plus /></el-icon> 费用
</el-button>
</div>
<!-- 移动端车辆选择 + 添加按钮 + 汉堡菜单 -->
<div class="mobile-nav">
<el-dropdown trigger="click" v-if="vehicles.length > 0">
<el-button>
{{ selectedVehicleName || '选择车辆' }}
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="vehicle in vehicles"
:key="vehicle.id"
@click="selectVehicle(vehicle.id)"
>
{{ vehicle.name }}
</el-dropdown-item>
<el-dropdown-item divided @click="showAddVehicle = true">+ 添加车辆</el-dropdown-item>
<el-dropdown-item v-if="selectedVehicle" @click="editCurrentVehicle"> 编辑车辆</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button v-if="mobileView === 'fuel'" @click="showAddFuel = true">
<el-icon><Plus /></el-icon>
</el-button>
<el-button v-if="mobileView === 'cost'" @click="showAddCost = true">
<el-icon><Plus /></el-icon>
</el-button>
<el-dropdown trigger="click">
<el-button>
<el-icon><Menu /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="mobileView = 'fuel'"> 加油记录</el-dropdown-item>
<el-dropdown-item @click="mobileView = 'cost'">💰 费用记录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
</el-header>
<el-main>
<div class="content-wrapper">
<!-- 移动端车辆选择 -->
<div class="mobile-vehicle-select">
<el-select
v-model="selectedVehicle"
placeholder="选择车辆"
style="width: 100%"
@change="onVehicleChange"
>
<el-option
v-for="vehicle in vehicles"
:key="vehicle.id"
:label="vehicle.name"
:value="vehicle.id"
/>
</el-select>
</div>
<!-- 桌面端布局 -->
<div class="desktop-layout">
<!-- 上方统计卡片 -->
<StatsCards :dashboard-data="dashboardData" :total-cost="totalCost" />
<!-- 四个图表 -->
<DashboardCharts :vehicle-id="selectedVehicle" />
<!-- 下方左右两栏列表 -->
<el-row :gutter="20" class="records-row">
<el-col :span="12">
<FuelRecordsPanel
:vehicle-id="selectedVehicle"
:show-charts="false"
:show-list="true"
@add-fuel="showAddFuel = true"
/>
</el-col>
<el-col :span="12">
<CostRecordsPanel
:vehicle-id="selectedVehicle"
:show-charts="false"
:show-list="true"
@add-cost="showAddCost = true"
/>
</el-col>
</el-row>
</div>
<!-- 移动端布局 -->
<div class="mobile-layout">
<!-- 仪表盘 -->
<div v-if="mobileView === 'dashboard'">
<StatsCards :dashboard-data="dashboardData" :total-cost="totalCost" />
<!-- 移动端图表 -->
<DashboardCharts :vehicle-id="selectedVehicle" />
</div>
<!-- 加油记录 -->
<div v-if="mobileView === 'fuel'">
<FuelRecordsPanel
:vehicle-id="selectedVehicle"
:show-charts="false"
:show-list="true"
@add-fuel="showAddFuel = true"
/>
</div>
<!-- 费用记录 -->
<div v-if="mobileView === 'cost'">
<CostRecordsPanel
:vehicle-id="selectedVehicle"
:show-charts="false"
:show-list="true"
@add-cost="showAddCost = true"
/>
</div>
</div>
</div>
</el-main>
</el-container>
<!-- 弹窗 -->
<AddVehicleDialog v-model:visible="showAddVehicle" @success="loadVehicles" />
<EditVehicleDialog v-model:visible="showEditVehicle" :vehicle="currentVehicle" @success="loadVehicles" />
<AddFuelDialog v-model:visible="showAddFuel" :vehicle-id="selectedVehicle" @success="onRecordAdded" />
<AddCostDialog v-model:visible="showAddCost" :vehicle-id="selectedVehicle" @success="onRecordAdded" />
</div>
<router-view />
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { Menu, ArrowDown, Plus, Edit } from '@element-plus/icons-vue'
import AddVehicleDialog from './components/dialogs/vehicle/AddVehicleDialog.vue'
import EditVehicleDialog from './components/dialogs/vehicle/EditVehicleDialog.vue'
import AddFuelDialog from './components/dialogs/fuel/AddFuelDialog.vue'
import AddCostDialog from './components/dialogs/cost/AddCostDialog.vue'
import StatsCards from './components/cards/StatsCards.vue'
import DashboardCharts from './components/charts/DashboardCharts.vue'
import FuelRecordsPanel from './components/panels/FuelRecordsPanel.vue'
import CostRecordsPanel from './components/panels/CostRecordsPanel.vue'
const API_BASE = 'https://api.yuany3721.site/carcost'
const vehicles = ref([])
const selectedVehicle = ref(null)
const dashboardData = ref(null)
const totalCost = ref(0)
const mobileView = ref('dashboard') // 'dashboard', 'fuel', 'cost'
//
const selectedVehicleName = computed(() => {
const vehicle = vehicles.value.find(v => v.id === selectedVehicle.value)
return vehicle?.name || ''
})
const showAddVehicle = ref(false)
const showAddFuel = ref(false)
const showAddCost = ref(false)
const showEditVehicle = ref(false)
const currentVehicle = ref(null)
//
const loadVehicles = async () => {
try {
const res = await axios.get(`${API_BASE}/vehicles/list`)
vehicles.value = res.data
if (vehicles.value.length > 0 && !selectedVehicle.value) {
selectedVehicle.value = vehicles.value[0].id
loadDashboard()
}
} catch (error) {
ElMessage.error('加载车辆失败')
}
}
//
const loadDashboard = async () => {
if (!selectedVehicle.value) return
try {
const res = await axios.get(`${API_BASE}/dashboard/data?vehicle_id=${selectedVehicle.value}`)
dashboardData.value = res.data
//
const costRes = await axios.get(`${API_BASE}/costs/list?vehicle_id=${selectedVehicle.value}&limit=1000`)
const costs = costRes.data || []
totalCost.value = costs.reduce((sum, c) => sum + c.amount, 0)
} catch (error) {
ElMessage.error('加载数据失败')
}
}
//
const onVehicleChange = () => {
loadDashboard()
}
//
const selectVehicle = (id) => {
selectedVehicle.value = id
onVehicleChange()
}
//
const editCurrentVehicle = () => {
if (!selectedVehicle.value) return
currentVehicle.value = vehicles.value.find(v => v.id === selectedVehicle.value)
showEditVehicle.value = true
}
//
const onRecordAdded = () => {
loadDashboard()
}
onMounted(() => {
loadVehicles()
})
// App.vue
</script>
<style>
@ -277,175 +21,3 @@ html, body {
overflow-x: hidden;
}
</style>
<style scoped>
.app {
min-height: 100vh;
background: #f5f7fa;
width: 100%;
}
.content-wrapper {
max-width: 900px;
margin: 0 auto;
width: 100%;
}
.header {
background: #fff;
border-bottom: 1px solid #e4e7ed;
padding: 0;
}
.header-inner {
max-width: 900px;
margin: 0 auto;
width: 100%;
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
height: 60px;
padding: 0 20px;
}
.header h1 {
margin: 0;
font-size: 20px;
color: #303133;
}
.app-title {
cursor: pointer;
}
.app-title:hover {
opacity: 0.8;
}
.desktop-nav {
display: flex;
align-items: center;
gap: 10px;
}
.mobile-nav {
display: none;
}
.mobile-vehicle-select {
display: none;
margin-bottom: 15px;
}
.desktop-layout {
display: block;
width: 100%;
overflow-x: hidden;
}
.mobile-layout {
display: none;
width: 100%;
overflow-x: hidden;
}
.records-row {
margin: 0 -10px !important;
width: calc(100% + 20px);
display: flex;
align-items: stretch;
}
.records-row :deep(.el-col) {
padding-left: 10px !important;
padding-right: 10px !important;
display: flex;
}
.records-row :deep(.el-col > *) {
flex: 1;
}
.mobile-charts {
margin-top: 15px;
}
.mobile-tip {
margin-top: 20px;
}
/* 调整 main 的 padding */
:deep(.el-main) {
padding: 20px !important;
}
@media (max-width: 768px) {
:deep(.el-main) {
padding: 10px !important;
}
}
:deep(.el-container) {
padding: 0 !important;
}
/* 移动端内容区域 */
.mobile-layout {
padding: 0;
}
@media (max-width: 768px) {
:deep(.el-main) {
padding: 0 !important;
}
}
/* 弹窗默认样式(电脑端) */
:deep(.el-dialog) {
width: 450px !important;
}
/* 移动端弹窗适配 */
@media (max-width: 768px) {
:deep(.el-dialog) {
width: 90% !important;
max-width: 350px !important;
}
}
/* 移动端适配 */
@media (max-width: 768px) {
.desktop-nav {
display: none;
}
.mobile-nav {
display: flex;
align-items: center;
gap: 8px;
}
.mobile-vehicle-select {
display: none;
}
.desktop-layout {
display: none;
}
.mobile-layout {
display: block;
}
.header-content {
padding: 0 10px;
}
.header h1 {
font-size: 18px;
}
}
</style>

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>
<el-card class="stats-card" v-if="dashboardData">
<el-card class="stats-card" v-if="hasData">
<el-row :gutter="20">
<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>
</el-statistic>
</el-col>
<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>
</el-statistic>
</el-col>
@ -27,7 +27,7 @@
</el-statistic>
</el-col>
<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>
</el-statistic>
</el-col>
@ -38,26 +38,34 @@
<script setup>
import { computed } from 'vue'
import { useVehicleData } from '../../composables/useVehicleData.js'
const props = defineProps({
dashboardData: Object,
totalCost: {
vehicleId: {
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(() => {
if (!props.dashboardData?.total_mileage || props.dashboardData.total_mileage === 0) return 0
return (props.dashboardData.total_fuel_cost || 0) / props.dashboardData.total_mileage
if (!stats.value?.total_mileage || stats.value.total_mileage === 0) return 0
return (stats.value.total_fuel_cost || 0) / stats.value.total_mileage
})
//
const totalCostPerKm = computed(() => {
if (!props.dashboardData?.total_mileage || props.dashboardData.total_mileage === 0) return 0
const total = (props.dashboardData.total_fuel_cost || 0) + props.totalCost
return total / props.dashboardData.total_mileage
if (!stats.value?.total_mileage || stats.value.total_mileage === 0) return 0
const total = (stats.value.total_fuel_cost || 0) + totalCost.value
return total / stats.value.total_mileage
})
</script>

View File

@ -12,7 +12,7 @@
<!-- 四个图表 -->
<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-content">
<v-chart
@ -28,7 +28,7 @@
</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-content">
<v-chart
@ -44,7 +44,7 @@
</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-content">
<v-chart
@ -79,110 +79,88 @@
</template>
<script setup>
import { ref, computed, watch, onMounted } from "vue";
import axios from "axios";
import { ref, computed, onMounted } from "vue";
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({
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 timeRange = ref("past_year");
onMounted(() => {
isMobile.value = window.innerWidth <= 768;
timeRange.value = isMobile.value ? "past_6months" : "past_year";
});
//
const allRecords = ref([]);
const fuelRecords = ref([]);
//
const loadData = async () => {
if (!props.vehicleId) return;
try {
const [fuelRes, costRes] = await Promise.all([
axios.get(`${API_BASE}/fuel-records/list?vehicle_id=${props.vehicleId}&limit=1000`),
axios.get(`${API_BASE}/costs/list?vehicle_id=${props.vehicleId}&limit=1000`),
]);
fuelRecords.value = fuelRes.data || [];
allRecords.value = costRes.data || [];
} catch (error) {
console.error("加载数据失败");
}
};
watch(() => props.vehicleId, loadData, { immediate: true });
//
const costInstallments = computed(() => {
const installments = [];
allRecords.value.forEach((cost) => {
if (cost.is_installment && cost.installment_months > 1) {
//
const monthlyAmount = cost.amount / cost.installment_months;
const startMonth = cost.date.slice(0, 7);
for (let i = 0; i < cost.installment_months; i++) {
const month = getNextMonth(startMonth, i);
installments.push({
month: month,
type: cost.type,
amount: monthlyAmount,
originalCostId: cost.id,
});
}
} else {
//
installments.push({
month: cost.date.slice(0, 7),
type: cost.type,
amount: cost.amount,
originalCostId: cost.id,
});
}
});
return installments;
//
const costRecords = computed(() => {
if (!state.vehicleId || state.vehicleId !== props.vehicleId) return [];
return allCostRecords.value;
});
// N
const getMonthsAgo = (months) => {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth() - months, now.getDate());
};
//
const fuelRecords = computed(() => {
if (!state.vehicleId || state.vehicleId !== props.vehicleId) return [];
return filterRecordsByTimeRange(timeRange.value, allFuelRecords.value);
});
//
const getFilteredRecords = () => {
let filteredFuel = fuelRecords.value;
let filteredCost = allRecords.value;
// - 使
//
const costInstallments = computed(() => {
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") {
const sixMonthsAgo = getMonthsAgo(6);
const cutoffDate = sixMonthsAgo.toISOString().slice(0, 10);
filteredFuel = fuelRecords.value.filter((r) => r.date >= cutoffDate);
filteredCost = allRecords.value.filter((r) => r.date >= cutoffDate);
return getRecentMonths(6);
} else if (timeRange.value === "past_year") {
const oneYearAgo = getMonthsAgo(12);
const cutoffDate = oneYearAgo.toISOString().slice(0, 10);
filteredFuel = fuelRecords.value.filter((r) => r.date >= cutoffDate);
filteredCost = allRecords.value.filter((r) => r.date >= cutoffDate);
return getRecentMonths(12);
}
return { filteredFuel, filteredCost };
};
return null;
});
//
const consumptionChartOption = computed(() => {
const { filteredFuel } = getFilteredRecords();
const validRecords = filteredFuel.filter((r) => r.fuel_consumption);
const validRecords = fuelRecords.value.filter((r) => r.fuel_consumption);
if (validRecords.length === 0) return null;
@ -292,15 +270,9 @@ const consumptionChartOption = computed(() => {
//
const monthlyCostChartOption = computed(() => {
const { filteredFuel } = getFilteredRecords();
const monthlyData = {};
filteredFuel.forEach((r) => {
const month = r.date.slice(0, 7);
monthlyData[month] = (monthlyData[month] || 0) + r.total_cost;
});
const monthlyData = monthlyFuelCost.value;
const sortedMonths = Object.keys(monthlyData).sort();
if (sortedMonths.length === 0) return null;
return {
@ -338,65 +310,13 @@ const monthlyCostChartOption = computed(() => {
};
});
//
const typeColorMap = {
保养: "#67c23a",
维修: "#e6a23c",
保险: "#f56c6c",
停车: "#909399",
过路费: "#9a60b4",
其他: "#fc8452",
油费: "#409eff",
};
//
const pieChartOption = computed(() => {
const { filteredFuel } = getFilteredRecords();
//
const now = new Date();
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
//
let targetMonths = [];
if (timeRange.value === "past_6months") {
// 656
for (let i = 5; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
targetMonths.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`);
}
} else if (timeRange.value === "past_year") {
// 1112
for (let i = 11; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
targetMonths.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`);
}
}
// ""
const typeData = {};
let totalAmount = 0;
//
costInstallments.value.forEach((item) => {
if (timeRange.value === "all" || targetMonths.includes(item.month)) {
typeData[item.type] = (typeData[item.type] || 0) + item.amount;
totalAmount += item.amount;
}
});
let totalFuelCost = 0;
filteredFuel.forEach((r) => {
totalFuelCost += r.total_cost;
});
if (totalFuelCost > 0) {
typeData["油费"] = totalFuelCost;
totalAmount += totalFuelCost;
}
const data = Object.entries(typeData)
.map(([name, value]) => ({ name, value: parseFloat(value.toFixed(0)) }))
.sort((a, b) => b.value - a.value);
const { totalAmount, data } = calculateCostTypeDistribution(
costInstallments.value,
fuelRecords.value,
targetMonths.value
);
if (data.length === 0) return null;
@ -423,7 +343,10 @@ const pieChartOption = computed(() => {
scale: true,
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: {
@ -442,62 +365,22 @@ const pieChartOption = computed(() => {
//
const monthlyChartOption = computed(() => {
const { filteredFuel } = getFilteredRecords();
const monthlyData = monthlyStats.value;
const allTypes = ["保养", "维修", "保险", "停车", "过路费", "洗车", "违章", "其他", "油费"];
const monthlyData = {};
const allTypes = ["保养", "维修", "保险", "停车", "过路费", "其他", "油费"];
//
const now = new Date();
//
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
//
const allMonthsSet = new Set();
costInstallments.value.forEach((item) => allMonthsSet.add(item.month));
filteredFuel.forEach((r) => allMonthsSet.add(r.date.slice(0, 7)));
let targetMonths = Array.from(allMonthsSet).sort();
//
if (timeRange.value === "past_6months") {
// 656
const cutoffDate = new Date(now.getFullYear(), now.getMonth() - 5, 1);
const cutoffMonth = `${cutoffDate.getFullYear()}-${String(cutoffDate.getMonth() + 1).padStart(2, "0")}`;
targetMonths = targetMonths.filter((m) => m >= cutoffMonth && m <= currentMonth);
} else if (timeRange.value === "past_year") {
// 1112
const cutoffDate = new Date(now.getFullYear(), now.getMonth() - 11, 1);
const cutoffMonth = `${cutoffDate.getFullYear()}-${String(cutoffDate.getMonth() + 1).padStart(2, "0")}`;
targetMonths = targetMonths.filter((m) => m >= cutoffMonth && m <= currentMonth);
//
let targetMonthsList = Object.keys(monthlyData).sort();
if (targetMonths.value) {
targetMonthsList = targetMonthsList.filter(m => targetMonths.value.includes(m));
}
// ""
//
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);
return total.toFixed(0);
});
@ -532,7 +415,7 @@ const monthlyChartOption = computed(() => {
},
xAxis: {
type: "category",
data: targetMonths.map((m) => m.slice(5)),
data: targetMonthsList.map((m) => m.slice(5)),
axisLabel: { fontSize: 10 },
axisLine: { show: true },
axisTick: { show: false },
@ -543,14 +426,14 @@ const monthlyChartOption = computed(() => {
name: type,
type: "bar",
stack: "total",
data: targetMonths.map((month) => parseFloat(monthlyData[month][type].toFixed(0))),
itemStyle: { color: typeColorMap[type] || "#909399" },
data: targetMonthsList.map((month) => parseFloat(monthlyData[month][type].toFixed(0))),
itemStyle: { color: COST_TYPE_COLORS[type] || "#909399" },
})),
{
name: "总计",
type: "bar",
stack: "total",
data: targetMonths.map(() => 0),
data: targetMonthsList.map(() => 0),
label: {
show: true,
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>
<style scoped>

View File

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

View File

@ -45,7 +45,7 @@
</el-form-item>
<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>
<template #footer>
@ -58,7 +58,7 @@
<script setup>
import { ref, computed, watch } from 'vue'
import axios from 'axios'
import api from '../../../api.js'
import { ElMessage, ElMessageBox } from 'element-plus'
const props = defineProps(['visible', 'record'])
@ -137,7 +137,7 @@ const submit = async () => {
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('保存成功')
visible.value = false
emit('success')
@ -166,7 +166,7 @@ const deleteRecord = async () => {
})
deleting.value = true
await axios.post(`${API_BASE}/costs/delete`, {
await api.post(`${API_BASE}/costs/delete`, {
id: form.value.id
})
ElMessage.success('删除成功')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -28,9 +28,9 @@
<!-- 记录列表 -->
<div v-if="showList" class="table-container">
<el-table
:data="records"
:data="paginatedRecords"
style="width: 100%"
v-loading="loading"
v-loading="isLoading"
size="small"
highlight-current-row
@row-click="editRecord"
@ -38,9 +38,9 @@
<el-table-column prop="date" label="日期" width="100" />
<el-table-column prop="mileage" label="里程" width="80" />
<el-table-column prop="fuel_amount" label="油量" width="70" />
<el-table-column prop="total_cost" label="金额" width="80">
<el-table-column prop="actual_cost" label="金额" width="80">
<template #default="scope">
¥{{ scope.row.total_cost?.toFixed(0) }}
¥{{ scope.row.actual_cost?.toFixed(0) }}
</template>
</el-table-column>
<el-table-column prop="fuel_consumption" label="油耗" width="70">
@ -57,9 +57,8 @@
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
:total="total"
:total="filteredRecords.length"
layout="total, prev, pager, next"
@current-change="loadRecords"
style="margin-top: 15px; padding: 0 10px 10px;"
size="small"
/>
@ -69,16 +68,16 @@
<EditFuelDialog
v-model:visible="showEditDialog"
:record="selectedRecord"
@success="loadRecords"
@success="onRecordUpdated"
/>
</div>
</template>
<script setup>
import { ref, watch, onMounted, computed } from 'vue'
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { ref, computed, watch } from 'vue'
import EditFuelDialog from '../dialogs/fuel/EditFuelDialog.vue'
import { useVehicleData } from '../../composables/useVehicleData.js'
import { filterRecordsByTimeRange } from '../../utils/calculations.js'
const props = defineProps({
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 pageSize = ref(15)
const showEditDialog = ref(false)
const selectedRecord = ref(null)
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 now = new Date()
@ -116,41 +125,25 @@ const getOneYearAgo = () => {
const getFilteredMonths = () => {
//
const monthlyData = {}
allRecords.value.forEach(r => {
filteredRecords.value.forEach(r => {
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()
//
if (fuelTimeRange.value === 'past_year') {
const oneYearAgo = getOneYearAgo()
const cutoffMonth = `${oneYearAgo.getFullYear()}-${String(oneYearAgo.getMonth() + 1).padStart(2, '0')}`
sortedMonths = sortedMonths.filter(m => m >= cutoffMonth)
}
return { sortedMonths, monthlyData }
}
// - X
const consumptionChartOption = computed(() => {
if (!allRecords.value.length) return null
const records = filteredRecords.value.filter(r => r.fuel_consumption)
//
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
if (records.length === 0) return null
// 线
const fuelConsumptionData = filteredRecords
const fuelConsumptionData = records
.sort((a, b) => a.mileage - b.mileage)
.map(r => ({
value: [r.mileage, r.fuel_consumption],
@ -159,8 +152,6 @@ const consumptionChartOption = computed(() => {
fuelAmount: r.fuel_amount
}))
if (fuelConsumptionData.length === 0) return null
// X
const mileages = fuelConsumptionData.map(d => d.value[0])
const minMileage = Math.min(...mileages)
@ -197,9 +188,7 @@ const consumptionChartOption = computed(() => {
min: Math.floor(minMileage - padding),
max: Math.ceil(maxMileage + padding),
nameTextStyle: { fontSize: 9 },
axisLabel: {
fontSize: 9
}
axisLabel: { fontSize: 9 }
},
yAxis: {
type: 'value',
@ -253,7 +242,7 @@ const consumptionChartOption = computed(() => {
//
const monthlyCostChartOption = computed(() => {
if (!allRecords.value.length) return null
if (!filteredRecords.value.length) return null
const { sortedMonths, monthlyData } = getFilteredMonths()
if (sortedMonths.length < 1) return null
@ -303,33 +292,16 @@ const editRecord = (record) => {
showEditDialog.value = true
}
const loadRecords = async () => {
if (!props.vehicleId) return
loading.value = true
try {
const res = await axios.get(`${API_BASE}/fuel-records/list?vehicle_id=${props.vehicleId}&limit=1000`)
allRecords.value = res.data || []
total.value = allRecords.value.length
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
records.value = allRecords.value.slice(start, end)
} catch (error) {
ElMessage.error('加载加油记录失败')
} finally {
loading.value = false
const onRecordUpdated = async () => {
if (props.vehicleId) {
await refreshVehicleData(props.vehicleId)
}
}
//
watch(() => props.vehicleId, () => {
currentPage.value = 1
loadRecords()
}, { immediate: true })
onMounted(() => {
loadRecords()
})
</script>
<style scoped>

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 { GridComponent, TooltipComponent, LegendComponent, TitleComponent, GraphicComponent, MarkPointComponent, MarkLineComponent } from 'echarts/components'
import App from './App.vue'
import router from './router.js'
// 注册 ECharts 组件
use([CanvasRenderer, LineChart, BarChart, PieChart, ScatterChart, GridComponent, TooltipComponent, LegendComponent, TitleComponent, GraphicComponent, MarkPointComponent, MarkLineComponent])
const app = createApp(App)
app.use(ElementPlus)
app.use(router)
app.component('v-chart', VueECharts)
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>