merge
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import http from '@/api/http'
|
||||
|
||||
export interface QuickEntriesDto {
|
||||
keys: string[]
|
||||
usingDefaults: boolean
|
||||
}
|
||||
|
||||
const MOCK_STORAGE_KEY = 'simple.mock.dashboard.quickEntries'
|
||||
|
||||
function mockStorageKey(scope: string, userId: string) {
|
||||
return `${MOCK_STORAGE_KEY}.${scope}.${userId}`
|
||||
}
|
||||
|
||||
function isMock() {
|
||||
return import.meta.env.VITE_USE_MOCK === 'true'
|
||||
}
|
||||
|
||||
function mockLoad(scope: string, userId: string): QuickEntriesDto | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(mockStorageKey(scope, userId))
|
||||
return raw ? (JSON.parse(raw) as QuickEntriesDto) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function mockSave(scope: string, userId: string, dto: QuickEntriesDto) {
|
||||
try {
|
||||
localStorage.setItem(mockStorageKey(scope, userId), JSON.stringify(dto))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export async function fetchQuickEntryKeys(userId: string, scope: string): Promise<QuickEntriesDto> {
|
||||
if (isMock()) {
|
||||
return mockLoad(scope, userId) ?? { keys: [], usingDefaults: true }
|
||||
}
|
||||
const { data } = await http.get<QuickEntriesDto>('/dashboard/quick-entries')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function saveQuickEntryKeys(
|
||||
userId: string, scope: string, keys: string[]
|
||||
): Promise<QuickEntriesDto> {
|
||||
if (isMock()) {
|
||||
const dto: QuickEntriesDto = { keys, usingDefaults: false }
|
||||
mockSave(scope, userId, dto)
|
||||
return dto
|
||||
}
|
||||
const { data } = await http.put<QuickEntriesDto>('/dashboard/quick-entries', { keys })
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import http from './http'
|
||||
import type { SimpleFieldBatchPayload, SimpleFieldPayload, SimpleFieldRecord } from '@/types/simpleField'
|
||||
|
||||
function q(params?: Record<string, unknown>) {
|
||||
return { params }
|
||||
}
|
||||
|
||||
export async function listSimpleFields(fieldType?: string, carType?: string, keyword?: string) {
|
||||
const { data } = await http.get<SimpleFieldRecord[]>('/simple-fields', q({
|
||||
fieldType,
|
||||
carType,
|
||||
q: keyword
|
||||
}))
|
||||
return data
|
||||
}
|
||||
|
||||
export async function saveSimpleField(payload: SimpleFieldPayload) {
|
||||
const { data } = payload.id
|
||||
? await http.put<SimpleFieldRecord>(`/simple-fields/${payload.id}`, payload)
|
||||
: await http.post<SimpleFieldRecord>('/simple-fields', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteSimpleField(id: string) {
|
||||
await http.delete(`/simple-fields/${id}`)
|
||||
}
|
||||
|
||||
export async function saveSimpleFieldsBatch(payload: SimpleFieldBatchPayload) {
|
||||
const { data } = await http.post<{ count: number }>('/simple-fields/batch', payload)
|
||||
return data
|
||||
}
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="添加快捷入口"
|
||||
width="960px"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
class="quick-entry-dialog"
|
||||
@closed="emit('closed')">
|
||||
<p class="qed-desc">
|
||||
从下方选择常用菜单页,固定到总览快捷入口(总览最多 {{ maxCount }} 个,已固定 {{ pinnedCount }} 个)
|
||||
</p>
|
||||
|
||||
<div v-if="!items.length" class="qed-empty">暂无可添加的菜单页</div>
|
||||
|
||||
<div v-else class="qed-grid">
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="qed-chip"
|
||||
:class="{ 'is-pinned': isPinned(item.key), 'is-disabled': isDisabled(item.key) }"
|
||||
:title="chipTitle(item)"
|
||||
:disabled="isDisabled(item.key)"
|
||||
@click="onPick(item.key)">
|
||||
<span class="qed-chip-icon">
|
||||
<el-icon :size="22"><component :is="item.icon" /></el-icon>
|
||||
</span>
|
||||
<span class="qed-chip-label">{{ item.label }}</span>
|
||||
<span v-if="isPinned(item.key)" class="qed-chip-badge">已固定</span>
|
||||
</button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { MAX_QUICK_ENTRIES, type QuickEntryDef } from '@/config/quickEntries'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
items: QuickEntryDef[]
|
||||
pinnedKeys?: string[]
|
||||
canAdd?: boolean
|
||||
maxCount?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [boolean]
|
||||
pick: [key: string]
|
||||
closed: []
|
||||
}>()
|
||||
|
||||
const maxCount = computed(() => props.maxCount ?? MAX_QUICK_ENTRIES)
|
||||
const pinnedSet = computed(() => new Set(props.pinnedKeys ?? []))
|
||||
const pinnedCount = computed(() => pinnedSet.value.size)
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
function isPinned(key: string) {
|
||||
return pinnedSet.value.has(key)
|
||||
}
|
||||
|
||||
function isDisabled(key: string) {
|
||||
return isPinned(key)
|
||||
}
|
||||
|
||||
function chipTitle(item: QuickEntryDef) {
|
||||
if (isPinned(item.key)) return `${item.label}(已在快捷入口中)`
|
||||
if (props.canAdd === false) return `${item.label}(快捷入口已满,请先移除再添加)`
|
||||
return item.hint ?? item.label
|
||||
}
|
||||
|
||||
function onPick(key: string) {
|
||||
if (isPinned(key)) return
|
||||
if (props.canAdd === false) {
|
||||
ElMessage.warning(`快捷入口已满(最多 ${maxCount.value} 个),请先移除已有项`)
|
||||
return
|
||||
}
|
||||
emit('pick', key)
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.qed-desc {
|
||||
margin: 0 0 16px;
|
||||
font-size: 13px;
|
||||
color: var(--qed-text-muted, rgba(45, 27, 105, 0.72));
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.qed-empty {
|
||||
padding: 32px 0;
|
||||
text-align: center;
|
||||
color: var(--qed-text-muted, rgba(45, 27, 105, 0.55));
|
||||
font-size: 13px;
|
||||
}
|
||||
.qed-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
gap: 14px 12px;
|
||||
}
|
||||
.qed-chip {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid rgba(var(--mg-accent-rgb), 0.35);
|
||||
border-radius: 14px;
|
||||
padding: 14px 8px 12px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--qed-text, #2d1b69);
|
||||
transition: all .22s cubic-bezier(.25, .8, .25, 1);
|
||||
position: relative;
|
||||
}
|
||||
.qed-chip:hover:not(:disabled) {
|
||||
border-color: rgba(var(--mg-accent-rgb), 0.85);
|
||||
background: #fff;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(var(--mg-primary-rgb), 0.22);
|
||||
}
|
||||
.qed-chip.is-pinned,
|
||||
.qed-chip.is-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
background: rgba(45, 27, 105, 0.04);
|
||||
}
|
||||
.qed-chip-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg,
|
||||
rgba(var(--mg-primary-hover-rgb), 0.55) 0%,
|
||||
rgba(var(--mg-primary-rgb), 0.42) 100%);
|
||||
border: 1px solid rgba(var(--mg-accent-rgb), 0.45);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
box-shadow:
|
||||
0 6px 16px rgba(var(--mg-primary-rgb), 0.28),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.18) inset;
|
||||
}
|
||||
.qed-chip-label {
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
text-align: center;
|
||||
color: var(--qed-text, #2d1b69);
|
||||
letter-spacing: 0.6px;
|
||||
word-break: keep-all;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
.qed-chip-badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
font-size: 9px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 6px;
|
||||
background: rgba(var(--mg-primary-rgb), 0.12);
|
||||
color: rgba(45, 27, 105, 0.72);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
@media (max-width: 960px) {
|
||||
.qed-grid { grid-template-columns: repeat(4, 1fr); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* el-dialog teleport 到 body,需非 scoped;兼容 fame-lavender 白底弹窗 */
|
||||
.quick-entry-dialog.el-dialog,
|
||||
.quick-entry-dialog .el-dialog {
|
||||
--qed-text: #2d1b69;
|
||||
--qed-text-muted: rgba(45, 27, 105, 0.72);
|
||||
background: #fff !important;
|
||||
border: 1px solid rgba(var(--mg-accent-rgb), 0.28);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 24px 48px rgba(45, 27, 105, 0.18);
|
||||
}
|
||||
.quick-entry-dialog .el-dialog__header {
|
||||
border-bottom: 1px solid rgba(var(--mg-accent-rgb), 0.14);
|
||||
margin-right: 0;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
.quick-entry-dialog .el-dialog__title {
|
||||
color: #2d1b69 !important;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.quick-entry-dialog .el-dialog__headerbtn .el-dialog__close {
|
||||
color: rgba(45, 27, 105, 0.55);
|
||||
}
|
||||
.quick-entry-dialog .el-dialog__body {
|
||||
color: #2d1b69;
|
||||
padding-top: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,287 @@
|
||||
<template>
|
||||
<div
|
||||
class="v-row"
|
||||
:class="rowClass"
|
||||
@click="onRowClick"
|
||||
@dblclick="onRowDblClick"
|
||||
>
|
||||
<span class="v-row__accent" :class="accentTone" />
|
||||
|
||||
<div class="v-row__main">
|
||||
<span class="live-dot" :class="accentTone" />
|
||||
<div class="v-row__identity">
|
||||
<span class="name" :title="vehicle.name">{{ vehicle.name }}</span>
|
||||
<span class="id">{{ vehicle.id }}{{ missionIdSuffix }}</span>
|
||||
</div>
|
||||
|
||||
<span class="mg-pill v-row__status" :class="statusPillClass">{{ statusDisplayLabel }}</span>
|
||||
|
||||
<div class="v-row__battery" :class="batteryTone" :title="`电量 ${batteryPct}%`">
|
||||
<div class="bat-track"><div class="bat-fill" :style="{ width: `${batteryPct}%` }" /></div>
|
||||
<span class="bat-pct">{{ batteryPct }}%</span>
|
||||
</div>
|
||||
|
||||
<span class="v-row__meta mono" :title="vehicle.ip ?? ''">{{ vehicle.ip ?? '—' }}</span>
|
||||
<span class="v-row__meta" :class="latencyClass">{{ latencyLabel }}</span>
|
||||
<span class="v-row__meta" :class="faultClass">{{ faultLabel }}</span>
|
||||
<span class="v-row__meta muted">{{ vehicle.group ?? '—' }}</span>
|
||||
|
||||
<div class="v-row__flags">
|
||||
<span v-if="vehicle.isAlarmActive" class="flag bad">报警</span>
|
||||
<span v-if="vehicle.reachable === false" class="flag bad">不可达</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="v-row__tools" @click.stop>
|
||||
<VehicleMaintenanceSelect
|
||||
:vehicle="vehicle"
|
||||
:can-write="canWrite"
|
||||
@changed="emit('maintenanceChanged')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { VehicleCardModel } from '@/types/car'
|
||||
import { openOnboardWeb } from '@/api/vehicleOps'
|
||||
import {
|
||||
useVehicleCardState,
|
||||
type VehicleCardStateOptions
|
||||
} from '@/composables/useVehicleCardState'
|
||||
import VehicleMaintenanceSelect from '@/components/fleet/VehicleMaintenanceSelect.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
vehicle: VehicleCardModel
|
||||
selected?: boolean
|
||||
canWrite?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [id: string]
|
||||
maintenanceChanged: []
|
||||
}>()
|
||||
|
||||
const stateOpts: VehicleCardStateOptions = { vehicle: () => props.vehicle }
|
||||
|
||||
const {
|
||||
stateLabel,
|
||||
batteryPct,
|
||||
batteryTone,
|
||||
statusPill,
|
||||
missionIdSuffix,
|
||||
statusDisplayLabel,
|
||||
accentTone,
|
||||
latencyLabel,
|
||||
latencyClass,
|
||||
faultLabel,
|
||||
faultClass
|
||||
} = useVehicleCardState(stateOpts)
|
||||
|
||||
const rowClass = computed(() => ({
|
||||
'is-selected': props.selected,
|
||||
'is-alarm': props.vehicle.isAlarmActive,
|
||||
'is-unreachable': props.vehicle.reachable === false,
|
||||
[accentTone.value]: true
|
||||
}))
|
||||
|
||||
const statusPillClass = computed(() => {
|
||||
switch (accentTone.value) {
|
||||
case 'tone-success':
|
||||
return 'is-success'
|
||||
case 'tone-danger':
|
||||
return 'is-danger'
|
||||
case 'tone-warning':
|
||||
return 'is-warning'
|
||||
case 'tone-info':
|
||||
return 'is-info'
|
||||
default:
|
||||
return 'is-idle'
|
||||
}
|
||||
})
|
||||
|
||||
function onRowClick() {
|
||||
emit('select', props.vehicle.id)
|
||||
}
|
||||
|
||||
function onRowDblClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('.v-row__tools')) return
|
||||
openOnboardWeb(props.vehicle.onboardUrl, props.vehicle.ip)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 52px;
|
||||
padding: 8px 12px 8px 14px;
|
||||
border-radius: 12px;
|
||||
background: rgba(var(--mg-bg-card-rgb, 38, 24, 78), 0.55);
|
||||
border: 1px solid rgba(var(--mg-accent-rgb, 196, 181, 253), 0.18);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.v-row:hover {
|
||||
background: rgba(var(--mg-bg-card-hi-rgb, 58, 38, 110), 0.65);
|
||||
border-color: rgba(var(--mg-accent-rgb, 196, 181, 253), 0.38);
|
||||
}
|
||||
|
||||
.v-row.is-selected {
|
||||
border-color: rgba(var(--mg-primary-hover-rgb, 139, 92, 246), 0.75);
|
||||
box-shadow: 0 0 0 1px rgba(var(--mg-primary-hover-rgb, 139, 92, 246), 0.4);
|
||||
}
|
||||
|
||||
.v-row__accent {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 3px;
|
||||
border-radius: 0 3px 3px 0;
|
||||
background: rgba(var(--mg-accent-rgb, 196, 181, 253), 0.5);
|
||||
}
|
||||
|
||||
.v-row__accent.tone-success { background: var(--mg-status-success, #22c55e); }
|
||||
.v-row__accent.tone-warning { background: var(--mg-status-warning, #f59e0b); }
|
||||
.v-row__accent.tone-danger { background: var(--mg-status-danger, #ef4444); }
|
||||
.v-row__accent.tone-info { background: var(--mg-status-info, #3b82f6); }
|
||||
|
||||
.v-row__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.live-dot.tone-success { background: var(--mg-status-success, #22c55e); }
|
||||
.live-dot.tone-danger { background: var(--mg-status-danger, #ef4444); }
|
||||
.live-dot.tone-warning { background: var(--mg-status-warning, #f59e0b); }
|
||||
|
||||
.v-row__identity {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 100px;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
color: var(--mg-text-light, #fff);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.id {
|
||||
font-size: 11px;
|
||||
color: var(--mg-text-muted, rgba(255, 255, 255, 0.5));
|
||||
font-family: var(--mg-font-mono, ui-monospace, monospace);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.v-row__status {
|
||||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.v-row__battery {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 72px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bat-track {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bat-fill {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--mg-status-success, #22c55e);
|
||||
}
|
||||
|
||||
.v-row__battery.tone-warning .bat-fill { background: var(--mg-status-warning, #f59e0b); }
|
||||
.v-row__battery.tone-danger .bat-fill { background: var(--mg-status-danger, #ef4444); }
|
||||
|
||||
.bat-pct {
|
||||
font-size: 10px;
|
||||
font-family: var(--mg-font-mono, ui-monospace, monospace);
|
||||
color: var(--mg-text-muted, rgba(255, 255, 255, 0.65));
|
||||
min-width: 28px;
|
||||
}
|
||||
|
||||
.v-row__meta {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--mg-text-light, #fff);
|
||||
min-width: 48px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.v-row__meta.mono {
|
||||
font-family: var(--mg-font-mono, ui-monospace, monospace);
|
||||
font-weight: 500;
|
||||
min-width: 88px;
|
||||
}
|
||||
|
||||
.v-row__meta.muted {
|
||||
color: var(--mg-text-muted, rgba(255, 255, 255, 0.55));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.v-row__meta.val-danger { color: var(--mg-status-danger, #ef4444); }
|
||||
.v-row__meta.val-warn { color: var(--mg-status-warning, #f59e0b); }
|
||||
|
||||
.v-row__flags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.flag {
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.flag.bad {
|
||||
color: var(--mg-status-danger, #b91c1c);
|
||||
background: rgba(var(--mg-status-danger-rgb, 185, 28, 28), 0.12);
|
||||
border: 1px solid rgba(var(--mg-status-danger-rgb, 185, 28, 28), 0.28);
|
||||
}
|
||||
|
||||
.v-row__tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.v-row__meta.muted,
|
||||
.v-row__flags { display: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<el-select
|
||||
:model-value="currentMode"
|
||||
size="small"
|
||||
class="veh-maint-select"
|
||||
:class="toneClass"
|
||||
:disabled="!canWrite"
|
||||
:teleported="true"
|
||||
@change="onChange"
|
||||
@click.stop
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in MAINTENANCE_OPTIONS"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { VehicleCardModel } from '@/types/car'
|
||||
import type { VehicleMaintenanceMode } from '@/api/vehicleOps'
|
||||
import {
|
||||
MAINTENANCE_OPTIONS,
|
||||
confirmAndApplyMaintenance
|
||||
} from '@/composables/useVehicleMaintenanceActions'
|
||||
|
||||
const props = defineProps<{
|
||||
vehicle: VehicleCardModel
|
||||
canWrite?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
|
||||
const currentMode = ref<VehicleMaintenanceMode>(props.vehicle.maintenanceMode ?? 'online')
|
||||
|
||||
watch(
|
||||
() => props.vehicle.maintenanceMode,
|
||||
(m) => {
|
||||
currentMode.value = m ?? 'online'
|
||||
}
|
||||
)
|
||||
|
||||
const toneClass = computed(() => {
|
||||
const m = currentMode.value
|
||||
if (m === 'blown' || m === 'offline') return 'tone-danger'
|
||||
if (m === 'repair') return 'tone-warning'
|
||||
return 'tone-online'
|
||||
})
|
||||
|
||||
async function onChange(mode: VehicleMaintenanceMode) {
|
||||
const prev = props.vehicle.maintenanceMode ?? 'online'
|
||||
if (mode === prev) return
|
||||
|
||||
const rawId = props.vehicle.rawId ?? parseInt(props.vehicle.id.replace(/\D/g, ''), 10)
|
||||
const ok = await confirmAndApplyMaintenance(rawId, mode, prev)
|
||||
if (ok) {
|
||||
currentMode.value = mode
|
||||
emit('changed')
|
||||
} else {
|
||||
currentMode.value = prev
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.veh-maint-select {
|
||||
width: 108px;
|
||||
}
|
||||
|
||||
.veh-maint-select :deep(.el-select__wrapper) {
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
box-shadow: none;
|
||||
min-height: 28px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.veh-maint-select :deep(.el-select__selected-item),
|
||||
.veh-maint-select :deep(.el-select__placeholder) {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.veh-maint-select :deep(.el-select__caret) {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
.veh-maint-select.tone-online :deep(.el-select__wrapper) {
|
||||
border-color: rgba(var(--mg-status-success-rgb, 34, 197, 94), 0.45);
|
||||
}
|
||||
.veh-maint-select.tone-warning :deep(.el-select__wrapper) {
|
||||
border-color: rgba(var(--mg-status-warning-rgb, 245, 158, 11), 0.5);
|
||||
}
|
||||
.veh-maint-select.tone-danger :deep(.el-select__wrapper) {
|
||||
border-color: rgba(var(--mg-status-danger-rgb, 239, 68, 68), 0.5);
|
||||
}
|
||||
|
||||
.veh-maint-select.is-disabled :deep(.el-select__wrapper) {
|
||||
opacity: 0.65;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,145 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { fetchQuickEntryKeys, saveQuickEntryKeys } from '@/api/dashboardQuickEntries'
|
||||
import {
|
||||
defaultQuickKeys, getQuickEntryCatalog, MAX_QUICK_ENTRIES,
|
||||
normalizeQuickKeys, resolveQuickEntry, type QuickEntryDef
|
||||
} from '@/config/quickEntries'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { Scope } from '@/types/auth'
|
||||
|
||||
export function useDashboardQuickEntries() {
|
||||
const auth = useAuthStore()
|
||||
const keys = ref<string[]>([])
|
||||
const loading = ref(false)
|
||||
const usingDefaults = ref(true)
|
||||
const pickerOpen = ref(false)
|
||||
|
||||
const scope = computed(() => auth.scope ?? 'Platform')
|
||||
const userId = computed(() => auth.user?.id ?? '')
|
||||
|
||||
/** 空列表仅在「尚未自定义」(usingDefaults) 时回退系统默认;用户主动清空则保持为空 */
|
||||
function effectiveKeys(): string[] {
|
||||
if (keys.value.length > 0) return keys.value
|
||||
return usingDefaults.value ? defaultQuickKeys(scope.value as Scope) : []
|
||||
}
|
||||
|
||||
function filterByPermission(list: string[]): string[] {
|
||||
return list.filter((key) => {
|
||||
const def = resolveQuickEntry(key, scope.value as Scope)
|
||||
return def && auth.hasPage(def.pageKey)
|
||||
})
|
||||
}
|
||||
|
||||
const resolvedEntries = computed<QuickEntryDef[]>(() => {
|
||||
return filterByPermission(effectiveKeys())
|
||||
.slice(0, MAX_QUICK_ENTRIES)
|
||||
.map((k) => resolveQuickEntry(k, scope.value as Scope))
|
||||
.filter((d): d is QuickEntryDef => !!d)
|
||||
})
|
||||
|
||||
const pinnedKeys = computed(() => filterByPermission(effectiveKeys()))
|
||||
|
||||
/** 弹窗展示全部可访问菜单(含已固定项,已固定项在弹窗内置灰不可选) */
|
||||
const pickerCatalog = computed(() =>
|
||||
getQuickEntryCatalog(scope.value as Scope).filter((item) => auth.hasPage(item.pageKey))
|
||||
)
|
||||
|
||||
const canAddMore = computed(() => pinnedKeys.value.length < MAX_QUICK_ENTRIES)
|
||||
|
||||
const availableToAdd = computed(() => {
|
||||
const current = new Set(pinnedKeys.value)
|
||||
return pickerCatalog.value.filter((item) => !current.has(item.key))
|
||||
})
|
||||
|
||||
async function load() {
|
||||
if (!userId.value) {
|
||||
keys.value = defaultQuickKeys(scope.value as Scope)
|
||||
usingDefaults.value = true
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const dto = await fetchQuickEntryKeys(userId.value, scope.value)
|
||||
const loaded = normalizeQuickKeys(dto.keys)
|
||||
keys.value = loaded.length > 0
|
||||
? loaded
|
||||
: (dto.usingDefaults ? defaultQuickKeys(scope.value as Scope) : [])
|
||||
usingDefaults.value = dto.usingDefaults
|
||||
} catch (e) {
|
||||
keys.value = defaultQuickKeys(scope.value as Scope)
|
||||
usingDefaults.value = true
|
||||
ElMessage.warning(`加载快捷入口失败:${e instanceof Error ? e.message : String(e)}`)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function persist(nextKeys: string[]) {
|
||||
if (!userId.value) {
|
||||
keys.value = nextKeys
|
||||
return
|
||||
}
|
||||
try {
|
||||
const dto = await saveQuickEntryKeys(userId.value, scope.value, normalizeQuickKeys(nextKeys))
|
||||
keys.value = normalizeQuickKeys(dto.keys)
|
||||
usingDefaults.value = dto.usingDefaults
|
||||
} catch (e) {
|
||||
ElMessage.error(`保存快捷入口失败:${e instanceof Error ? e.message : String(e)}`)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function addKey(key: string) {
|
||||
const base = [...effectiveKeys()]
|
||||
if (base.includes(key)) {
|
||||
ElMessage.info('该菜单已在快捷入口中')
|
||||
return
|
||||
}
|
||||
if (base.length >= MAX_QUICK_ENTRIES) {
|
||||
ElMessage.warning(`快捷入口最多 ${MAX_QUICK_ENTRIES} 个`)
|
||||
return
|
||||
}
|
||||
await persist([...base, key])
|
||||
ElMessage.success('已添加快捷入口')
|
||||
}
|
||||
|
||||
async function removeKey(key: string) {
|
||||
const base = [...effectiveKeys()]
|
||||
await persist(base.filter((k) => k !== key))
|
||||
ElMessage.success('已移除快捷入口')
|
||||
}
|
||||
|
||||
async function swapKeys(keyA: string, keyB: string) {
|
||||
if (keyA === keyB || keyA === 'add' || keyB === 'add') return
|
||||
const list = [...pinnedKeys.value]
|
||||
const i = list.indexOf(keyA)
|
||||
const j = list.indexOf(keyB)
|
||||
if (i < 0 || j < 0 || i === j) return
|
||||
;[list[i], list[j]] = [list[j], list[i]]
|
||||
await persist(list)
|
||||
}
|
||||
|
||||
function openPicker() {
|
||||
pickerOpen.value = true
|
||||
}
|
||||
|
||||
watch([userId, scope], () => { void load() }, { immediate: true })
|
||||
|
||||
return {
|
||||
keys,
|
||||
loading,
|
||||
usingDefaults,
|
||||
pickerOpen,
|
||||
resolvedEntries,
|
||||
pickerCatalog,
|
||||
pinnedKeys,
|
||||
availableToAdd,
|
||||
canAddMore,
|
||||
load,
|
||||
addKey,
|
||||
removeKey,
|
||||
swapKeys,
|
||||
openPicker
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const LONG_PRESS_MS = 450
|
||||
const PRE_DRAG_MOVE_PX = 10
|
||||
|
||||
export interface QuickDragTile {
|
||||
key: string
|
||||
label: string
|
||||
icon: unknown
|
||||
primary?: boolean
|
||||
}
|
||||
|
||||
export function useQuickEntryDragSwap(
|
||||
swapKeys: (keyA: string, keyB: string) => Promise<void>
|
||||
) {
|
||||
const dragKey = ref<string | null>(null)
|
||||
const hoverTargetKey = ref<string | null>(null)
|
||||
const ghostPos = ref({ x: 0, y: 0 })
|
||||
|
||||
let pressTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let suppressClick = false
|
||||
let active = false
|
||||
|
||||
function clearPressTimer() {
|
||||
if (pressTimer) {
|
||||
clearTimeout(pressTimer)
|
||||
pressTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function findTargetKey(clientX: number, clientY: number, sourceKey: string): string | null {
|
||||
const el = document.elementFromPoint(clientX, clientY)
|
||||
const tile = el?.closest('[data-quick-key]') as HTMLElement | null
|
||||
const key = tile?.dataset.quickKey
|
||||
if (!key || key === 'add' || key === sourceKey) return null
|
||||
return key
|
||||
}
|
||||
|
||||
function onPointerDown(item: QuickDragTile, e: PointerEvent) {
|
||||
if (item.key === 'add' || e.button !== 0) return
|
||||
|
||||
const target = e.currentTarget as HTMLElement
|
||||
const startX = e.clientX
|
||||
const startY = e.clientY
|
||||
let dragging = false
|
||||
|
||||
clearPressTimer()
|
||||
active = true
|
||||
|
||||
const cleanup = () => {
|
||||
clearPressTimer()
|
||||
active = false
|
||||
dragging = false
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', onUp)
|
||||
window.removeEventListener('pointercancel', onUp)
|
||||
}
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
if (!dragging) {
|
||||
const dx = ev.clientX - startX
|
||||
const dy = ev.clientY - startY
|
||||
if (dx * dx + dy * dy > PRE_DRAG_MOVE_PX * PRE_DRAG_MOVE_PX) {
|
||||
clearPressTimer()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ghostPos.value = { x: ev.clientX, y: ev.clientY }
|
||||
hoverTargetKey.value = findTargetKey(ev.clientX, ev.clientY, item.key)
|
||||
}
|
||||
|
||||
const onUp = async (ev: PointerEvent) => {
|
||||
clearPressTimer()
|
||||
|
||||
if (dragging) {
|
||||
suppressClick = true
|
||||
const from = item.key
|
||||
const to = hoverTargetKey.value ?? findTargetKey(ev.clientX, ev.clientY, from)
|
||||
dragKey.value = null
|
||||
hoverTargetKey.value = null
|
||||
if (to) {
|
||||
try {
|
||||
await swapKeys(from, to)
|
||||
} catch {
|
||||
/* persist failed */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cleanup()
|
||||
}
|
||||
|
||||
pressTimer = setTimeout(() => {
|
||||
pressTimer = null
|
||||
dragging = true
|
||||
suppressClick = false
|
||||
dragKey.value = item.key
|
||||
ghostPos.value = { x: e.clientX, y: e.clientY }
|
||||
hoverTargetKey.value = null
|
||||
try {
|
||||
target.setPointerCapture(e.pointerId)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, LONG_PRESS_MS)
|
||||
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onUp)
|
||||
window.addEventListener('pointercancel', onUp)
|
||||
}
|
||||
|
||||
function shouldSuppressClick(): boolean {
|
||||
if (!suppressClick) return false
|
||||
suppressClick = false
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
dragKey,
|
||||
hoverTargetKey,
|
||||
ghostPos,
|
||||
onPointerDown,
|
||||
shouldSuppressClick
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { computed, toValue, type MaybeRefOrGetter } from 'vue'
|
||||
import type { VehicleCardModel } from '@/types/car'
|
||||
|
||||
export interface VehicleCardStateOptions {
|
||||
vehicle: MaybeRefOrGetter<VehicleCardModel>
|
||||
}
|
||||
|
||||
const stateLabels: Record<string, string> = {
|
||||
idle: '空闲',
|
||||
running: '运行',
|
||||
charging: '充电',
|
||||
paused: '暂停',
|
||||
fault: '故障',
|
||||
offline: '离线'
|
||||
}
|
||||
|
||||
function formatMissionIdSuffix(missionId?: string | number | null): string {
|
||||
if (missionId == null) return ''
|
||||
const id = String(missionId).trim()
|
||||
if (!id || id === '0') return ''
|
||||
return `-${id}`
|
||||
}
|
||||
|
||||
function appendMissionId(base: string, missionId?: string | number | null): string {
|
||||
const suffix = formatMissionIdSuffix(missionId)
|
||||
return suffix ? `${base}${suffix}` : base
|
||||
}
|
||||
|
||||
export function useVehicleCardState(opts: VehicleCardStateOptions) {
|
||||
const vehicle = computed(() => toValue(opts.vehicle))
|
||||
|
||||
const stateLabel = computed(() => stateLabels[vehicle.value.state] ?? vehicle.value.state)
|
||||
|
||||
const batteryPct = computed(() => {
|
||||
const raw = vehicle.value.batterySoc ?? 0
|
||||
const pct = raw > 1 ? raw : raw * 100
|
||||
return Math.max(0, Math.min(100, Math.round(pct)))
|
||||
})
|
||||
|
||||
const batteryTone = computed(() => {
|
||||
const p = batteryPct.value
|
||||
if (p < 20) return 'tone-danger'
|
||||
if (p < 50) return 'tone-warning'
|
||||
return 'tone-success'
|
||||
})
|
||||
|
||||
const switchOn = computed(() => vehicle.value.maintenanceMode === 'online')
|
||||
|
||||
const statusPill = computed(() => {
|
||||
const v = vehicle.value
|
||||
if (v.reachable === false) return { label: '不可达', tone: 'tone-danger' }
|
||||
if (v.isAlarmActive) return { label: '报警中', tone: 'tone-danger' }
|
||||
if (v.maintenanceMode === 'offline') return { label: '下线维护', tone: 'tone-warning' }
|
||||
if (v.maintenanceMode === 'repair') return { label: '现场检修', tone: 'tone-warning' }
|
||||
if (v.maintenanceMode === 'blown') return { label: '返厂检修', tone: 'tone-danger' }
|
||||
if (v.state === 'fault') return { label: '故障', tone: 'tone-danger' }
|
||||
if (v.state === 'running') return { label: '运行中', tone: 'tone-success' }
|
||||
if (v.state === 'charging') return { label: '充电中', tone: 'tone-info' }
|
||||
if (v.state === 'offline') return { label: '离线', tone: 'tone-idle' }
|
||||
return { label: stateLabel.value, tone: 'tone-idle' }
|
||||
})
|
||||
|
||||
const missionIdSuffix = computed(() => formatMissionIdSuffix(vehicle.value.missionId))
|
||||
|
||||
const statusDisplayLabel = computed(() =>
|
||||
appendMissionId(statusPill.value.label, vehicle.value.missionId)
|
||||
)
|
||||
|
||||
const runtimeStatusLabel = computed(() =>
|
||||
appendMissionId(vehicle.value.lstatus ?? stateLabel.value, vehicle.value.missionId)
|
||||
)
|
||||
|
||||
const accentTone = computed(() => statusPill.value.tone)
|
||||
|
||||
const latencyLabel = computed(() => {
|
||||
const ms = vehicle.value.latencyMs
|
||||
if (ms == null) return '—'
|
||||
if (vehicle.value.reachable === false) return '超时'
|
||||
return `${ms} ms`
|
||||
})
|
||||
|
||||
const latencyClass = computed(() => {
|
||||
const ms = vehicle.value.latencyMs
|
||||
if (vehicle.value.reachable === false) return 'val-danger'
|
||||
if (ms != null && ms > 80) return 'val-warn'
|
||||
return ''
|
||||
})
|
||||
|
||||
const faultLabel = computed(() => {
|
||||
const v = vehicle.value.faultRatePercent
|
||||
if (v == null) return '—'
|
||||
return `${v.toFixed(2)}%`
|
||||
})
|
||||
|
||||
const faultClass = computed(() => {
|
||||
const v = vehicle.value.faultRatePercent ?? 0
|
||||
if (v >= 5) return 'val-danger'
|
||||
if (v >= 1) return 'val-warn'
|
||||
return ''
|
||||
})
|
||||
|
||||
const cpuLabel = computed(() => {
|
||||
const v = vehicle.value.cpuPercent
|
||||
return v != null ? `${Math.round(v)}%` : '—'
|
||||
})
|
||||
|
||||
const memLabel = computed(() => {
|
||||
const v = vehicle.value.memPercent
|
||||
return v != null ? `${Math.round(v)}%` : '—'
|
||||
})
|
||||
|
||||
const cpuChipClass = computed(() => {
|
||||
const v = vehicle.value.cpuPercent
|
||||
if (v == null) return ''
|
||||
if (v >= 90) return 'val-danger'
|
||||
if (v >= 75) return 'val-warn'
|
||||
return 'val-ok'
|
||||
})
|
||||
|
||||
const memChipClass = computed(() => {
|
||||
const v = vehicle.value.memPercent
|
||||
if (v == null) return ''
|
||||
if (v >= 90) return 'val-danger'
|
||||
if (v >= 75) return 'val-warn'
|
||||
return 'val-ok'
|
||||
})
|
||||
|
||||
return {
|
||||
stateLabel,
|
||||
batteryPct,
|
||||
batteryTone,
|
||||
switchOn,
|
||||
statusPill,
|
||||
missionIdSuffix,
|
||||
statusDisplayLabel,
|
||||
runtimeStatusLabel,
|
||||
accentTone,
|
||||
latencyLabel,
|
||||
latencyClass,
|
||||
faultLabel,
|
||||
faultClass,
|
||||
cpuLabel,
|
||||
memLabel,
|
||||
cpuChipClass,
|
||||
memChipClass
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { setVehicleMaintenance, type VehicleMaintenanceMode } from '@/api/vehicleOps'
|
||||
|
||||
export const MAINTENANCE_OPTIONS: { value: VehicleMaintenanceMode; label: string }[] = [
|
||||
{ value: 'online', label: '上线' },
|
||||
{ value: 'offline', label: '下线维护' },
|
||||
{ value: 'repair', label: '现场检修' },
|
||||
{ value: 'blown', label: '返厂检修' }
|
||||
]
|
||||
|
||||
export function maintenanceModeLabel(mode?: VehicleMaintenanceMode): string {
|
||||
return MAINTENANCE_OPTIONS.find((o) => o.value === mode)?.label ?? '上线'
|
||||
}
|
||||
|
||||
export async function confirmAndApplyMaintenance(
|
||||
rawId: number | undefined,
|
||||
mode: VehicleMaintenanceMode,
|
||||
prevMode: VehicleMaintenanceMode
|
||||
): Promise<boolean> {
|
||||
if (!Number.isFinite(rawId)) return false
|
||||
if (mode === prevMode) return false
|
||||
|
||||
try {
|
||||
if (mode === 'blown') {
|
||||
await ElMessageBox.confirm('返厂检修将停止调度并清空站点,确认?', '危险操作', {
|
||||
type: 'error',
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消'
|
||||
})
|
||||
} else if (mode === 'repair') {
|
||||
await ElMessageBox.confirm('现场检修:不调度但仍刷新状态,确认?', '维护确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消'
|
||||
})
|
||||
} else if (mode === 'online' || mode === 'offline') {
|
||||
await ElMessageBox.confirm(
|
||||
mode === 'online' ? '确认将车辆上线?' : '确认将车辆下线维护?',
|
||||
'维护确认',
|
||||
{ type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' }
|
||||
)
|
||||
}
|
||||
|
||||
const ok = await setVehicleMaintenance(rawId!, mode)
|
||||
if (ok) {
|
||||
ElMessage.success('维护状态已更新')
|
||||
return true
|
||||
}
|
||||
ElMessage.error('维护操作失败')
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
Collection, Connection, Cpu, Document, DocumentCopy, EditPen,
|
||||
Histogram, Link, MapLocation, Monitor, Notebook, OfficeBuilding,
|
||||
Operation, Promotion, SetUp, Setting, Tools, User, Van, VideoCamera
|
||||
} from '@element-plus/icons-vue'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
export interface NavMenuItem {
|
||||
path: string
|
||||
label: string
|
||||
icon?: Component
|
||||
key?: string
|
||||
group?: string
|
||||
children?: NavMenuItem[]
|
||||
}
|
||||
|
||||
export const ADMIN_MENU: NavMenuItem[] = [
|
||||
{ path: '/admin/dashboard', label: '总览', icon: Histogram, key: 'admin-dashboard', group: '概览' },
|
||||
{ path: '/admin/map-monitor', label: '地图监控', icon: MapLocation, key: 'admin-map-monitor', group: '概览' },
|
||||
{
|
||||
path: '/admin/design', label: '设计与编排', icon: Tools, group: '设计与编排',
|
||||
children: [
|
||||
{ path: '/admin/maps', label: '地图管理', icon: MapLocation, key: 'admin-maps', group: '设计与编排' },
|
||||
{ path: '/admin/map-editor', label: '地图编辑', icon: EditPen, key: 'admin-map-editor', group: '设计与编排' },
|
||||
{ path: '/admin/project-properties', label: '项目属性', icon: Document, key: 'admin-project-properties', group: '设计与编排' },
|
||||
{ path: '/admin/tracks', label: '场景管理', icon: Connection, key: 'admin-tracks', group: '设计与编排' },
|
||||
{ path: '/admin/cars', label: '车辆管理', icon: Van, key: 'admin-cars', group: '设计与编排' },
|
||||
{ path: '/admin/processes', label: '进程管理', icon: Cpu, key: 'admin-processes', group: '设计与编排' },
|
||||
{ path: '/admin/scripts', label: '脚本管理', icon: DocumentCopy, key: 'admin-scripts', group: '设计与编排' },
|
||||
{ path: '/admin/task-templates', label: '任务编排', icon: Operation, key: 'admin-task-templates', group: '设计与编排' },
|
||||
{ path: '/admin/simple-fields', label: '字段管理', icon: Collection, key: 'admin-simple-fields', group: '设计与编排' }
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/admin/config', label: '平台配置中心', icon: Setting, group: '平台配置中心',
|
||||
children: [
|
||||
{ path: '/admin/config/strategy', label: '调度策略', icon: SetUp, key: 'admin-config-strategy', group: '平台配置中心' },
|
||||
{ path: '/admin/config/vehicle-hub', label: '车辆运维', icon: Van, key: 'admin-vehicle-hub', group: '平台配置中心' },
|
||||
{ path: '/admin/config/facility', label: '设备与库位', icon: OfficeBuilding, key: 'admin-config-facility', group: '平台配置中心' },
|
||||
{ path: '/admin/config/business', label: '业务与集成', icon: Link, key: 'admin-config-business', group: '平台配置中心' },
|
||||
{ path: '/admin/config/ops-center', label: '运维与回放', icon: VideoCamera, key: 'admin-config-ops-center', group: '平台配置中心' },
|
||||
{ path: '/admin/config/system-center', label: '系统与权限', icon: User, key: 'admin-config-system-center', group: '平台配置中心' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
export const MONITOR_MENU: NavMenuItem[] = [
|
||||
{ path: '/monitor/dashboard', label: '运营总览', icon: Monitor, key: 'monitor-dashboard', group: '运营监控' },
|
||||
{ path: '/monitor/vehicle-hub', label: '车辆运维', icon: Van, key: 'monitor-vehicle-hub', group: '运营监控' },
|
||||
{ path: '/monitor/map', label: '地图监控', icon: MapLocation, key: 'monitor-map', group: '运营监控' },
|
||||
{ path: '/monitor/ops', label: '运维操作', icon: Promotion, key: 'monitor-ops', group: '运营监控' },
|
||||
{ path: '/monitor/notes', label: '运营备注', icon: Notebook, key: 'monitor-notes', group: '运营监控' }
|
||||
]
|
||||
|
||||
export function flattenNavMenu(items: NavMenuItem[]): NavMenuItem[] {
|
||||
const out: NavMenuItem[] = []
|
||||
for (const item of items) {
|
||||
if (item.children?.length) out.push(...flattenNavMenu(item.children))
|
||||
else if (item.key) out.push(item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Setting } from '@element-plus/icons-vue'
|
||||
import type { Component } from 'vue'
|
||||
import type { Scope } from '@/types/auth'
|
||||
import {
|
||||
ADMIN_MENU, MONITOR_MENU, flattenNavMenu, type NavMenuItem
|
||||
} from '@/config/navMenu'
|
||||
|
||||
export const MAX_QUICK_ENTRIES = 16
|
||||
export const QUICK_GRID_COLUMNS = 8
|
||||
|
||||
export interface QuickEntryDef {
|
||||
key: string
|
||||
label: string
|
||||
path: string
|
||||
icon: Component
|
||||
hint?: string
|
||||
primary?: boolean
|
||||
pageKey: string
|
||||
group?: string
|
||||
}
|
||||
|
||||
/** 当前页即总览,不作为快捷入口候选 */
|
||||
const EXCLUDED_QUICK_ENTRY_KEYS = new Set(['admin-dashboard', 'monitor-dashboard'])
|
||||
|
||||
/** 旧版别名 key → 菜单 key(加载/保存时归一化,避免重复项) */
|
||||
const LEGACY_KEY_ALIASES: Record<string, string> = {
|
||||
'platform-config': 'admin-map-editor',
|
||||
mission: 'admin-task-templates',
|
||||
cars: 'admin-cars',
|
||||
auth: 'admin-config-system-center',
|
||||
system: 'admin-config-system-center',
|
||||
ops: 'admin-config-ops-center',
|
||||
tasks: 'admin-config-strategy'
|
||||
}
|
||||
|
||||
/** 与后端 DashboardShortcutCatalog.DefaultPlatformKeys 对齐(均为菜单 key) */
|
||||
export const DEFAULT_PLATFORM_QUICK_KEYS = [
|
||||
'admin-map-editor',
|
||||
'admin-task-templates',
|
||||
'admin-cars',
|
||||
'admin-config-system-center',
|
||||
'admin-config-ops-center',
|
||||
'admin-config-strategy'
|
||||
] as const
|
||||
|
||||
export const DEFAULT_MONITOR_QUICK_KEYS = [
|
||||
'monitor-vehicle-hub', 'monitor-map', 'monitor-ops'
|
||||
] as const
|
||||
|
||||
export function normalizeQuickKey(key: string): string {
|
||||
return LEGACY_KEY_ALIASES[key] ?? key
|
||||
}
|
||||
|
||||
export function normalizeQuickKeys(keys: string[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
for (const raw of keys) {
|
||||
const k = normalizeQuickKey(raw.trim())
|
||||
if (!k || seen.has(k) || EXCLUDED_QUICK_ENTRY_KEYS.has(k)) continue
|
||||
seen.add(k)
|
||||
out.push(k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function menuItemToQuick(item: NavMenuItem): QuickEntryDef | null {
|
||||
if (!item.key || EXCLUDED_QUICK_ENTRY_KEYS.has(item.key)) return null
|
||||
return {
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
path: item.path,
|
||||
icon: item.icon ?? Setting,
|
||||
pageKey: item.key,
|
||||
group: item.group
|
||||
}
|
||||
}
|
||||
|
||||
function buildCatalog(scope: Scope): Map<string, QuickEntryDef> {
|
||||
const map = new Map<string, QuickEntryDef>()
|
||||
const menu = scope === 'RCSMonitor' ? MONITOR_MENU : ADMIN_MENU
|
||||
for (const item of flattenNavMenu(menu)) {
|
||||
const q = menuItemToQuick(item)
|
||||
if (q) map.set(q.key, q)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export function getQuickEntryCatalog(scope: Scope): QuickEntryDef[] {
|
||||
return [...buildCatalog(scope).values()]
|
||||
}
|
||||
|
||||
export function resolveQuickEntry(key: string, scope: Scope): QuickEntryDef | undefined {
|
||||
return buildCatalog(scope).get(normalizeQuickKey(key))
|
||||
}
|
||||
|
||||
export function defaultQuickKeys(scope: Scope): string[] {
|
||||
return scope === 'RCSMonitor'
|
||||
? [...DEFAULT_MONITOR_QUICK_KEYS]
|
||||
: [...DEFAULT_PLATFORM_QUICK_KEYS]
|
||||
}
|
||||
|
||||
export function groupQuickEntries(items: QuickEntryDef[]): { group: string; items: QuickEntryDef[] }[] {
|
||||
const groups = new Map<string, QuickEntryDef[]>()
|
||||
for (const item of items) {
|
||||
const g = item.group ?? '其他'
|
||||
if (!groups.has(g)) groups.set(g, [])
|
||||
groups.get(g)!.push(item)
|
||||
}
|
||||
return [...groups.entries()].map(([group, list]) => ({ group, items: list }))
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
export type SimpleFieldCategory = 'siteFields' | 'trackFields' | 'planFields' | 'carFields'
|
||||
|
||||
export const SIMPLE_FIELD_CATEGORIES: { key: SimpleFieldCategory; label: string }[] = [
|
||||
{ key: 'siteFields', label: '站点字段 (siteFields)' },
|
||||
{ key: 'trackFields', label: '路径字段 (trackFields)' },
|
||||
{ key: 'planFields', label: '计划字段 (planFields)' },
|
||||
{ key: 'carFields', label: '车辆字段 (carFields)' }
|
||||
]
|
||||
|
||||
export interface SimpleFieldRecord {
|
||||
id: string
|
||||
carType: string
|
||||
fieldType: string
|
||||
key: string
|
||||
value: string
|
||||
dataType: string
|
||||
chinese: string | null
|
||||
english: string | null
|
||||
other: string
|
||||
isDefault: boolean
|
||||
createTime: string
|
||||
updateTime: string
|
||||
}
|
||||
|
||||
export interface SimpleFieldPayload {
|
||||
id?: string
|
||||
carType: string
|
||||
fieldType: string
|
||||
key: string
|
||||
value?: string
|
||||
dataType?: string
|
||||
chinese?: string | null
|
||||
english?: string | null
|
||||
other?: string
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
export interface CoderFieldDef {
|
||||
name: string
|
||||
typeName: string
|
||||
defaultValue: unknown
|
||||
}
|
||||
|
||||
export interface CoderFieldGroup {
|
||||
typeName: string
|
||||
shortName: string
|
||||
assemblyName: string
|
||||
baseTypeName?: string
|
||||
fields: CoderFieldDef[]
|
||||
}
|
||||
|
||||
export interface CarTypeCoderFields {
|
||||
typeName: string
|
||||
shortName: string
|
||||
label: string
|
||||
assemblyName: string
|
||||
siteFields: CoderFieldGroup
|
||||
trackFields: CoderFieldGroup
|
||||
planFields: CoderFieldGroup
|
||||
carFields: CoderFieldGroup
|
||||
}
|
||||
|
||||
export interface SimpleFieldBatchPayload {
|
||||
replaceAll: boolean
|
||||
items: SimpleFieldPayload[]
|
||||
}
|
||||
|
||||
export interface FieldRow {
|
||||
rowKey: string
|
||||
id?: string
|
||||
carType: string
|
||||
fieldType: SimpleFieldCategory
|
||||
key: string
|
||||
dataType: string
|
||||
value: string
|
||||
chinese: string | null
|
||||
english: string | null
|
||||
other: string
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
export const ALL_CAR_TYPES = '__all__'
|
||||
|
||||
/** car_type 唯一标识:assemblyName.shortName */
|
||||
export function buildCarType(assemblyName: string, shortName: string): string {
|
||||
const asm = assemblyName?.trim() ?? ''
|
||||
const sn = shortName?.trim() ?? ''
|
||||
return asm && sn ? `${asm}.${sn}` : sn || asm
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* webVRender (SimpleLite 3D 视口, 默认 :8223) 的 host 解析。
|
||||
*
|
||||
* 优先级:显式 VITE_VRENDER_HOST > 当前页面 hostname:8223。
|
||||
* 不能写死 localhost —— 从远程浏览器访问平台时 iframe 会去连访问者本机而非服务器。
|
||||
*/
|
||||
export function defaultVrHost(): string {
|
||||
const env = import.meta.env.VITE_VRENDER_HOST as string | undefined
|
||||
if (env && env.trim()) return env.trim()
|
||||
return `${window.location.hostname}:8223`
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
<template>
|
||||
<div class="simple-field-page">
|
||||
<el-card v-loading="initializing" shadow="never" class="page-card" element-loading-text="正在加载字段数据…">
|
||||
<template #header>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>字段管理</h2>
|
||||
<p>「默认」从 SimpleLite 拉取全部车型字段;「刷新」从数据库读取;「保存」将全部字段写入数据库。</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button :loading="initializing || loadingDefaults" @click="loadDefaults">默认</el-button>
|
||||
<el-button type="primary" :loading="saving" :disabled="initializing || !fieldRows.length" @click="saveAll">保存</el-button>
|
||||
<el-button :icon="Refresh" :loading="initializing || loading" @click="loadFromDb()">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="filters">
|
||||
<div class="car-type-filter">
|
||||
<span class="filter-label">车辆类型:</span>
|
||||
<el-select
|
||||
v-model="filterCarType"
|
||||
filterable
|
||||
placeholder="请选择车型"
|
||||
class="car-type-select bordered-select"
|
||||
popper-class="car-type-option-popper"
|
||||
>
|
||||
<el-option
|
||||
v-for="c in carTypes"
|
||||
:key="c.typeName"
|
||||
:label="carTypeSelectLabel(c)"
|
||||
:value="buildCarType(c.assemblyName, c.shortName)"
|
||||
>
|
||||
<span class="car-type-option">
|
||||
<span class="car-type-option-cn">{{ carTypeCn(c) }}</span>
|
||||
<span class="car-type-option-en">({{ buildCarType(c.assemblyName, c.shortName) }})</span>
|
||||
</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="keyword-filter">
|
||||
<span class="filter-label">搜索:</span>
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="当前车型下搜索属性 / key / 中文 / 英文 / 其他语言"
|
||||
clearable
|
||||
class="keyword-input"
|
||||
/>
|
||||
</div>
|
||||
<el-button type="primary" :disabled="initializing" @click="openDialog()">新增字段</el-button>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="activeCategory" class="field-tabs">
|
||||
<el-tab-pane
|
||||
v-for="cat in SIMPLE_FIELD_CATEGORIES"
|
||||
:key="cat.key"
|
||||
:name="cat.key"
|
||||
:label="cat.label"
|
||||
>
|
||||
<el-table
|
||||
v-loading="initializing || loading || loadingDefaults"
|
||||
:data="filteredRows"
|
||||
:row-class-name="searchRowClassName"
|
||||
border
|
||||
size="small"
|
||||
height="520"
|
||||
>
|
||||
<el-table-column prop="carType" label="车型 (car_type)" min-width="200" />
|
||||
<el-table-column prop="key" label="属性" min-width="140" sortable />
|
||||
<el-table-column prop="dataType" label="数据类型" min-width="130" sortable />
|
||||
<el-table-column prop="value" label="默认值" min-width="100" />
|
||||
<el-table-column prop="chinese" label="中文名" min-width="100" />
|
||||
<el-table-column prop="english" label="英文名" min-width="100" />
|
||||
<el-table-column prop="other" label="其他语言" min-width="100" />
|
||||
<el-table-column prop="isDefault" label="默认" width="72" sortable :sort-method="sortByIsDefault">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.isDefault ? 'info' : 'success'" size="small">{{ row.isDefault ? '是' : '否' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openDialog(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="removeRow(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="520px" destroy-on-close>
|
||||
<el-form label-width="96px">
|
||||
<el-form-item label="车型" required>
|
||||
<el-select
|
||||
v-model="form.carType"
|
||||
filterable
|
||||
:disabled="!!editingRowKey"
|
||||
class="bordered-select"
|
||||
popper-class="car-type-option-popper"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="c in carTypes"
|
||||
:key="c.typeName"
|
||||
:label="carTypeSelectLabel(c)"
|
||||
:value="buildCarType(c.assemblyName, c.shortName)"
|
||||
>
|
||||
<span class="car-type-option">
|
||||
<span class="car-type-option-cn">{{ carTypeCn(c) }}</span>
|
||||
<span class="car-type-option-en">({{ buildCarType(c.assemblyName, c.shortName) }})</span>
|
||||
</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="字段类型" required>
|
||||
<el-select
|
||||
v-model="form.fieldType"
|
||||
:disabled="!!editingRowKey"
|
||||
class="bordered-select"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="cat in SIMPLE_FIELD_CATEGORIES"
|
||||
:key="cat.key"
|
||||
:label="cat.label"
|
||||
:value="cat.key"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="属性" required>
|
||||
<el-input v-model="form.key" :disabled="!!editingRowKey" placeholder="属性名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="数据类型" required>
|
||||
<el-select
|
||||
v-model="form.dataType"
|
||||
filterable
|
||||
allow-create
|
||||
:disabled="!!editingRowKey"
|
||||
class="bordered-select"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="t in DATA_TYPE_OPTIONS" :key="t" :label="t" :value="t" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="默认值" required>
|
||||
<el-input v-model="form.value" placeholder="默认值(字符串存储)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="中文名">
|
||||
<el-input v-model="form.chinese" />
|
||||
</el-form-item>
|
||||
<el-form-item label="英文名">
|
||||
<el-input v-model="form.english" />
|
||||
</el-form-item>
|
||||
<el-form-item label="其他语言">
|
||||
<el-input v-model="form.other" placeholder="日语、德语等其他语言名称" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editingRowKey" label="是否内置">
|
||||
<el-switch v-model="form.isDefault" class="builtin-switch" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="applyDialog">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { reflectionApi, type CarTypeCoderFieldsRow, type ReflectionCreatableType } from '@/api/reflection'
|
||||
import * as simpleFieldApi from '@/api/simpleField'
|
||||
import {
|
||||
SIMPLE_FIELD_CATEGORIES,
|
||||
buildCarType,
|
||||
type FieldRow,
|
||||
type SimpleFieldCategory,
|
||||
type SimpleFieldRecord
|
||||
} from '@/types/simpleField'
|
||||
|
||||
const DATA_TYPE_OPTIONS = [
|
||||
'System.Boolean',
|
||||
'System.Int32',
|
||||
'System.Single',
|
||||
'System.Double',
|
||||
'System.String'
|
||||
]
|
||||
|
||||
const initializing = ref(true)
|
||||
const loading = ref(false)
|
||||
const loadingDefaults = ref(false)
|
||||
const saving = ref(false)
|
||||
const carTypes = ref<ReflectionCreatableType[]>([])
|
||||
const fieldRows = ref<FieldRow[]>([])
|
||||
const filterCarType = ref('')
|
||||
const activeCategory = ref<SimpleFieldCategory>('siteFields')
|
||||
const keyword = ref('')
|
||||
const highlightRowKey = ref('')
|
||||
const dialogVisible = ref(false)
|
||||
const editingRowKey = ref('')
|
||||
|
||||
const form = reactive({
|
||||
carType: '',
|
||||
fieldType: 'siteFields' as SimpleFieldCategory,
|
||||
key: '',
|
||||
value: '',
|
||||
dataType: 'System.String',
|
||||
chinese: '',
|
||||
english: '',
|
||||
other: '',
|
||||
isDefault: false
|
||||
})
|
||||
|
||||
const dialogTitle = computed(() => (editingRowKey.value ? '编辑字段' : '新增字段'))
|
||||
|
||||
const categoryRows = computed(() =>
|
||||
rowsForCurrentCar().filter((r) => r.fieldType === activeCategory.value)
|
||||
)
|
||||
|
||||
function rowsForCurrentCar() {
|
||||
if (!filterCarType.value) return fieldRows.value
|
||||
return fieldRows.value.filter((r) => r.carType === filterCarType.value)
|
||||
}
|
||||
|
||||
function rowMatchesKeyword(row: FieldRow, kw: string) {
|
||||
return (
|
||||
row.key.toLowerCase().includes(kw) ||
|
||||
(row.chinese ?? '').toLowerCase().includes(kw) ||
|
||||
(row.english ?? '').toLowerCase().includes(kw) ||
|
||||
row.other.toLowerCase().includes(kw) ||
|
||||
row.value.toLowerCase().includes(kw) ||
|
||||
row.dataType.toLowerCase().includes(kw)
|
||||
)
|
||||
}
|
||||
|
||||
function findFirstSearchMatch(kw: string): FieldRow | undefined {
|
||||
const rows = rowsForCurrentCar()
|
||||
for (const cat of SIMPLE_FIELD_CATEGORIES) {
|
||||
const match = rows.find((r) => r.fieldType === cat.key && rowMatchesKeyword(r, kw))
|
||||
if (match) return match
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function searchRowClassName({ row }: { row: FieldRow }) {
|
||||
return row.rowKey === highlightRowKey.value ? 'search-hit-row' : ''
|
||||
}
|
||||
|
||||
function sortByIsDefault(a: FieldRow, b: FieldRow) {
|
||||
return Number(a.isDefault) - Number(b.isDefault)
|
||||
}
|
||||
|
||||
function scrollToHighlightedRow() {
|
||||
nextTick(() => {
|
||||
document.querySelector('.field-tabs .search-hit-row')?.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
}
|
||||
|
||||
function navigateSearchToFirstMatch() {
|
||||
const kw = keyword.value.trim().toLowerCase()
|
||||
highlightRowKey.value = ''
|
||||
if (!kw) return
|
||||
const first = findFirstSearchMatch(kw)
|
||||
if (!first) return
|
||||
activeCategory.value = first.fieldType
|
||||
highlightRowKey.value = first.rowKey
|
||||
scrollToHighlightedRow()
|
||||
}
|
||||
|
||||
watch([keyword, filterCarType], () => {
|
||||
navigateSearchToFirstMatch()
|
||||
})
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
const rows = categoryRows.value
|
||||
const kw = keyword.value.trim().toLowerCase()
|
||||
if (!kw) return rows
|
||||
return rows.filter((r) => rowMatchesKeyword(r, kw))
|
||||
})
|
||||
|
||||
function formatValue(v: unknown): string {
|
||||
if (v === null || v === undefined) return ''
|
||||
if (typeof v === 'object') return JSON.stringify(v)
|
||||
return String(v)
|
||||
}
|
||||
|
||||
function makeRowKey(carType: string, fieldType: string, key: string) {
|
||||
return `${carType}:${fieldType}:${key}`
|
||||
}
|
||||
|
||||
function recordToRow(r: SimpleFieldRecord): FieldRow {
|
||||
return {
|
||||
rowKey: makeRowKey(r.carType, r.fieldType, r.key),
|
||||
id: r.id,
|
||||
carType: r.carType,
|
||||
fieldType: r.fieldType as SimpleFieldCategory,
|
||||
key: r.key,
|
||||
dataType: r.dataType,
|
||||
value: r.value,
|
||||
chinese: r.chinese,
|
||||
english: r.english,
|
||||
other: r.other,
|
||||
isDefault: r.isDefault
|
||||
}
|
||||
}
|
||||
|
||||
function carTypeToRows(car: CarTypeCoderFieldsRow): FieldRow[] {
|
||||
const carType = buildCarType(car.assemblyName, car.shortName)
|
||||
const rows: FieldRow[] = []
|
||||
for (const cat of SIMPLE_FIELD_CATEGORIES) {
|
||||
const group = car[cat.key]
|
||||
for (const f of group?.fields ?? []) {
|
||||
rows.push({
|
||||
rowKey: makeRowKey(carType, cat.key, f.name),
|
||||
carType,
|
||||
fieldType: cat.key,
|
||||
key: f.name,
|
||||
dataType: f.typeName,
|
||||
value: formatValue(f.defaultValue),
|
||||
chinese: '',
|
||||
english: '',
|
||||
other: '',
|
||||
isDefault: true
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function allCarsToRows(cars: CarTypeCoderFieldsRow[]): FieldRow[] {
|
||||
return cars.flatMap(carTypeToRows)
|
||||
}
|
||||
|
||||
/** 从已加载的数据库记录推导车型下拉(不请求 SimpleLite)。 */
|
||||
function syncCarTypesFromFieldRows() {
|
||||
const labelByKey = new Map(
|
||||
carTypes.value.map((c) => [buildCarType(c.assemblyName, c.shortName), c.label])
|
||||
)
|
||||
const seen = new Set<string>()
|
||||
const options: ReflectionCreatableType[] = []
|
||||
for (const r of fieldRows.value) {
|
||||
if (seen.has(r.carType)) continue
|
||||
seen.add(r.carType)
|
||||
const carType = r.carType
|
||||
const dot = carType.lastIndexOf('.')
|
||||
const assemblyName = dot >= 0 ? carType.slice(0, dot) : ''
|
||||
const shortName = dot >= 0 ? carType.slice(dot + 1) : carType
|
||||
options.push({
|
||||
typeName: carType,
|
||||
shortName,
|
||||
label: labelByKey.get(carType) || shortName,
|
||||
assemblyName
|
||||
})
|
||||
}
|
||||
carTypes.value = options
|
||||
}
|
||||
|
||||
/** 从 SimpleLite 补全车型中文名(轻量接口,不拉字段定义)。 */
|
||||
async function enrichCarTypeLabels() {
|
||||
try {
|
||||
const types = await reflectionApi.listCreatableTypes('car')
|
||||
const labelMap = new Map(
|
||||
types.map((t) => [buildCarType(t.assemblyName, t.shortName), t.label || t.shortName])
|
||||
)
|
||||
carTypes.value = carTypes.value.map((c) => {
|
||||
const key = buildCarType(c.assemblyName, c.shortName)
|
||||
const label = labelMap.get(key)
|
||||
return label ? { ...c, label } : c
|
||||
})
|
||||
} catch {
|
||||
/* SimpleLite 未连接时保留现有显示 */
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDefaultCarType() {
|
||||
if (filterCarType.value) return
|
||||
if (carTypes.value.length) {
|
||||
const first = carTypes.value[0]
|
||||
filterCarType.value = buildCarType(first.assemblyName, first.shortName)
|
||||
} else if (fieldRows.value.length) {
|
||||
filterCarType.value = fieldRows.value[0].carType
|
||||
}
|
||||
}
|
||||
|
||||
function carTypeCn(c: ReflectionCreatableType) {
|
||||
if (c.label && c.label !== c.shortName) return c.label
|
||||
return c.label || c.shortName
|
||||
}
|
||||
|
||||
function carTypeSelectLabel(c: ReflectionCreatableType, fullCarType = true) {
|
||||
const en = fullCarType ? buildCarType(c.assemblyName, c.shortName) : c.shortName
|
||||
return `${carTypeCn(c)} (${en})`
|
||||
}
|
||||
|
||||
async function loadFromDb(silent = false) {
|
||||
loading.value = true
|
||||
try {
|
||||
const records = await simpleFieldApi.listSimpleFields()
|
||||
fieldRows.value = records.map(recordToRow)
|
||||
syncCarTypesFromFieldRows()
|
||||
await enrichCarTypeLabels()
|
||||
ensureDefaultCarType()
|
||||
if (!silent) ElMessage.success(`已从数据库加载 ${fieldRows.value.length} 条字段`)
|
||||
} catch (e) {
|
||||
fieldRows.value = []
|
||||
carTypes.value = []
|
||||
ElMessage.error(e instanceof Error ? e.message : '从数据库加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDefaults() {
|
||||
loadingDefaults.value = true
|
||||
try {
|
||||
const cars = await reflectionApi.getCarTypeCoderFields()
|
||||
if (!cars.length) {
|
||||
ElMessage.warning('未获取到车型列表,请确认 SimpleLite 已启动')
|
||||
return
|
||||
}
|
||||
carTypes.value = cars.map((c) => ({
|
||||
typeName: c.typeName,
|
||||
shortName: c.shortName,
|
||||
label: c.label,
|
||||
assemblyName: c.assemblyName
|
||||
}))
|
||||
ensureDefaultCarType()
|
||||
fieldRows.value = allCarsToRows(cars)
|
||||
ElMessage.success(`已从 SimpleLite 加载 ${cars.length} 种车型、共 ${fieldRows.value.length} 条默认字段(请点击保存写入数据库)`)
|
||||
} catch (e) {
|
||||
ElMessage.error(e instanceof Error ? e.message : '加载默认字段失败')
|
||||
} finally {
|
||||
loadingDefaults.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAll() {
|
||||
if (!fieldRows.value.length) return
|
||||
saving.value = true
|
||||
try {
|
||||
const { count } = await simpleFieldApi.saveSimpleFieldsBatch({
|
||||
replaceAll: true,
|
||||
items: fieldRows.value.map((r) => ({
|
||||
carType: r.carType,
|
||||
fieldType: r.fieldType,
|
||||
key: r.key,
|
||||
value: r.value,
|
||||
dataType: r.dataType,
|
||||
chinese: r.chinese ?? '',
|
||||
english: r.english ?? '',
|
||||
other: r.other,
|
||||
isDefault: r.isDefault
|
||||
}))
|
||||
})
|
||||
ElMessage.success(`已保存 ${count} 条字段到数据库`)
|
||||
await loadFromDb(true)
|
||||
} catch (e) {
|
||||
ElMessage.error(e instanceof Error ? e.message : '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
editingRowKey.value = ''
|
||||
form.carType = filterCarType.value
|
||||
|| (carTypes.value[0] ? buildCarType(carTypes.value[0].assemblyName, carTypes.value[0].shortName) : '')
|
||||
form.fieldType = activeCategory.value
|
||||
form.key = ''
|
||||
form.value = ''
|
||||
form.dataType = 'System.String'
|
||||
form.chinese = ''
|
||||
form.english = ''
|
||||
form.other = ''
|
||||
form.isDefault = false
|
||||
}
|
||||
|
||||
function openDialog(row?: FieldRow) {
|
||||
resetForm()
|
||||
if (row) {
|
||||
editingRowKey.value = row.rowKey
|
||||
form.carType = row.carType
|
||||
form.fieldType = row.fieldType
|
||||
form.key = row.key
|
||||
form.value = row.value
|
||||
form.dataType = row.dataType
|
||||
form.chinese = row.chinese ?? ''
|
||||
form.english = row.english ?? ''
|
||||
form.other = row.other
|
||||
form.isDefault = row.isDefault
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function applyDialog() {
|
||||
if (!form.carType.trim()) {
|
||||
ElMessage.warning('请选择车型')
|
||||
return
|
||||
}
|
||||
if (!form.key.trim()) {
|
||||
ElMessage.warning('请填写属性')
|
||||
return
|
||||
}
|
||||
if (!form.dataType.trim()) {
|
||||
ElMessage.warning('请选择数据类型')
|
||||
return
|
||||
}
|
||||
if (!form.value.trim()) {
|
||||
ElMessage.warning('请填写默认值')
|
||||
return
|
||||
}
|
||||
const rowKey = makeRowKey(form.carType.trim(), form.fieldType, form.key.trim())
|
||||
const payload: FieldRow = {
|
||||
rowKey,
|
||||
carType: form.carType.trim(),
|
||||
fieldType: form.fieldType,
|
||||
key: form.key.trim(),
|
||||
dataType: form.dataType,
|
||||
value: form.value,
|
||||
chinese: form.chinese,
|
||||
english: form.english,
|
||||
other: form.other,
|
||||
isDefault: editingRowKey.value ? form.isDefault : false,
|
||||
}
|
||||
if (editingRowKey.value) {
|
||||
const idx = fieldRows.value.findIndex((r) => r.rowKey === editingRowKey.value)
|
||||
if (idx >= 0) {
|
||||
fieldRows.value[idx] = {
|
||||
...fieldRows.value[idx],
|
||||
value: form.value,
|
||||
chinese: form.chinese,
|
||||
english: form.english,
|
||||
other: form.other,
|
||||
isDefault: form.isDefault
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (fieldRows.value.some((r) => r.rowKey === rowKey)) {
|
||||
ElMessage.warning('该车型下该字段已存在')
|
||||
return
|
||||
}
|
||||
fieldRows.value.push(payload)
|
||||
}
|
||||
activeCategory.value = form.fieldType
|
||||
dialogVisible.value = false
|
||||
}
|
||||
|
||||
async function removeRow(row: FieldRow) {
|
||||
await ElMessageBox.confirm(`确定删除字段「${row.carType} / ${row.key}」?`, '确认', { type: 'warning' })
|
||||
fieldRows.value = fieldRows.value.filter((r) => r.rowKey !== row.rowKey)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
initializing.value = true
|
||||
try {
|
||||
await loadFromDb(true)
|
||||
} catch (e) {
|
||||
ElMessage.error(e instanceof Error ? e.message : '初始化失败')
|
||||
} finally {
|
||||
initializing.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.simple-field-page {
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
.page-card { min-height: calc(100vh - 88px); }
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.page-header h2 { margin: 0 0 4px; font-size: 18px; }
|
||||
.page-header p { margin: 0; color: var(--el-text-color-secondary); font-size: 13px; }
|
||||
.header-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.car-type-filter,
|
||||
.keyword-filter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.filter-label {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
color: var(--el-text-color-regular);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.car-type-select {
|
||||
width: 480px;
|
||||
}
|
||||
.car-type-select :deep(.el-select__selected-item),
|
||||
.car-type-select :deep(.el-select__placeholder) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.keyword-input {
|
||||
width: 400px;
|
||||
}
|
||||
.bordered-select :deep(.el-select__wrapper) {
|
||||
min-height: 32px;
|
||||
background-color: #fff !important;
|
||||
border: 1px solid #dcdfe6 !important;
|
||||
border-radius: var(--el-border-radius-base, 4px);
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.bordered-select :deep(.el-select__wrapper:hover) {
|
||||
border-color: #c0c4cc !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.bordered-select :deep(.el-select__wrapper.is-focused),
|
||||
.bordered-select :deep(.el-select__wrapper.is-hovering.is-focused) {
|
||||
border-color: var(--el-color-primary, #7c3aed) !important;
|
||||
box-shadow: 0 0 0 1px var(--el-color-primary, #7c3aed) inset !important;
|
||||
}
|
||||
.bordered-select :deep(.el-select__selected-item),
|
||||
.bordered-select :deep(.el-select__placeholder) {
|
||||
line-height: 30px;
|
||||
}
|
||||
.field-tabs { margin-top: 4px; }
|
||||
.field-tabs :deep(.search-hit-row > td.el-table__cell) {
|
||||
background-color: #f5f3ff !important;
|
||||
}
|
||||
.builtin-switch :deep(.el-switch__core) {
|
||||
border: 1px solid #c0c4cc;
|
||||
background-color: #dcdfe6 !important;
|
||||
}
|
||||
.builtin-switch.is-checked :deep(.el-switch__core) {
|
||||
background-color: var(--el-color-primary, #7c3aed) !important;
|
||||
border-color: var(--el-color-primary, #7c3aed) !important;
|
||||
}
|
||||
.builtin-switch :deep(.el-switch__action) {
|
||||
background-color: #fff;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.car-type-option-popper {
|
||||
min-width: 520px !important;
|
||||
}
|
||||
.car-type-option-popper .car-type-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.car-type-option-popper .car-type-option-cn {
|
||||
flex: 0 0 auto;
|
||||
max-width: 11em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.car-type-option-popper .car-type-option-en {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user