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:
parent
10890e515f
commit
71e11eaf30
2
.gitignore
vendored
2
.gitignore
vendored
@ -300,3 +300,5 @@ dist
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
|
||||
.ruff_cache/
|
||||
189
AGENTS.md
189
AGENTS.md
@ -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
14
api/Dockerfile
Normal 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
184
api/auth.py
Normal 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
|
||||
@ -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
11
api/docker-compose.yml
Normal 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
|
||||
22
api/main.py
22
api/main.py
@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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="")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
)
|
||||
@ -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()
|
||||
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,
|
||||
)
|
||||
return [get_record_with_consumption(session, r) for r in records]
|
||||
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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
86
deploy-web.sh
Executable 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
474
web/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
432
web/src/App.vue
432
web/src/App.vue
@ -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
16
web/src/api.js
Normal 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
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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") {
|
||||
// 近6个月:从当前月往前推5个月(共6个月)
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
targetMonths.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`);
|
||||
}
|
||||
} else if (timeRange.value === "past_year") {
|
||||
// 近一年:从当前月往前推11个月(共12个月)
|
||||
for (let i = 11; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
targetMonths.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`);
|
||||
}
|
||||
}
|
||||
// "全部"模式下不过滤月份
|
||||
|
||||
const typeData = {};
|
||||
let totalAmount = 0;
|
||||
|
||||
// 从分摊明细表中筛选数据
|
||||
costInstallments.value.forEach((item) => {
|
||||
if (timeRange.value === "all" || targetMonths.includes(item.month)) {
|
||||
typeData[item.type] = (typeData[item.type] || 0) + item.amount;
|
||||
totalAmount += item.amount;
|
||||
}
|
||||
});
|
||||
|
||||
let totalFuelCost = 0;
|
||||
filteredFuel.forEach((r) => {
|
||||
totalFuelCost += r.total_cost;
|
||||
});
|
||||
if (totalFuelCost > 0) {
|
||||
typeData["油费"] = totalFuelCost;
|
||||
totalAmount += totalFuelCost;
|
||||
}
|
||||
|
||||
const data = Object.entries(typeData)
|
||||
.map(([name, value]) => ({ name, value: parseFloat(value.toFixed(0)) }))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
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") {
|
||||
// 近6个月:从当前月往前推5个月(共6个月)
|
||||
const cutoffDate = new Date(now.getFullYear(), now.getMonth() - 5, 1);
|
||||
const cutoffMonth = `${cutoffDate.getFullYear()}-${String(cutoffDate.getMonth() + 1).padStart(2, "0")}`;
|
||||
targetMonths = targetMonths.filter((m) => m >= cutoffMonth && m <= currentMonth);
|
||||
} else if (timeRange.value === "past_year") {
|
||||
// 近一年:从当前月往前推11个月(共12个月)
|
||||
const cutoffDate = new Date(now.getFullYear(), now.getMonth() - 11, 1);
|
||||
const cutoffMonth = `${cutoffDate.getFullYear()}-${String(cutoffDate.getMonth() + 1).padStart(2, "0")}`;
|
||||
targetMonths = targetMonths.filter((m) => m >= cutoffMonth && m <= currentMonth);
|
||||
// 根据时间范围筛选月份
|
||||
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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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('删除成功')
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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('删除成功')
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
})
|
||||
// 计算月度统计
|
||||
const monthlyStats = computed(() => {
|
||||
return calculateMonthlyStats(fuelRecords.value, filteredCostInstallments.value);
|
||||
});
|
||||
|
||||
// 获取时间范围对应的月份列表
|
||||
const targetMonths = computed(() => {
|
||||
if (costTimeRange.value === 'past_year') {
|
||||
return getRecentMonths(isMobile.value ? 6 : 12);
|
||||
}
|
||||
} else {
|
||||
// 一次性费用,生成一条记录
|
||||
installments.push({
|
||||
month: cost.date.slice(0, 7),
|
||||
type: cost.type,
|
||||
amount: cost.amount,
|
||||
originalCostId: cost.id
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return installments
|
||||
})
|
||||
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') {
|
||||
// 近6个月:从当前月往前推5个月(共6个月)
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
targetMonths.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`);
|
||||
}
|
||||
} else if (costTimeRange.value === 'past_year') {
|
||||
// 近一年:从当前月往前推11个月(共12个月)
|
||||
for (let i = 11; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
targetMonths.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`);
|
||||
}
|
||||
}
|
||||
// "全部"模式下不过滤月份
|
||||
|
||||
// 筛选油费记录
|
||||
let filteredFuelRecords = fuelRecords.value;
|
||||
if (costTimeRange.value !== 'all') {
|
||||
const cutoffDate = getMonthsAgo(costTimeRange.value === 'past_6months' ? 6 : 12).toISOString().slice(0, 10);
|
||||
filteredFuelRecords = fuelRecords.value.filter(r => r.date >= cutoffDate);
|
||||
}
|
||||
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,16 +353,22 @@ const monthlyChartOption = computed(() => {
|
||||
axisTick: { show: false }
|
||||
},
|
||||
yAxis: { type: "value", show: false },
|
||||
series: existingTypes.map((type, index) => ({
|
||||
series: [
|
||||
...existingTypes.map((type, index) => ({
|
||||
name: type,
|
||||
type: "bar",
|
||||
stack: "total",
|
||||
data: sortedMonths.map((month) => parseFloat(monthlyData[month][type].toFixed(0))),
|
||||
data: sortedMonths.map((month) => parseFloat((monthlyData[month]?.[type] || 0).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 ? {
|
||||
color: COST_TYPE_COLORS[type] || "#909399"
|
||||
}
|
||||
})),
|
||||
{
|
||||
name: "总计",
|
||||
type: "bar",
|
||||
stack: "total",
|
||||
data: sortedMonths.map(() => 0),
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
fontSize: 9,
|
||||
@ -442,13 +376,19 @@ const monthlyChartOption = computed(() => {
|
||||
return monthlyTotals[params.dataIndex];
|
||||
},
|
||||
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>
|
||||
|
||||
@ -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>
|
||||
|
||||
35
web/src/composables/useAuth.js
Normal file
35
web/src/composables/useAuth.js
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
147
web/src/composables/useVehicleData.js
Normal file
147
web/src/composables/useVehicleData.js
Normal 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 }
|
||||
15
web/src/constants/index.js
Normal file
15
web/src/constants/index.js
Normal file
@ -0,0 +1,15 @@
|
||||
// 费用类型常量
|
||||
export const COST_TYPES = ['保养', '维修', '保险', '停车', '过路费', '洗车', '违章', '其他']
|
||||
|
||||
// 费用类型颜色映射
|
||||
export const COST_TYPE_COLORS = {
|
||||
保养: '#67c23a',
|
||||
维修: '#e6a23c',
|
||||
保险: '#f56c6c',
|
||||
停车: '#909399',
|
||||
过路费: '#9a60b4',
|
||||
洗车: '#409eff',
|
||||
违章: '#ff6b6b',
|
||||
其他: '#fc8452',
|
||||
油费: '#409eff'
|
||||
}
|
||||
@ -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
65
web/src/router.js
Normal 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;
|
||||
357
web/src/utils/calculations.js
Normal file
357
web/src/utils/calculations.js
Normal 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
405
web/src/views/Main.vue
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user