优化地图编辑中的字段管理
This commit is contained in:
+273
-83
@@ -76,6 +76,16 @@
|
||||
<el-option v-for="l in layers" :key="l" :label="l" :value="l" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div v-if="directionField" class="prop-card-row prop-direction-row">
|
||||
<label class="prop-name-label">方向</label>
|
||||
<el-input
|
||||
:model-value="directionField.value"
|
||||
size="small"
|
||||
spellcheck="false"
|
||||
@change="(v: string) => onFieldChange('direction', v)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 几何 / 位置:x, y, z, size, angle, rotation, scale, radius, siteA, siteB, ... -->
|
||||
@@ -114,68 +124,80 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 样式:颜色、显示开关 -->
|
||||
<section v-if="styleFields.length > 0" class="prop-card prop-card--style">
|
||||
<!-- 扩展字段:上行 key - 中文,下行整行输入框(站点 / 路段 / 小车共用样式) -->
|
||||
<section
|
||||
v-if="selectionKind === 'site' || selectionKind === 'track' || selectionKind === 'car'"
|
||||
class="prop-card prop-card--custom"
|
||||
>
|
||||
<header class="prop-card-header">
|
||||
<span class="prop-card-title">样式 / 外观</span>
|
||||
<span class="prop-card-meta">{{ styleFields.length }} 项</span>
|
||||
<span class="prop-card-title">扩展字段</span>
|
||||
<span class="prop-card-meta">{{ customFieldRows.length }} 项</span>
|
||||
</header>
|
||||
<div class="prop-grid">
|
||||
<div v-for="f in styleFields" :key="f.key" class="prop-grid-item">
|
||||
<label class="prop-field-label">{{ fieldDisplayLabel(f.key) }}</label>
|
||||
<el-color-picker
|
||||
v-if="isColorField(f.key, f.value)"
|
||||
:model-value="f.value || '#ffffff'"
|
||||
show-alpha
|
||||
size="small"
|
||||
@change="(v: string | null) => onFieldChange(f.key, v ?? '')"
|
||||
/>
|
||||
<el-switch
|
||||
v-else-if="isBooleanField(f.key, f.value)"
|
||||
:model-value="parseBool(f.value)"
|
||||
size="small"
|
||||
@change="(v: any) => onFieldChange(f.key, v ? 'true' : 'false')"
|
||||
/>
|
||||
<el-input
|
||||
v-else
|
||||
:model-value="f.value"
|
||||
size="small"
|
||||
spellcheck="false"
|
||||
@change="(v: string) => onFieldChange(f.key, v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 其它 / 自定义字段(兜底,可添加) -->
|
||||
<section class="prop-card prop-card--custom">
|
||||
<header class="prop-card-header">
|
||||
<span class="prop-card-title">其它字段 / 自定义</span>
|
||||
<span class="prop-card-meta">{{ otherFields.length }} 项</span>
|
||||
</header>
|
||||
<div v-if="otherFields.length > 0" class="prop-grid">
|
||||
<div v-for="f in otherFields" :key="f.key" class="prop-grid-item prop-grid-item--wide">
|
||||
<label class="prop-field-label" :title="f.key">{{ f.key }}</label>
|
||||
<div class="prop-field-row">
|
||||
<el-input
|
||||
:model-value="f.value"
|
||||
size="small"
|
||||
spellcheck="false"
|
||||
@change="(v: string) => onFieldChange(f.key, v)"
|
||||
/>
|
||||
<el-tooltip content="删除该字段" placement="top">
|
||||
<div v-if="fieldCatalogLoading" class="prop-empty-row">加载字段定义…</div>
|
||||
<template v-else>
|
||||
<div v-if="customFieldRows.length > 0" class="custom-field-list">
|
||||
<div v-for="row in customFieldRows" :key="row.key" class="custom-field-item">
|
||||
<label class="prop-field-label custom-field-key" :title="row.label">{{ row.label }}</label>
|
||||
<div class="prop-field-row">
|
||||
<el-input
|
||||
v-model="customFieldDrafts[row.key]"
|
||||
size="small"
|
||||
spellcheck="false"
|
||||
class="custom-field-input"
|
||||
@focus="customFieldFocusKey = row.key"
|
||||
@blur="customFieldFocusKey = null"
|
||||
@change="(v: string) => onCustomFieldCommit(row.key, v)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="prop-field-icon-btn"
|
||||
@click="onFieldChange(f.key, '')"
|
||||
title="删除该字段"
|
||||
@click="onDeleteCustomField(row.key)"
|
||||
>×</button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="prop-empty-row">无其它字段</div>
|
||||
<button class="prop-add-field-btn" @click="onAddField">+ 添加字段</button>
|
||||
<div v-else class="prop-empty-row">暂无扩展字段</div>
|
||||
|
||||
<button
|
||||
class="prop-add-field-btn"
|
||||
:disabled="!addableFieldOptions.length"
|
||||
@click="openAddFieldDialog"
|
||||
>
|
||||
+ 添加字段
|
||||
</button>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<el-dialog
|
||||
v-model="addFieldDialogOpen"
|
||||
title="添加扩展字段"
|
||||
width="420px"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
>
|
||||
<el-select
|
||||
v-model="addFieldPickKey"
|
||||
filterable
|
||||
placeholder="选择要添加的字段"
|
||||
class="add-field-select"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in addableFieldOptions"
|
||||
:key="opt.key"
|
||||
:label="opt.label"
|
||||
:value="opt.key"
|
||||
/>
|
||||
</el-select>
|
||||
<template #footer>
|
||||
<el-button @click="addFieldDialogOpen = false">取消</el-button>
|
||||
<el-button type="primary" :disabled="!addFieldPickKey" @click="confirmAddField">
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 运行状态(只读) -->
|
||||
<section v-if="status.length > 0" class="prop-card prop-card--status">
|
||||
<header class="prop-card-header">
|
||||
@@ -427,8 +449,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { InfoFilled, Loading } from '@element-plus/icons-vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, ref, toRef, watch } from 'vue'
|
||||
import { useSimpleFieldCatalog } from '@/composables/useSimpleFieldCatalog'
|
||||
import {
|
||||
argbToHex,
|
||||
hexToArgb,
|
||||
@@ -455,7 +479,9 @@ const props = defineProps<{
|
||||
track: { layer: string; lineWidth: number; color: string }
|
||||
}
|
||||
layerRows: LayerRow[]
|
||||
selectionKind: 'site' | 'track' | null
|
||||
selectionKind: 'site' | 'track' | 'car' | null
|
||||
/** 当前小车类型(assemblyName.shortName 或 typeName),用于过滤 carFields。 */
|
||||
carType?: string | null
|
||||
viewportStyle: ViewportStylePayload | null
|
||||
viewportStyleLoading?: boolean
|
||||
viewportSyncing?: boolean
|
||||
@@ -465,7 +491,7 @@ const emit = defineEmits<{
|
||||
(e: 'rename', name: string): void
|
||||
(e: 'change-layer', layer: string): void
|
||||
(e: 'set-field', key: string, value: string): void
|
||||
(e: 'add-field'): void
|
||||
(e: 'delete-field', key: string): void
|
||||
(e: 'exec-action', method: string): void
|
||||
(e: 'save-defaults'): void
|
||||
(e: 'layer-toggle', row: LayerRow): void
|
||||
@@ -541,14 +567,13 @@ function flashSync() {
|
||||
// public 字段都吐出来(含 `id / name / layerName / x / y / z / color /
|
||||
// displaySetting / radius / direction / typeInfo / siteA / siteB / mustFree / ...`)。
|
||||
// 这里的策略:
|
||||
// - 顶部 Header 卡片已经覆盖 id / name / layerName / status,所以从 fields 中剔除这些;
|
||||
// - 顶部 Header 卡片已经覆盖 id / name / layerName / direction / status;
|
||||
// - 「位置 / 几何」吃掉所有跟坐标 / 角度 / 大小 / 半径 / 端点站点强相关的 key;
|
||||
// - 「样式 / 外观」吃掉 color / displaySetting / direction / typeInfo / mustFree / 文字渲染相关;
|
||||
// - 剩下的统统进「其它字段」(含用户 [FieldMember] 自定义、tag 反序、随便加上去的 free-form key)。
|
||||
// - 剩下的统统进「其它字段」(含 displaySetting / typeInfo / color 及用户自定义字段)。
|
||||
// 这样不管后端塞什么字段进来都能露出来 + 可编辑。
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const HEADER_KEYS = new Set(['id', 'name', 'layerName'])
|
||||
const HEADER_KEYS = new Set(['id', 'name', 'layerName', 'direction'])
|
||||
|
||||
const POSITION_KEYS = new Set([
|
||||
'x', 'y', 'z',
|
||||
@@ -563,20 +588,145 @@ const POSITION_KEYS = new Set([
|
||||
'siteA', 'siteB'
|
||||
])
|
||||
|
||||
const STYLE_KEYS = new Set([
|
||||
'color',
|
||||
'displaySetting',
|
||||
'direction',
|
||||
'typeInfo',
|
||||
'mustFree',
|
||||
'imagePath',
|
||||
'modelPath',
|
||||
'fontFamily',
|
||||
'fontSize',
|
||||
'textColor',
|
||||
'lineWidth',
|
||||
'opacity'
|
||||
])
|
||||
const editableFields = computed(() => props.fields.filter((f) => !HEADER_KEYS.has(f.key)))
|
||||
|
||||
const directionField = computed(() => props.fields.find((f) => f.key === 'direction'))
|
||||
|
||||
const positionFields = computed(() => editableFields.value.filter((f) => POSITION_KEYS.has(f.key)))
|
||||
const otherFields = computed(() =>
|
||||
editableFields.value.filter((f) => !POSITION_KEYS.has(f.key))
|
||||
)
|
||||
|
||||
const selectionKindRef = toRef(props, 'selectionKind')
|
||||
const carTypeRef = computed(() => props.carType ?? null)
|
||||
|
||||
const {
|
||||
loading: fieldCatalogLoading,
|
||||
records: fieldCatalogRecords,
|
||||
labelForKey,
|
||||
defaultValueForKey
|
||||
} = useSimpleFieldCatalog(selectionKindRef, carTypeRef)
|
||||
|
||||
const catalogKeySet = computed(() => new Set(fieldCatalogRecords.value.map((r) => r.key)))
|
||||
const catalogDataTypeByKey = computed(() => {
|
||||
const map = new Map<string, string>()
|
||||
for (const r of fieldCatalogRecords.value) {
|
||||
if (!map.has(r.key)) map.set(r.key, r.dataType || '')
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
function customFieldDisplayLabel(key: string, chinese: string): string {
|
||||
const cn = chinese.trim()
|
||||
if (!cn || cn === '—') return key
|
||||
return `${key} - ${cn}`
|
||||
}
|
||||
|
||||
/** 仅展示已在对象上存在、且在 simple_fields 目录中的扩展字段 */
|
||||
const customFieldRows = computed(() =>
|
||||
otherFields.value
|
||||
.filter((f) => catalogKeySet.value.has(f.key))
|
||||
.map((f) => {
|
||||
const chinese = labelForKey(f.key)
|
||||
return {
|
||||
key: f.key,
|
||||
value: f.value,
|
||||
dataType: catalogDataTypeByKey.value.get(f.key) ?? '',
|
||||
chinese,
|
||||
label: customFieldDisplayLabel(f.key, chinese)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const addableFieldOptions = computed(() => {
|
||||
const existing = new Set(customFieldRows.value.map((r) => r.key))
|
||||
return fieldCatalogRecords.value
|
||||
.filter((r) => !existing.has(r.key))
|
||||
.map((r) => ({
|
||||
key: r.key,
|
||||
label: r.chinese?.trim()
|
||||
? `${r.key} · ${r.chinese}`
|
||||
: (r.english?.trim() ? `${r.key} · ${r.english}` : r.key)
|
||||
}))
|
||||
})
|
||||
|
||||
const addFieldDialogOpen = ref(false)
|
||||
const addFieldPickKey = ref('')
|
||||
/** 扩展字段本地草稿;避免受控 input 无 update 导致无法键入,并抵御 SSE 刷新打断编辑。 */
|
||||
const customFieldDrafts = ref<Record<string, string>>({})
|
||||
const customFieldFocusKey = ref<string | null>(null)
|
||||
|
||||
watch(
|
||||
customFieldRows,
|
||||
(rows) => {
|
||||
const next = { ...customFieldDrafts.value }
|
||||
for (const row of rows) {
|
||||
if (customFieldFocusKey.value !== row.key) next[row.key] = row.value
|
||||
}
|
||||
for (const key of Object.keys(next)) {
|
||||
if (!rows.some((r) => r.key === key)) delete next[key]
|
||||
}
|
||||
customFieldDrafts.value = next
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
function validateCustomFieldValue(value: string, dataType: string): string | null {
|
||||
const t = dataType.trim().toLowerCase()
|
||||
if (!t || t.includes('string')) return null
|
||||
if (value.trim() === '') return null
|
||||
|
||||
if (t.includes('bool')) {
|
||||
return /^(true|false|1|0)$/i.test(value.trim())
|
||||
? null
|
||||
: '只允许 true / false / 1 / 0'
|
||||
}
|
||||
|
||||
if (t.includes('int16') || t.includes('int32') || t.includes('int64') ||
|
||||
t.includes('uint16') || t.includes('uint32') || t.includes('uint64') ||
|
||||
t.endsWith('.int') || t.endsWith('.long') || t.endsWith('.short')) {
|
||||
return /^-?\d+$/.test(value.trim()) ? null : '只允许整数'
|
||||
}
|
||||
|
||||
if (t.includes('single') || t.includes('double') || t.includes('float') || t.includes('decimal')) {
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? null : '只允许数字'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function onCustomFieldCommit(key: string, value: string) {
|
||||
const row = customFieldRows.value.find((r) => r.key === key)
|
||||
if (row) {
|
||||
const err = validateCustomFieldValue(value, row.dataType)
|
||||
if (err) {
|
||||
customFieldDrafts.value[key] = row.value
|
||||
ElMessage.warning(`${row.label} 校验失败:${err}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
customFieldDrafts.value[key] = value
|
||||
onFieldChange(key, value)
|
||||
}
|
||||
|
||||
function openAddFieldDialog() {
|
||||
addFieldPickKey.value = addableFieldOptions.value[0]?.key ?? ''
|
||||
addFieldDialogOpen.value = true
|
||||
}
|
||||
|
||||
function confirmAddField() {
|
||||
const key = addFieldPickKey.value
|
||||
if (!key) return
|
||||
const value = defaultValueForKey(key)
|
||||
customFieldDrafts.value = { ...customFieldDrafts.value, [key]: value }
|
||||
addFieldDialogOpen.value = false
|
||||
emit('set-field', key, value)
|
||||
}
|
||||
|
||||
function onDeleteCustomField(key: string) {
|
||||
emit('delete-field', key)
|
||||
}
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
x: 'X',
|
||||
@@ -624,14 +774,6 @@ const FIELD_UNITS: Record<string, string> = {
|
||||
fontSize: 'px', lineWidth: 'px'
|
||||
}
|
||||
|
||||
const editableFields = computed(() => props.fields.filter((f) => !HEADER_KEYS.has(f.key)))
|
||||
|
||||
const positionFields = computed(() => editableFields.value.filter((f) => POSITION_KEYS.has(f.key)))
|
||||
const styleFields = computed(() => editableFields.value.filter((f) => STYLE_KEYS.has(f.key)))
|
||||
const otherFields = computed(() =>
|
||||
editableFields.value.filter((f) => !POSITION_KEYS.has(f.key) && !STYLE_KEYS.has(f.key))
|
||||
)
|
||||
|
||||
function fieldDisplayLabel(key: string): string {
|
||||
return FIELD_LABELS[key] ?? key
|
||||
}
|
||||
@@ -692,7 +834,6 @@ function onFieldChange(key: string, value: string) {
|
||||
emit('set-field', key, value)
|
||||
if (POSITION_KEYS.has(key)) flashSync()
|
||||
}
|
||||
function onAddField() { emit('add-field') }
|
||||
async function onAction(method: string) {
|
||||
try {
|
||||
actionBusy.value = method
|
||||
@@ -909,7 +1050,7 @@ function onDelete() {
|
||||
.prop-delete-btn:disabled { opacity: 0.55; cursor: progress; }
|
||||
.prop-delete-icon { font-size: 12px; line-height: 1; }
|
||||
|
||||
.prop-name-row, .prop-layer-row { gap: 8px; }
|
||||
.prop-name-row, .prop-layer-row, .prop-direction-row { gap: 8px; }
|
||||
.prop-name-label {
|
||||
width: 36px;
|
||||
font-size: 11.5px;
|
||||
@@ -991,6 +1132,55 @@ function onDelete() {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.custom-field-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.custom-field-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.custom-field-key {
|
||||
margin: 0;
|
||||
font-family: var(--mg-font-mono, ui-monospace, monospace);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: rgba(200, 180, 230, 0.88);
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
line-height: 1.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.custom-field-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.custom-field-input :deep(.el-input__wrapper) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
box-shadow: inset 0 0 0 1px rgba(170, 110, 250, 0.18);
|
||||
}
|
||||
|
||||
.custom-field-input :deep(.el-input__wrapper:hover) {
|
||||
box-shadow: inset 0 0 0 1px rgba(170, 110, 250, 0.32);
|
||||
}
|
||||
|
||||
.custom-field-input :deep(.el-input__wrapper.is-focus) {
|
||||
box-shadow: inset 0 0 0 1px rgba(190, 130, 255, 0.55);
|
||||
}
|
||||
|
||||
.add-field-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.prop-status-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.prop-status-item {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
|
||||
@@ -2,8 +2,8 @@ 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
|
||||
defaultQuickKeys, finalizeQuickKeys, getQuickEntryCatalog, isMandatoryQuickKey,
|
||||
MAX_QUICK_ENTRIES, normalizeQuickKeys, resolveQuickEntry, type QuickEntryDef
|
||||
} from '@/config/quickEntries'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { Scope } from '@/types/auth'
|
||||
@@ -20,8 +20,10 @@ export function useDashboardQuickEntries() {
|
||||
|
||||
/** 空列表仅在「尚未自定义」(usingDefaults) 时回退系统默认;用户主动清空则保持为空 */
|
||||
function effectiveKeys(): string[] {
|
||||
if (keys.value.length > 0) return keys.value
|
||||
return usingDefaults.value ? defaultQuickKeys(scope.value as Scope) : []
|
||||
const base = keys.value.length > 0
|
||||
? keys.value
|
||||
: (usingDefaults.value ? defaultQuickKeys(scope.value as Scope) : [])
|
||||
return finalizeQuickKeys(base, scope.value as Scope)
|
||||
}
|
||||
|
||||
function filterByPermission(list: string[]): string[] {
|
||||
@@ -63,8 +65,8 @@ export function useDashboardQuickEntries() {
|
||||
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) : [])
|
||||
? finalizeQuickKeys(loaded, scope.value as Scope)
|
||||
: (dto.usingDefaults ? defaultQuickKeys(scope.value as Scope) : finalizeQuickKeys([], scope.value as Scope))
|
||||
usingDefaults.value = dto.usingDefaults
|
||||
} catch (e) {
|
||||
keys.value = defaultQuickKeys(scope.value as Scope)
|
||||
@@ -76,13 +78,14 @@ export function useDashboardQuickEntries() {
|
||||
}
|
||||
|
||||
async function persist(nextKeys: string[]) {
|
||||
const finalized = finalizeQuickKeys(nextKeys, scope.value as Scope)
|
||||
if (!userId.value) {
|
||||
keys.value = nextKeys
|
||||
keys.value = finalized
|
||||
return
|
||||
}
|
||||
try {
|
||||
const dto = await saveQuickEntryKeys(userId.value, scope.value, normalizeQuickKeys(nextKeys))
|
||||
keys.value = normalizeQuickKeys(dto.keys)
|
||||
const dto = await saveQuickEntryKeys(userId.value, scope.value, finalized)
|
||||
keys.value = finalizeQuickKeys(normalizeQuickKeys(dto.keys), scope.value as Scope)
|
||||
usingDefaults.value = dto.usingDefaults
|
||||
} catch (e) {
|
||||
ElMessage.error(`保存快捷入口失败:${e instanceof Error ? e.message : String(e)}`)
|
||||
@@ -105,6 +108,10 @@ export function useDashboardQuickEntries() {
|
||||
}
|
||||
|
||||
async function removeKey(key: string) {
|
||||
if (isMandatoryQuickKey(key, scope.value as Scope)) {
|
||||
ElMessage.warning('该快捷入口为系统默认项,不可移除')
|
||||
return
|
||||
}
|
||||
const base = [...effectiveKeys()]
|
||||
await persist(base.filter((k) => k !== key))
|
||||
ElMessage.success('已移除快捷入口')
|
||||
@@ -140,6 +147,7 @@ export function useDashboardQuickEntries() {
|
||||
addKey,
|
||||
removeKey,
|
||||
swapKeys,
|
||||
openPicker
|
||||
openPicker,
|
||||
isMandatoryQuickKey: (key: string) => isMandatoryQuickKey(key, scope.value as Scope)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { computed, ref, watch, type Ref } from 'vue'
|
||||
import { listSimpleFields } from '@/api/simpleField'
|
||||
import type { SimpleFieldCategory, SimpleFieldRecord } from '@/types/simpleField'
|
||||
|
||||
export function fieldCategoryForMapKind(kind: 'site' | 'track' | 'car' | null): SimpleFieldCategory | null {
|
||||
if (kind === 'site') return 'siteFields'
|
||||
if (kind === 'track') return 'trackFields'
|
||||
if (kind === 'car') return 'carFields'
|
||||
return null
|
||||
}
|
||||
|
||||
/** 按 key 去重(同 key 多条车型定义时保留第一条)。 */
|
||||
function dedupeByKey(rows: SimpleFieldRecord[]): SimpleFieldRecord[] {
|
||||
const seen = new Set<string>()
|
||||
const out: SimpleFieldRecord[] = []
|
||||
for (const row of rows) {
|
||||
if (seen.has(row.key)) continue
|
||||
seen.add(row.key)
|
||||
out.push(row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function normalizeCarTypeToken(v: string | null | undefined): string {
|
||||
return (v ?? '').trim().replace(/^UI/i, '')
|
||||
}
|
||||
|
||||
function shortCarType(v: string): string {
|
||||
const s = v.trim()
|
||||
const i = s.lastIndexOf('.')
|
||||
return i >= 0 ? s.slice(i + 1) : s
|
||||
}
|
||||
|
||||
function filterRowsByCarType(rows: SimpleFieldRecord[], current: string | null | undefined): SimpleFieldRecord[] {
|
||||
const token = shortCarType(normalizeCarTypeToken(current)).toLowerCase()
|
||||
if (!token) return []
|
||||
return rows.filter((r) => shortCarType(r.carType).toLowerCase() === token)
|
||||
}
|
||||
|
||||
export function useSimpleFieldCatalog(
|
||||
kind: Ref<'site' | 'track' | 'car' | null>,
|
||||
carType?: Ref<string | null | undefined>
|
||||
) {
|
||||
const loading = ref(false)
|
||||
const records = ref<SimpleFieldRecord[]>([])
|
||||
|
||||
const fieldType = computed(() => fieldCategoryForMapKind(kind.value))
|
||||
|
||||
const byKey = computed(() => {
|
||||
const map = new Map<string, SimpleFieldRecord>()
|
||||
for (const row of records.value) map.set(row.key, row)
|
||||
return map
|
||||
})
|
||||
|
||||
async function reload() {
|
||||
const ft = fieldType.value
|
||||
if (!ft) {
|
||||
records.value = []
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
// 车辆:为兼容 shortName / assembly.shortName 等不同 car_type 存储格式,先拉 carFields 再前端匹配当前车型。
|
||||
if (ft === 'carFields') {
|
||||
const all = await listSimpleFields(ft)
|
||||
records.value = dedupeByKey(filterRowsByCarType(all, carType?.value))
|
||||
} else {
|
||||
const rows = await listSimpleFields(ft)
|
||||
records.value = dedupeByKey(rows)
|
||||
}
|
||||
} catch {
|
||||
records.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(fieldType, () => { void reload() }, { immediate: true })
|
||||
|
||||
function labelForKey(key: string): string {
|
||||
const row = byKey.value.get(key)
|
||||
return row?.chinese?.trim() || row?.english?.trim() || '—'
|
||||
}
|
||||
|
||||
function defaultValueForKey(key: string): string {
|
||||
return byKey.value.get(key)?.value ?? ''
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
records,
|
||||
fieldType,
|
||||
byKey,
|
||||
reload,
|
||||
labelForKey,
|
||||
defaultValueForKey
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
Collection, Connection, Cpu, Document, DocumentCopy, EditPen,
|
||||
Histogram, Link, MapLocation, Monitor, Notebook, OfficeBuilding,
|
||||
Operation, Promotion, SetUp, Setting, Tools, User, Van, VideoCamera
|
||||
Collection, Connection, Cpu, Document, DocumentCopy, EditPen, Files,
|
||||
Histogram, Link, MapLocation, Monitor, Notebook, OfficeBuilding, Odometer,
|
||||
Operation, Promotion, SetUp, Setting, Tools, User, Van, VideoCamera, View
|
||||
} from '@element-plus/icons-vue'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
@@ -16,11 +16,11 @@ export interface 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/map-monitor', label: '地图监控', icon: View, 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/maps', label: '地图管理', icon: Files, 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: '设计与编排' },
|
||||
|
||||
@@ -33,11 +33,19 @@ const LEGACY_KEY_ALIASES: Record<string, string> = {
|
||||
tasks: 'admin-config-strategy'
|
||||
}
|
||||
|
||||
/** Platform 域下固定保留、不可删除的快捷入口(顺序即展示优先级) */
|
||||
export const MANDATORY_PLATFORM_QUICK_KEYS = [
|
||||
'admin-maps',
|
||||
'admin-map-editor',
|
||||
'admin-cars',
|
||||
'admin-map-monitor',
|
||||
'admin-vehicle-hub',
|
||||
'admin-task-templates'
|
||||
] as const
|
||||
|
||||
/** 与后端 DashboardShortcutCatalog.DefaultPlatformKeys 对齐(均为菜单 key) */
|
||||
export const DEFAULT_PLATFORM_QUICK_KEYS = [
|
||||
'admin-map-editor',
|
||||
'admin-task-templates',
|
||||
'admin-cars',
|
||||
...MANDATORY_PLATFORM_QUICK_KEYS,
|
||||
'admin-config-system-center',
|
||||
'admin-config-ops-center',
|
||||
'admin-config-strategy'
|
||||
@@ -63,6 +71,25 @@ export function normalizeQuickKeys(keys: string[]): string[] {
|
||||
return out
|
||||
}
|
||||
|
||||
export function isMandatoryQuickKey(key: string, scope: Scope): boolean {
|
||||
if (scope !== 'Platform') return false
|
||||
return (MANDATORY_PLATFORM_QUICK_KEYS as readonly string[]).includes(normalizeQuickKey(key))
|
||||
}
|
||||
|
||||
/** 保证固定四项始终存在且排在最前(其余项保持原顺序) */
|
||||
export function ensureMandatoryQuickKeys(keys: string[], scope: Scope): string[] {
|
||||
const normalized = normalizeQuickKeys(keys)
|
||||
if (scope !== 'Platform') return normalized
|
||||
|
||||
const mandatory = [...MANDATORY_PLATFORM_QUICK_KEYS]
|
||||
const rest = normalized.filter((k) => !(MANDATORY_PLATFORM_QUICK_KEYS as readonly string[]).includes(k))
|
||||
return [...mandatory, ...rest].slice(0, MAX_QUICK_ENTRIES)
|
||||
}
|
||||
|
||||
export function finalizeQuickKeys(keys: string[], scope: Scope): string[] {
|
||||
return ensureMandatoryQuickKeys(normalizeQuickKeys(keys), scope)
|
||||
}
|
||||
|
||||
function menuItemToQuick(item: NavMenuItem): QuickEntryDef | null {
|
||||
if (!item.key || EXCLUDED_QUICK_ENTRY_KEYS.has(item.key)) return null
|
||||
return {
|
||||
@@ -96,7 +123,7 @@ export function resolveQuickEntry(key: string, scope: Scope): QuickEntryDef | un
|
||||
export function defaultQuickKeys(scope: Scope): string[] {
|
||||
return scope === 'RCSMonitor'
|
||||
? [...DEFAULT_MONITOR_QUICK_KEYS]
|
||||
: [...DEFAULT_PLATFORM_QUICK_KEYS]
|
||||
: finalizeQuickKeys([...DEFAULT_PLATFORM_QUICK_KEYS], scope)
|
||||
}
|
||||
|
||||
export function groupQuickEntries(items: QuickEntryDef[]): { group: string; items: QuickEntryDef[] }[] {
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
@click="onQuickClick(item)"
|
||||
>
|
||||
<span
|
||||
v-if="item.key !== 'add'"
|
||||
v-if="item.key !== 'add' && !isMandatoryQuickKey(item.key)"
|
||||
class="quick-item-remove"
|
||||
title="移除"
|
||||
@pointerdown.stop
|
||||
@@ -407,7 +407,8 @@ const {
|
||||
openPicker,
|
||||
addKey,
|
||||
removeKey,
|
||||
swapKeys
|
||||
swapKeys,
|
||||
isMandatoryQuickKey
|
||||
} = useDashboardQuickEntries()
|
||||
|
||||
interface QuickTile {
|
||||
|
||||
@@ -41,13 +41,14 @@
|
||||
:defaults="defaults"
|
||||
:layer-rows="layerRows"
|
||||
:selection-kind="selectionKind"
|
||||
:car-type="selectionCarType"
|
||||
:viewport-style="viewportStyle"
|
||||
:viewport-style-loading="viewportStyleLoading"
|
||||
:viewport-syncing="viewportSyncing"
|
||||
@rename="onRename"
|
||||
@change-layer="onChangeLayer"
|
||||
@set-field="onSetField"
|
||||
@add-field="onAddField"
|
||||
@delete-field="onDeleteField"
|
||||
@exec-action="onExecAction"
|
||||
@save-defaults="onSaveDefaults"
|
||||
@layer-toggle="onLayerToggle"
|
||||
@@ -241,15 +242,23 @@ let viewportPatchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let viewportPatchSeq = 0
|
||||
let pendingViewportPatch: { site?: ViewportStyleSite; track?: ViewportStyleTrack } = {}
|
||||
|
||||
/** 单选且为 site/track 时驱动「类型默认」面板;多选或车型等返回 null。 */
|
||||
const selectionKind = computed<'site' | 'track' | null>(() => {
|
||||
/** 单选且为 site/track/car 时驱动属性扩展;类型默认 Tab 仍只支持 site/track。 */
|
||||
const selectionKind = computed<'site' | 'track' | 'car' | null>(() => {
|
||||
const items = selection.items.value
|
||||
if (items.length !== 1) return null
|
||||
const k = items[0]!.kind
|
||||
if (k === 'site' || k === 'track') return k
|
||||
if (k === 'car') return 'car'
|
||||
return null
|
||||
})
|
||||
|
||||
/** 当前选中车的车型唯一标识(用于 simple_fields.carFields 过滤)。 */
|
||||
const selectionCarType = computed<string | null>(() => {
|
||||
if (selectionKind.value !== 'car') return null
|
||||
// 选中车后 primary 会刷新为真实对象摘要,typeName 比 selection item 更可靠(不是 "Car" 占位名)。
|
||||
return primary.value?.typeName?.trim() || null
|
||||
})
|
||||
|
||||
const knownLayers = ref<string[]>(['g'])
|
||||
interface LayerRow { name: string; visible: boolean; selectable: boolean; color: string }
|
||||
const layerRows = ref<LayerRow[]>([
|
||||
@@ -1138,7 +1147,7 @@ async function applyBatchAsCommand(label: string, ops: { action: 'create' | 'del
|
||||
// 属性面板交互
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function refreshPrimary() {
|
||||
async function refreshPrimary(opts?: { silent?: boolean }) {
|
||||
if (selection.items.value.length === 0) {
|
||||
primary.value = null
|
||||
primaryFields.value = []
|
||||
@@ -1147,7 +1156,7 @@ async function refreshPrimary() {
|
||||
return
|
||||
}
|
||||
const first = selection.items.value[0]!
|
||||
primaryLoadingStart()
|
||||
if (!opts?.silent) primaryLoadingStart()
|
||||
try {
|
||||
const b = await reflectionApi.getBundle(first.kind, first.id)
|
||||
primary.value = b.summary
|
||||
@@ -1157,13 +1166,32 @@ async function refreshPrimary() {
|
||||
} catch (err) {
|
||||
ElMessage.error(`加载属性失败:${(err as Error).message}`)
|
||||
} finally {
|
||||
primaryLoadingEnd()
|
||||
if (!opts?.silent) primaryLoadingEnd()
|
||||
}
|
||||
}
|
||||
|
||||
function primaryLoadingStart() { propertyLoading.value = true }
|
||||
function primaryLoadingEnd() { propertyLoading.value = false }
|
||||
|
||||
function patchPrimaryFieldLocal(key: string, value: string) {
|
||||
const idx = primaryFields.value.findIndex((f) => f.key === key)
|
||||
if (idx >= 0) primaryFields.value[idx] = { key, value }
|
||||
else primaryFields.value = [...primaryFields.value, { key, value }]
|
||||
}
|
||||
|
||||
function removePrimaryFieldLocal(key: string) {
|
||||
primaryFields.value = primaryFields.value.filter((f) => f.key !== key)
|
||||
}
|
||||
|
||||
let silentRefreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function scheduleSilentRefreshPrimary() {
|
||||
if (silentRefreshTimer) clearTimeout(silentRefreshTimer)
|
||||
silentRefreshTimer = setTimeout(() => {
|
||||
silentRefreshTimer = null
|
||||
void refreshPrimary({ silent: true })
|
||||
}, 200)
|
||||
}
|
||||
|
||||
async function onRename(name: string) {
|
||||
if (!primary.value || selection.items.value.length === 0) return
|
||||
const it = selection.items.value[0]!
|
||||
@@ -1205,34 +1233,55 @@ async function onChangeLayer(layer: string) {
|
||||
async function onSetField(key: string, value: string) {
|
||||
if (!primary.value) return
|
||||
const it = selection.items.value[0]!
|
||||
// 抓原值以便 revert 真正回滚
|
||||
let oldValue: string | undefined
|
||||
try {
|
||||
const b = await reflectionApi.getBundle(it.kind, primary.value.id)
|
||||
oldValue = b.fields?.[key] != null ? String(b.fields[key]) : undefined
|
||||
} catch { /* 抓不到原值就只能不回滚 */ }
|
||||
const hadKey = primaryFields.value.some((f) => f.key === key)
|
||||
const oldValue = primaryFields.value.find((f) => f.key === key)?.value
|
||||
|
||||
await history.run({
|
||||
label: `修改 ${key}`,
|
||||
apply: async () => { await reflectionApi.setField(it.kind, primary.value!.id, key, value) },
|
||||
revert: async () => {
|
||||
if (oldValue !== undefined) {
|
||||
try { await reflectionApi.setField(it.kind, primary.value!.id, key, oldValue) }
|
||||
catch (err) { ElMessage.warning(`回滚 ${key} 失败:${(err as Error).message}`) }
|
||||
} else {
|
||||
ElMessage.warning(`回滚 ${key}:没拿到原值快照,仅本地撤销`)
|
||||
patchPrimaryFieldLocal(key, value)
|
||||
try {
|
||||
await history.run({
|
||||
label: hadKey ? `修改 ${key}` : `添加字段 ${key}`,
|
||||
apply: async () => { await reflectionApi.setField(it.kind, primary.value!.id, key, value) },
|
||||
revert: async () => {
|
||||
if (oldValue !== undefined) {
|
||||
try { await reflectionApi.setField(it.kind, primary.value!.id, key, oldValue) }
|
||||
catch (err) { ElMessage.warning(`回滚 ${key} 失败:${(err as Error).message}`) }
|
||||
} else {
|
||||
try { await reflectionApi.deleteField(it.kind, primary.value!.id, key) }
|
||||
catch (err) { ElMessage.warning(`回滚字段 ${key} 失败:${(err as Error).message}`) }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
await refreshPrimary()
|
||||
})
|
||||
} catch (err) {
|
||||
if (oldValue !== undefined) patchPrimaryFieldLocal(key, oldValue)
|
||||
else removePrimaryFieldLocal(key)
|
||||
ElMessage.error(`保存字段失败:${(err as Error).message}`)
|
||||
return
|
||||
}
|
||||
scheduleSilentRefreshPrimary()
|
||||
}
|
||||
|
||||
async function onAddField() {
|
||||
async function onDeleteField(key: string) {
|
||||
if (!primary.value) return
|
||||
const it = selection.items.value[0]!
|
||||
const oldValue = primaryFields.value.find((f) => f.key === key)?.value
|
||||
if (oldValue === undefined) return
|
||||
|
||||
removePrimaryFieldLocal(key)
|
||||
try {
|
||||
const k = (await ElMessageBox.prompt('字段名', '新增字段', { inputPattern: /^[A-Za-z0-9_]+$/ })).value
|
||||
const v = (await ElMessageBox.prompt('字段值', '新增字段', { inputValue: '' })).value
|
||||
await onSetField(k, v)
|
||||
} catch { /* cancel */ }
|
||||
await history.run({
|
||||
label: `删除字段 ${key}`,
|
||||
apply: async () => { await reflectionApi.deleteField(it.kind, primary.value!.id, key) },
|
||||
revert: async () => {
|
||||
try { await reflectionApi.setField(it.kind, primary.value!.id, key, oldValue) }
|
||||
catch (err) { ElMessage.warning(`回滚字段 ${key} 失败:${(err as Error).message}`) }
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
patchPrimaryFieldLocal(key, oldValue)
|
||||
ElMessage.error(`删除字段失败:${(err as Error).message}`)
|
||||
return
|
||||
}
|
||||
scheduleSilentRefreshPrimary()
|
||||
}
|
||||
|
||||
async function onExecAction(method: string) {
|
||||
@@ -1555,8 +1604,7 @@ const stream = useMapEditStream({
|
||||
})
|
||||
|
||||
function refreshAfterMutation() {
|
||||
// 简化:刷新当前选中的属性
|
||||
refreshPrimary()
|
||||
scheduleSilentRefreshPrimary()
|
||||
}
|
||||
|
||||
watch(() => stream.connected.value, (v) => (streamConnected.value = v), { immediate: true })
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<ConfigPageBase
|
||||
section="location"
|
||||
title="库位管理"
|
||||
description="出入库、库存、库位可视化(LocationManagement)"
|
||||
:defaults="DEFAULT_LOCATION">
|
||||
<template #default="{ payload }">
|
||||
<el-tabs model-value="locs">
|
||||
<el-tab-pane name="locs" label="库位">
|
||||
<el-table :data="payload.locations" size="small" border>
|
||||
<el-table-column label="ID" prop="id" width="100" />
|
||||
<el-table-column label="编码" prop="code" width="120" />
|
||||
<el-table-column label="名称" prop="name" />
|
||||
<el-table-column label="站点" prop="siteId" width="100" />
|
||||
<el-table-column label="容量" prop="capacity" width="100" />
|
||||
<el-table-column label="占用">
|
||||
<template #default="s">
|
||||
<el-progress :percentage="Math.round((s.row.occupied / s.row.capacity) * 100)" :stroke-width="10" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane name="rules" label="库存规则">
|
||||
<el-table :data="payload.inventoryRules" size="small" border>
|
||||
<el-table-column label="ID" prop="id" width="100" />
|
||||
<el-table-column label="物料类型" prop="itemType" />
|
||||
<el-table-column label="下限" prop="minQty" width="100" />
|
||||
<el-table-column label="上限" prop="maxQty" width="100" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</template>
|
||||
</ConfigPageBase>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ConfigPageBase from '@/components/ConfigPageBase.vue'
|
||||
import { DEFAULT_LOCATION } from '@/mock/data/configs'
|
||||
</script>
|
||||
Reference in New Issue
Block a user