This commit is contained in:
ArtoriasWu
2026-06-24 16:22:24 +08:00
parent 60b3afb954
commit 4b7ce6790f
24 changed files with 3421 additions and 0 deletions
@@ -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
}
@@ -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>