e-scooter-store-web/src/views/Vehicles.vue

705 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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