705 lines
16 KiB
Vue
705 lines
16 KiB
Vue
<template>
|
||
<div class="page">
|
||
<div class="page-header">
|
||
<div class="header-row">
|
||
<button class="btn-back" @click="$router.back()">
|
||
<span class="back-arrow">‹</span>
|
||
</button>
|
||
<div class="page-title">车辆管理</div>
|
||
<button class="btn-add-inline" @click="handleAdd">+ 新增</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="list-wrap">
|
||
<div v-if="loading" class="loading">
|
||
<div class="loading-ring"></div>
|
||
</div>
|
||
<div v-else-if="list.length === 0" class="empty">
|
||
<div class="empty-icon">🏍️</div>
|
||
<div class="empty-text">暂无车辆</div>
|
||
<div class="empty-sub">点击上方添加车辆</div>
|
||
</div>
|
||
<div v-else class="stats-bar">
|
||
<div class="stat-item" :class="{ active: currentTab === 'all' }" @click="currentTab = 'all'">
|
||
<span class="stat-num">{{ list.length }}</span>
|
||
<span class="stat-label">全部</span>
|
||
</div>
|
||
<div class="stat-divider"></div>
|
||
<div class="stat-item" :class="{ active: currentTab === 'idle' }" @click="currentTab = 'idle'">
|
||
<span class="stat-num idle">{{ idleCount }}</span>
|
||
<span class="stat-label">空闲</span>
|
||
</div>
|
||
<div class="stat-divider"></div>
|
||
<div class="stat-item" :class="{ active: currentTab === 'rented' }" @click="currentTab = 'rented'">
|
||
<span class="stat-num rented">{{ rentedCount }}</span>
|
||
<span class="stat-label">在租</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-for="item in filteredList" :key="item._id" class="vehicle-card">
|
||
<!-- 卡片头部:车牌 + 状态 -->
|
||
<div class="card-header">
|
||
<div class="plate-row">
|
||
<div class="plate-icon">🛵</div>
|
||
<div class="plate-number">{{ item.plateNumber || '未上牌' }}</div>
|
||
</div>
|
||
<div class="status-badge" :class="item.isRented ? 'badge-rented' : 'badge-idle'">
|
||
<span class="badge-dot"></span>
|
||
{{ item.isRented ? '在租' : '空闲' }}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 信息区域 -->
|
||
<div class="card-body">
|
||
<div class="info-row">
|
||
<div class="info-group">
|
||
<span class="info-label">车架号</span>
|
||
<span class="info-value">{{ item.frameNumber || '-' }}</span>
|
||
</div>
|
||
<div class="info-group">
|
||
<span class="info-label">品牌</span>
|
||
<span class="info-value">{{ item.brand || '-' }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="info-row">
|
||
<div class="info-group">
|
||
<span class="info-label">车型</span>
|
||
<span class="info-value">{{ item.vehicleType || '-' }}</span>
|
||
</div>
|
||
<div class="info-group">
|
||
<span class="info-label">颜色</span>
|
||
<span class="info-value">{{ item.color || '-' }}</span>
|
||
</div>
|
||
</div>
|
||
<div v-if="item.batteryType" class="info-row">
|
||
<div class="info-group full">
|
||
<span class="info-label">电池</span>
|
||
<span class="info-value">{{ item.batteryType }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 卡片底部操作栏 -->
|
||
<div class="card-footer">
|
||
<div class="footer-hint">最后更新:{{ formatTime(item.updatedAt) }}</div>
|
||
<div class="action-btns">
|
||
<button class="btn-action btn-edit" @click="handleEdit(item)">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||
</svg>
|
||
编辑
|
||
</button>
|
||
<button class="btn-action btn-del" @click="handleDelete(item._id)">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<polyline points="3 6 5 6 21 6"/>
|
||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
|
||
</svg>
|
||
删除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 添加/编辑弹窗 -->
|
||
<div v-if="showDialog" class="dialog-overlay" @click.self="closeDialog">
|
||
<div class="dialog">
|
||
<div class="dialog-handle"></div>
|
||
<div class="dialog-title">{{ editingId ? '编辑车辆' : '添加车辆' }}</div>
|
||
<div class="dialog-body">
|
||
<div class="form-item">
|
||
<label>车架号 <span class="required">*</span></label>
|
||
<input v-model="form.frameNumber" placeholder="请输入车架号" />
|
||
</div>
|
||
<div class="form-item">
|
||
<label>车牌号</label>
|
||
<input v-model="form.plateNumber" placeholder="如:京A12345" />
|
||
</div>
|
||
<div class="form-item">
|
||
<label>品牌</label>
|
||
<el-select v-model="form.brand" placeholder="请选择品牌" style="width:100%" @change="onBrandChange">
|
||
<el-option v-for="b in brands" :key="b" :label="b" :value="b" />
|
||
</el-select>
|
||
</div>
|
||
<div class="form-item">
|
||
<label>车型</label>
|
||
<el-select v-model="form.vehicleType" placeholder="请先选择品牌" style="width:100%" :disabled="!form.brand">
|
||
<el-option v-for="vt in filteredVehicleTypes" :key="vt._id" :label="vt.name" :value="vt.name" />
|
||
</el-select>
|
||
</div>
|
||
<div class="form-item">
|
||
<label>颜色</label>
|
||
<input v-model="form.color" placeholder="如:黑色、白色" />
|
||
</div>
|
||
<div class="form-item">
|
||
<label>电池类型</label>
|
||
<input v-model="form.batteryType" placeholder="如:铅酸电池、锂电池" />
|
||
</div>
|
||
</div>
|
||
<div class="dialog-footer">
|
||
<button class="btn-cancel" @click="closeDialog">取消</button>
|
||
<button class="btn-confirm" @click="handleSave">确定</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { vehiclesApi, vehicleTypesApi } from '../utils/api.js'
|
||
|
||
const list = ref([])
|
||
const loading = ref(true)
|
||
const showDialog = ref(false)
|
||
const editingId = ref(null)
|
||
const form = ref({ frameNumber: '', plateNumber: '', brand: '', vehicleType: '', color: '', batteryType: '' })
|
||
const vehicleTypes = ref([])
|
||
const currentTab = ref('all')
|
||
|
||
const idleCount = computed(() => list.value.filter(v => !v.isRented).length)
|
||
const rentedCount = computed(() => list.value.filter(v => v.isRented).length)
|
||
|
||
const filteredList = computed(() => {
|
||
if (currentTab.value === 'idle') return list.value.filter(v => !v.isRented)
|
||
if (currentTab.value === 'rented') return list.value.filter(v => v.isRented)
|
||
return list.value
|
||
})
|
||
|
||
const brands = computed(() => {
|
||
const set = new Set(vehicleTypes.value.map(vt => vt.brand).filter(Boolean))
|
||
return [...set]
|
||
})
|
||
|
||
const filteredVehicleTypes = computed(() => {
|
||
if (!form.value.brand) return []
|
||
return vehicleTypes.value.filter(vt => vt.brand === form.value.brand)
|
||
})
|
||
|
||
const onBrandChange = () => {
|
||
form.value.vehicleType = ''
|
||
}
|
||
|
||
const formatTime = (ts) => {
|
||
if (!ts) return '-'
|
||
const d = new Date(ts)
|
||
return `${d.getMonth()+1}/${d.getDate()} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`
|
||
}
|
||
|
||
const storeId = localStorage.getItem('storeId') || 'demo-store'
|
||
|
||
const loadData = async () => {
|
||
loading.value = true
|
||
try {
|
||
const res = await vehiclesApi.list({ storeId })
|
||
list.value = res.data.data || res.data || []
|
||
} catch (e) {
|
||
list.value = []
|
||
}
|
||
loading.value = false
|
||
}
|
||
|
||
const loadVehicleTypes = async () => {
|
||
try {
|
||
const res = await vehicleTypesApi.list({ storeId })
|
||
vehicleTypes.value = res.data.data || res.data || []
|
||
} catch (e) {
|
||
vehicleTypes.value = []
|
||
}
|
||
}
|
||
|
||
const handleAdd = () => {
|
||
editingId.value = null
|
||
form.value = { frameNumber: '', plateNumber: '', brand: '', vehicleType: '', color: '', batteryType: '' }
|
||
if (vehicleTypes.value.length === 0) loadVehicleTypes()
|
||
showDialog.value = true
|
||
}
|
||
|
||
const handleEdit = (item) => {
|
||
editingId.value = item._id
|
||
form.value = {
|
||
frameNumber: item.frameNumber || '',
|
||
plateNumber: item.plateNumber || '',
|
||
brand: '',
|
||
vehicleType: item.vehicleType || '',
|
||
color: item.color || '',
|
||
batteryType: item.batteryType || ''
|
||
}
|
||
const findBrand = (list) => {
|
||
const vt = list.find(v => v.name === item.vehicleType)
|
||
if (vt) form.value.brand = vt.brand
|
||
}
|
||
if (vehicleTypes.value.length === 0) {
|
||
loadVehicleTypes().then(findBrand)
|
||
} else {
|
||
findBrand(vehicleTypes.value)
|
||
}
|
||
showDialog.value = true
|
||
}
|
||
|
||
const handleSave = async () => {
|
||
if (!form.value.frameNumber) {
|
||
alert('请填写车架号')
|
||
return
|
||
}
|
||
try {
|
||
if (editingId.value) {
|
||
await vehiclesApi.update(editingId.value, form.value)
|
||
} else {
|
||
await vehiclesApi.create({ ...form.value, storeId })
|
||
}
|
||
closeDialog()
|
||
loadData()
|
||
} catch (e) {
|
||
alert(editingId.value ? '更新失败' : '添加失败')
|
||
}
|
||
}
|
||
|
||
const closeDialog = () => {
|
||
showDialog.value = false
|
||
editingId.value = null
|
||
form.value = { frameNumber: '', plateNumber: '', brand: '', vehicleType: '', color: '', batteryType: '' }
|
||
}
|
||
|
||
const handleDelete = async (id) => {
|
||
if (!confirm('确定删除该车辆?')) return
|
||
try {
|
||
await vehiclesApi.delete(id)
|
||
loadData()
|
||
} catch (e) {
|
||
alert('删除失败')
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadData()
|
||
loadVehicleTypes()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.page {
|
||
min-height: 100vh;
|
||
background: #F7F7F7;
|
||
}
|
||
|
||
.page-header {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 10;
|
||
background: #fff;
|
||
padding: 14px 16px 12px;
|
||
border-bottom: 0.5px solid rgba(0,0,0,0.06);
|
||
}
|
||
|
||
.header-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.btn-back {
|
||
width: 34px;
|
||
height: 34px;
|
||
border-radius: 50%;
|
||
background: #F7F7F7;
|
||
border: none;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.back-arrow {
|
||
font-size: 22px;
|
||
color: #1A1A1A;
|
||
line-height: 1;
|
||
margin-top: -2px;
|
||
}
|
||
|
||
.page-title {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #1A1A1A;
|
||
flex: 1;
|
||
text-align: center;
|
||
}
|
||
|
||
.btn-add-inline {
|
||
background: #FF6B00;
|
||
color: #fff;
|
||
border: none;
|
||
border-radius: 16px;
|
||
padding: 6px 14px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.list-wrap {
|
||
padding: 12px;
|
||
}
|
||
|
||
/* 统计栏 */
|
||
.stats-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
background: #fff;
|
||
border-radius: 14px;
|
||
padding: 12px 20px;
|
||
margin-bottom: 12px;
|
||
gap: 0;
|
||
}
|
||
|
||
.stat-item {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 2px;
|
||
cursor: pointer;
|
||
padding: 4px 0;
|
||
border-radius: 8px;
|
||
transition: background 0.2s;
|
||
}
|
||
|
||
.stat-item.active {
|
||
background: #FFF7F0;
|
||
}
|
||
|
||
.stat-item:active {
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.stat-num {
|
||
font-size: 22px;
|
||
font-weight: 700;
|
||
color: #1A1A1A;
|
||
line-height: 1;
|
||
}
|
||
|
||
.stat-num.idle {
|
||
color: #52C41A;
|
||
}
|
||
|
||
.stat-num.rented {
|
||
color: #FF6B00;
|
||
}
|
||
|
||
.stat-label {
|
||
font-size: 12px;
|
||
color: #8E8E93;
|
||
}
|
||
|
||
.stat-divider {
|
||
width: 1px;
|
||
height: 32px;
|
||
background: #F0F0F0;
|
||
}
|
||
|
||
.loading {
|
||
display: flex;
|
||
justify-content: center;
|
||
padding: 60px;
|
||
}
|
||
|
||
.loading-ring {
|
||
width: 32px;
|
||
height: 32px;
|
||
border: 3px solid #E5E5E5;
|
||
border-top-color: #FF6B00;
|
||
border-radius: 50%;
|
||
animation: spin 0.8s linear infinite;
|
||
}
|
||
|
||
@keyframes spin {
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
.empty {
|
||
text-align: center;
|
||
padding: 60px 40px;
|
||
}
|
||
|
||
.empty-icon {
|
||
font-size: 48px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.empty-text {
|
||
font-size: 15px;
|
||
color: #1A1A1A;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.empty-sub {
|
||
font-size: 13px;
|
||
color: #B2B2B2;
|
||
margin-top: 6px;
|
||
}
|
||
|
||
/* 车辆卡片 */
|
||
.vehicle-card {
|
||
background: #fff;
|
||
border-radius: 16px;
|
||
margin-bottom: 10px;
|
||
overflow: hidden;
|
||
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
|
||
}
|
||
|
||
/* 卡片头部 */
|
||
.card-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 14px 16px 12px;
|
||
border-bottom: 1px solid #F5F5F5;
|
||
}
|
||
|
||
.plate-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.plate-icon {
|
||
font-size: 20px;
|
||
}
|
||
|
||
.plate-number {
|
||
font-size: 17px;
|
||
font-weight: 700;
|
||
color: #1A1A1A;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.status-badge {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
padding: 4px 12px;
|
||
border-radius: 20px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.badge-dot {
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.badge-idle {
|
||
background: #F0FFF4;
|
||
color: #52C41A;
|
||
}
|
||
|
||
.badge-idle .badge-dot {
|
||
background: #52C41A;
|
||
}
|
||
|
||
.badge-rented {
|
||
background: #FFF7F0;
|
||
color: #FF6B00;
|
||
}
|
||
|
||
.badge-rented .badge-dot {
|
||
background: #FF6B00;
|
||
animation: pulse 1.5s infinite;
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.4; }
|
||
}
|
||
|
||
/* 卡片信息区 */
|
||
.card-body {
|
||
padding: 10px 16px;
|
||
}
|
||
|
||
.info-row {
|
||
display: flex;
|
||
gap: 12px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.info-row:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.info-group {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.info-group.full {
|
||
flex: none;
|
||
width: 100%;
|
||
}
|
||
|
||
.info-label {
|
||
font-size: 11px;
|
||
color: #B2B2B2;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.3px;
|
||
}
|
||
|
||
.info-value {
|
||
font-size: 13px;
|
||
color: #1A1A1A;
|
||
font-weight: 500;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
/* 卡片底部 */
|
||
.card-footer {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 10px 12px;
|
||
background: #FAFAFA;
|
||
border-top: 1px solid #F5F5F5;
|
||
}
|
||
|
||
.footer-hint {
|
||
font-size: 11px;
|
||
color: #B2B2B2;
|
||
}
|
||
|
||
.action-btns {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.btn-action {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
padding: 6px 14px;
|
||
border-radius: 16px;
|
||
border: none;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: opacity 0.2s, transform 0.15s;
|
||
}
|
||
|
||
.btn-action:active {
|
||
transform: scale(0.96);
|
||
opacity: 0.8;
|
||
}
|
||
|
||
.btn-edit {
|
||
background: #FFF7F0;
|
||
color: #FF6B00;
|
||
}
|
||
|
||
.btn-del {
|
||
background: #FFF0F0;
|
||
color: #FF4D4F;
|
||
}
|
||
|
||
/* 弹窗 */
|
||
.dialog-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0,0,0,0.4);
|
||
display: flex;
|
||
align-items: flex-end;
|
||
z-index: 200;
|
||
}
|
||
|
||
.dialog {
|
||
background: #fff;
|
||
width: 100%;
|
||
border-radius: 20px 20px 0 0;
|
||
padding-bottom: calc(16px + env(safe-area-inset-bottom));
|
||
}
|
||
|
||
.dialog-handle {
|
||
width: 36px;
|
||
height: 4px;
|
||
background: #DDD;
|
||
border-radius: 2px;
|
||
margin: 10px auto 0;
|
||
}
|
||
|
||
.dialog-title {
|
||
text-align: center;
|
||
font-size: 17px;
|
||
font-weight: 600;
|
||
color: #1A1A1A;
|
||
padding: 18px 16px 14px;
|
||
}
|
||
|
||
.dialog-body {
|
||
padding: 0 16px 16px;
|
||
}
|
||
|
||
.form-item {
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.form-item label {
|
||
display: block;
|
||
font-size: 13px;
|
||
color: #8E8E93;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.required {
|
||
color: #FF4D4F;
|
||
}
|
||
|
||
.form-item input {
|
||
width: 100%;
|
||
padding: 12px 14px;
|
||
background: #F7F7F7;
|
||
border: none;
|
||
border-radius: 10px;
|
||
font-size: 15px;
|
||
color: #1A1A1A;
|
||
outline: none;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.form-item input::placeholder {
|
||
color: #B2B2B2;
|
||
}
|
||
|
||
.dialog-footer {
|
||
display: flex;
|
||
gap: 12px;
|
||
padding: 0 16px;
|
||
}
|
||
|
||
.btn-cancel, .btn-confirm {
|
||
flex: 1;
|
||
padding: 13px;
|
||
border-radius: 24px;
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
border: none;
|
||
}
|
||
|
||
.btn-cancel {
|
||
background: #F0F0F0;
|
||
color: #666;
|
||
}
|
||
|
||
.btn-confirm {
|
||
background: #FF6B00;
|
||
color: #fff;
|
||
}
|
||
</style>
|