优化地图编辑中的字段管理

This commit is contained in:
18086616529
2026-06-26 09:23:16 +08:00
parent e7bd326ea2
commit ce66da772e
10 changed files with 598 additions and 139 deletions
@@ -59,11 +59,25 @@ public static class DashboardShortcutCatalog
public static readonly int MaxKeysPerUser = 16; public static readonly int MaxKeysPerUser = 16;
/// <summary>Platform 域固定保留、不可删除的快捷入口。</summary>
public static readonly IReadOnlyList<string> MandatoryPlatformKeys =
[
"admin-maps",
"admin-map-editor",
"admin-cars",
"admin-map-monitor",
"admin-vehicle-hub",
"admin-task-templates"
];
public static readonly IReadOnlyList<string> DefaultPlatformKeys = public static readonly IReadOnlyList<string> DefaultPlatformKeys =
[ [
"admin-maps",
"admin-map-editor", "admin-map-editor",
"admin-task-templates",
"admin-cars", "admin-cars",
"admin-map-monitor",
"admin-vehicle-hub",
"admin-task-templates",
"admin-config-system-center", "admin-config-system-center",
"admin-config-ops-center", "admin-config-ops-center",
"admin-config-strategy" "admin-config-strategy"
@@ -96,4 +110,8 @@ public static class DashboardShortcutCatalog
public static string NormalizeKey(string key) => public static string NormalizeKey(string key) =>
LegacyKeyAliases.TryGetValue(key.Trim(), out var canon) ? canon : key.Trim(); LegacyKeyAliases.TryGetValue(key.Trim(), out var canon) ? canon : key.Trim();
public static bool IsMandatoryKey(string key, string scope) =>
string.Equals(scope, PageCatalog.ScopePlatform, StringComparison.OrdinalIgnoreCase)
&& MandatoryPlatformKeys.Contains(NormalizeKey(key), StringComparer.OrdinalIgnoreCase);
} }
@@ -34,14 +34,17 @@ public sealed class DashboardShortcutService
if (row == null) if (row == null)
{ {
var defaults = FilterKeys(DashboardShortcutCatalog.DefaultKeysForScope(scope), scope, allowed); var defaults = EnsureMandatoryKeys(
FilterKeys(DashboardShortcutCatalog.DefaultKeysForScope(scope), scope, allowed),
scope, allowed);
return new QuickEntriesResult( return new QuickEntriesResult(
defaults.Take(DashboardShortcutCatalog.MaxKeysPerUser).ToList(), defaults.Take(DashboardShortcutCatalog.MaxKeysPerUser).ToList(),
UsingDefaults: true); UsingDefaults: true);
} }
var keys = ParseKeys(row.KeysJson); var keys = ParseKeys(row.KeysJson);
var filtered = FilterKeys(keys, scope, allowed) var filtered = EnsureMandatoryKeys(
FilterKeys(keys, scope, allowed), scope, allowed)
.Take(DashboardShortcutCatalog.MaxKeysPerUser) .Take(DashboardShortcutCatalog.MaxKeysPerUser)
.ToList(); .ToList();
return new QuickEntriesResult(filtered, UsingDefaults: false); return new QuickEntriesResult(filtered, UsingDefaults: false);
@@ -52,7 +55,8 @@ public sealed class DashboardShortcutService
{ {
scope = NormalizeScope(scope); scope = NormalizeScope(scope);
var allowed = AllowedPages(userId, scope); var allowed = AllowedPages(userId, scope);
var sanitized = FilterKeys(Deduplicate(keys ?? []), scope, allowed); var sanitized = EnsureMandatoryKeys(
FilterKeys(Deduplicate(keys ?? []), scope, allowed), scope, allowed);
if (sanitized.Count > DashboardShortcutCatalog.MaxKeysPerUser) if (sanitized.Count > DashboardShortcutCatalog.MaxKeysPerUser)
sanitized = sanitized.Take(DashboardShortcutCatalog.MaxKeysPerUser).ToList(); sanitized = sanitized.Take(DashboardShortcutCatalog.MaxKeysPerUser).ToList();
@@ -114,6 +118,32 @@ public sealed class DashboardShortcutService
return outKeys; return outKeys;
} }
private static List<string> EnsureMandatoryKeys(
List<string> keys, string scope, HashSet<string> allowedPages)
{
if (!string.Equals(scope, PageCatalog.ScopePlatform, StringComparison.OrdinalIgnoreCase))
return keys;
var result = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var raw in DashboardShortcutCatalog.MandatoryPlatformKeys)
{
var key = DashboardShortcutCatalog.NormalizeKey(raw);
if (!DashboardShortcutCatalog.IsValidKey(key)) continue;
var pageKey = DashboardShortcutCatalog.PageKeyFor(key);
if (!allowedPages.Contains(pageKey)) continue;
if (seen.Add(key)) result.Add(key);
}
foreach (var key in keys)
{
if (seen.Add(key)) result.Add(key);
}
return result;
}
private static List<string> Deduplicate(IReadOnlyList<string> keys) private static List<string> Deduplicate(IReadOnlyList<string> keys)
{ {
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
@@ -76,6 +76,16 @@
<el-option v-for="l in layers" :key="l" :label="l" :value="l" /> <el-option v-for="l in layers" :key="l" :label="l" :value="l" />
</el-select> </el-select>
</div> </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> </section>
<!-- 几何 / 位置:x, y, z, size, angle, rotation, scale, radius, siteA, siteB, ... --> <!-- 几何 / 位置:x, y, z, size, angle, rotation, scale, radius, siteA, siteB, ... -->
@@ -114,68 +124,80 @@
</div> </div>
</section> </section>
<!-- 样式:颜色、显示开关 --> <!-- 扩展字段:上行 key - 中文,下行整行输入框(站点 / 路段 / 小车共用样式) -->
<section v-if="styleFields.length > 0" class="prop-card prop-card--style"> <section
v-if="selectionKind === 'site' || selectionKind === 'track' || selectionKind === 'car'"
class="prop-card prop-card--custom"
>
<header class="prop-card-header"> <header class="prop-card-header">
<span class="prop-card-title">样式 / 外观</span> <span class="prop-card-title">扩展字段</span>
<span class="prop-card-meta">{{ styleFields.length }} 项</span> <span class="prop-card-meta">{{ customFieldRows.length }} 项</span>
</header> </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>
<!-- 其它 / 自定义字段(兜底,可添加) --> <div v-if="fieldCatalogLoading" class="prop-empty-row">加载字段定义…</div>
<section class="prop-card prop-card--custom"> <template v-else>
<header class="prop-card-header"> <div v-if="customFieldRows.length > 0" class="custom-field-list">
<span class="prop-card-title">其它字段 / 自定义</span> <div v-for="row in customFieldRows" :key="row.key" class="custom-field-item">
<span class="prop-card-meta">{{ otherFields.length }} 项</span> <label class="prop-field-label custom-field-key" :title="row.label">{{ row.label }}</label>
</header> <div class="prop-field-row">
<div v-if="otherFields.length > 0" class="prop-grid"> <el-input
<div v-for="f in otherFields" :key="f.key" class="prop-grid-item prop-grid-item--wide"> v-model="customFieldDrafts[row.key]"
<label class="prop-field-label" :title="f.key">{{ f.key }}</label> size="small"
<div class="prop-field-row"> spellcheck="false"
<el-input class="custom-field-input"
:model-value="f.value" @focus="customFieldFocusKey = row.key"
size="small" @blur="customFieldFocusKey = null"
spellcheck="false" @change="(v: string) => onCustomFieldCommit(row.key, v)"
@change="(v: string) => onFieldChange(f.key, v)" />
/>
<el-tooltip content="删除该字段" placement="top">
<button <button
type="button"
class="prop-field-icon-btn" class="prop-field-icon-btn"
@click="onFieldChange(f.key, '')" title="删除该字段"
@click="onDeleteCustomField(row.key)"
>×</button> >×</button>
</el-tooltip> </div>
</div> </div>
</div> </div>
</div> <div v-else class="prop-empty-row">暂无扩展字段</div>
<div v-else class="prop-empty-row">无其它字段</div>
<button class="prop-add-field-btn" @click="onAddField"> 添加字段</button> <button
class="prop-add-field-btn"
:disabled="!addableFieldOptions.length"
@click="openAddFieldDialog"
>
+ 添加字段
</button>
</template>
</section> </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"> <section v-if="status.length > 0" class="prop-card prop-card--status">
<header class="prop-card-header"> <header class="prop-card-header">
@@ -427,8 +449,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ElMessage } from 'element-plus'
import { InfoFilled, Loading } from '@element-plus/icons-vue' 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 { import {
argbToHex, argbToHex,
hexToArgb, hexToArgb,
@@ -455,7 +479,9 @@ const props = defineProps<{
track: { layer: string; lineWidth: number; color: string } track: { layer: string; lineWidth: number; color: string }
} }
layerRows: LayerRow[] layerRows: LayerRow[]
selectionKind: 'site' | 'track' | null selectionKind: 'site' | 'track' | 'car' | null
/** 当前小车类型(assemblyName.shortName 或 typeName),用于过滤 carFields。 */
carType?: string | null
viewportStyle: ViewportStylePayload | null viewportStyle: ViewportStylePayload | null
viewportStyleLoading?: boolean viewportStyleLoading?: boolean
viewportSyncing?: boolean viewportSyncing?: boolean
@@ -465,7 +491,7 @@ const emit = defineEmits<{
(e: 'rename', name: string): void (e: 'rename', name: string): void
(e: 'change-layer', layer: string): void (e: 'change-layer', layer: string): void
(e: 'set-field', key: string, value: 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: 'exec-action', method: string): void
(e: 'save-defaults'): void (e: 'save-defaults'): void
(e: 'layer-toggle', row: LayerRow): void (e: 'layer-toggle', row: LayerRow): void
@@ -541,14 +567,13 @@ function flashSync() {
// public 字段都吐出来(含 `id / name / layerName / x / y / z / color / // public 字段都吐出来(含 `id / name / layerName / x / y / z / color /
// displaySetting / radius / direction / typeInfo / siteA / siteB / mustFree / ...`)。 // displaySetting / radius / direction / typeInfo / siteA / siteB / mustFree / ...`)。
// 这里的策略: // 这里的策略:
// - 顶部 Header 卡片已经覆盖 id / name / layerName / status,所以从 fields 中剔除这些 // - 顶部 Header 卡片已经覆盖 id / name / layerName / direction / status
// - 「位置 / 几何」吃掉所有跟坐标 / 角度 / 大小 / 半径 / 端点站点强相关的 key; // - 「位置 / 几何」吃掉所有跟坐标 / 角度 / 大小 / 半径 / 端点站点强相关的 key;
// - 「样式 / 外观」吃掉 color / displaySetting / direction / typeInfo / mustFree / 文字渲染相关; // - 剩下的统统进「其它字段」(含 displaySetting / typeInfo / color 及用户自定义字段)。
// - 剩下的统统进「其它字段」(含用户 [FieldMember] 自定义、tag 反序、随便加上去的 free-form key)。
// 这样不管后端塞什么字段进来都能露出来 + 可编辑。 // 这样不管后端塞什么字段进来都能露出来 + 可编辑。
// ────────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────────
const HEADER_KEYS = new Set(['id', 'name', 'layerName']) const HEADER_KEYS = new Set(['id', 'name', 'layerName', 'direction'])
const POSITION_KEYS = new Set([ const POSITION_KEYS = new Set([
'x', 'y', 'z', 'x', 'y', 'z',
@@ -563,20 +588,145 @@ const POSITION_KEYS = new Set([
'siteA', 'siteB' 'siteA', 'siteB'
]) ])
const STYLE_KEYS = new Set([ const editableFields = computed(() => props.fields.filter((f) => !HEADER_KEYS.has(f.key)))
'color',
'displaySetting', const directionField = computed(() => props.fields.find((f) => f.key === 'direction'))
'direction',
'typeInfo', const positionFields = computed(() => editableFields.value.filter((f) => POSITION_KEYS.has(f.key)))
'mustFree', const otherFields = computed(() =>
'imagePath', editableFields.value.filter((f) => !POSITION_KEYS.has(f.key))
'modelPath', )
'fontFamily',
'fontSize', const selectionKindRef = toRef(props, 'selectionKind')
'textColor', const carTypeRef = computed(() => props.carType ?? null)
'lineWidth',
'opacity' 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> = { const FIELD_LABELS: Record<string, string> = {
x: 'X', x: 'X',
@@ -624,14 +774,6 @@ const FIELD_UNITS: Record<string, string> = {
fontSize: 'px', lineWidth: 'px' 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 { function fieldDisplayLabel(key: string): string {
return FIELD_LABELS[key] ?? key return FIELD_LABELS[key] ?? key
} }
@@ -692,7 +834,6 @@ function onFieldChange(key: string, value: string) {
emit('set-field', key, value) emit('set-field', key, value)
if (POSITION_KEYS.has(key)) flashSync() if (POSITION_KEYS.has(key)) flashSync()
} }
function onAddField() { emit('add-field') }
async function onAction(method: string) { async function onAction(method: string) {
try { try {
actionBusy.value = method actionBusy.value = method
@@ -909,7 +1050,7 @@ function onDelete() {
.prop-delete-btn:disabled { opacity: 0.55; cursor: progress; } .prop-delete-btn:disabled { opacity: 0.55; cursor: progress; }
.prop-delete-icon { font-size: 12px; line-height: 1; } .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 { .prop-name-label {
width: 36px; width: 36px;
font-size: 11.5px; font-size: 11.5px;
@@ -991,6 +1132,55 @@ function onDelete() {
color: #fff; 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-list { display: flex; flex-direction: column; gap: 4px; }
.prop-status-item { .prop-status-item {
display: flex; justify-content: space-between; align-items: center; 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 { ElMessage } from 'element-plus'
import { fetchQuickEntryKeys, saveQuickEntryKeys } from '@/api/dashboardQuickEntries' import { fetchQuickEntryKeys, saveQuickEntryKeys } from '@/api/dashboardQuickEntries'
import { import {
defaultQuickKeys, getQuickEntryCatalog, MAX_QUICK_ENTRIES, defaultQuickKeys, finalizeQuickKeys, getQuickEntryCatalog, isMandatoryQuickKey,
normalizeQuickKeys, resolveQuickEntry, type QuickEntryDef MAX_QUICK_ENTRIES, normalizeQuickKeys, resolveQuickEntry, type QuickEntryDef
} from '@/config/quickEntries' } from '@/config/quickEntries'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import type { Scope } from '@/types/auth' import type { Scope } from '@/types/auth'
@@ -20,8 +20,10 @@ export function useDashboardQuickEntries() {
/** 空列表仅在「尚未自定义」(usingDefaults) 时回退系统默认;用户主动清空则保持为空 */ /** 空列表仅在「尚未自定义」(usingDefaults) 时回退系统默认;用户主动清空则保持为空 */
function effectiveKeys(): string[] { function effectiveKeys(): string[] {
if (keys.value.length > 0) return keys.value const base = keys.value.length > 0
return usingDefaults.value ? defaultQuickKeys(scope.value as Scope) : [] ? keys.value
: (usingDefaults.value ? defaultQuickKeys(scope.value as Scope) : [])
return finalizeQuickKeys(base, scope.value as Scope)
} }
function filterByPermission(list: string[]): string[] { function filterByPermission(list: string[]): string[] {
@@ -63,8 +65,8 @@ export function useDashboardQuickEntries() {
const dto = await fetchQuickEntryKeys(userId.value, scope.value) const dto = await fetchQuickEntryKeys(userId.value, scope.value)
const loaded = normalizeQuickKeys(dto.keys) const loaded = normalizeQuickKeys(dto.keys)
keys.value = loaded.length > 0 keys.value = loaded.length > 0
? loaded ? finalizeQuickKeys(loaded, scope.value as Scope)
: (dto.usingDefaults ? defaultQuickKeys(scope.value as Scope) : []) : (dto.usingDefaults ? defaultQuickKeys(scope.value as Scope) : finalizeQuickKeys([], scope.value as Scope))
usingDefaults.value = dto.usingDefaults usingDefaults.value = dto.usingDefaults
} catch (e) { } catch (e) {
keys.value = defaultQuickKeys(scope.value as Scope) keys.value = defaultQuickKeys(scope.value as Scope)
@@ -76,13 +78,14 @@ export function useDashboardQuickEntries() {
} }
async function persist(nextKeys: string[]) { async function persist(nextKeys: string[]) {
const finalized = finalizeQuickKeys(nextKeys, scope.value as Scope)
if (!userId.value) { if (!userId.value) {
keys.value = nextKeys keys.value = finalized
return return
} }
try { try {
const dto = await saveQuickEntryKeys(userId.value, scope.value, normalizeQuickKeys(nextKeys)) const dto = await saveQuickEntryKeys(userId.value, scope.value, finalized)
keys.value = normalizeQuickKeys(dto.keys) keys.value = finalizeQuickKeys(normalizeQuickKeys(dto.keys), scope.value as Scope)
usingDefaults.value = dto.usingDefaults usingDefaults.value = dto.usingDefaults
} catch (e) { } catch (e) {
ElMessage.error(`保存快捷入口失败:${e instanceof Error ? e.message : String(e)}`) ElMessage.error(`保存快捷入口失败:${e instanceof Error ? e.message : String(e)}`)
@@ -105,6 +108,10 @@ export function useDashboardQuickEntries() {
} }
async function removeKey(key: string) { async function removeKey(key: string) {
if (isMandatoryQuickKey(key, scope.value as Scope)) {
ElMessage.warning('该快捷入口为系统默认项,不可移除')
return
}
const base = [...effectiveKeys()] const base = [...effectiveKeys()]
await persist(base.filter((k) => k !== key)) await persist(base.filter((k) => k !== key))
ElMessage.success('已移除快捷入口') ElMessage.success('已移除快捷入口')
@@ -140,6 +147,7 @@ export function useDashboardQuickEntries() {
addKey, addKey,
removeKey, removeKey,
swapKeys, 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 { import {
Collection, Connection, Cpu, Document, DocumentCopy, EditPen, Collection, Connection, Cpu, Document, DocumentCopy, EditPen, Files,
Histogram, Link, MapLocation, Monitor, Notebook, OfficeBuilding, Histogram, Link, MapLocation, Monitor, Notebook, OfficeBuilding, Odometer,
Operation, Promotion, SetUp, Setting, Tools, User, Van, VideoCamera Operation, Promotion, SetUp, Setting, Tools, User, Van, VideoCamera, View
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import type { Component } from 'vue' import type { Component } from 'vue'
@@ -16,11 +16,11 @@ export interface NavMenuItem {
export const ADMIN_MENU: NavMenuItem[] = [ export const ADMIN_MENU: NavMenuItem[] = [
{ path: '/admin/dashboard', label: '总览', icon: Histogram, key: 'admin-dashboard', group: '概览' }, { 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: '设计与编排', path: '/admin/design', label: '设计与编排', icon: Tools, group: '设计与编排',
children: [ 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/map-editor', label: '地图编辑', icon: EditPen, key: 'admin-map-editor', group: '设计与编排' },
{ path: '/admin/project-properties', label: '项目属性', icon: Document, key: 'admin-project-properties', 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/tracks', label: '场景管理', icon: Connection, key: 'admin-tracks', group: '设计与编排' },
@@ -33,11 +33,19 @@ const LEGACY_KEY_ALIASES: Record<string, string> = {
tasks: 'admin-config-strategy' 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 */ /** 与后端 DashboardShortcutCatalog.DefaultPlatformKeys 对齐(均为菜单 key */
export const DEFAULT_PLATFORM_QUICK_KEYS = [ export const DEFAULT_PLATFORM_QUICK_KEYS = [
'admin-map-editor', ...MANDATORY_PLATFORM_QUICK_KEYS,
'admin-task-templates',
'admin-cars',
'admin-config-system-center', 'admin-config-system-center',
'admin-config-ops-center', 'admin-config-ops-center',
'admin-config-strategy' 'admin-config-strategy'
@@ -63,6 +71,25 @@ export function normalizeQuickKeys(keys: string[]): string[] {
return out 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 { function menuItemToQuick(item: NavMenuItem): QuickEntryDef | null {
if (!item.key || EXCLUDED_QUICK_ENTRY_KEYS.has(item.key)) return null if (!item.key || EXCLUDED_QUICK_ENTRY_KEYS.has(item.key)) return null
return { return {
@@ -96,7 +123,7 @@ export function resolveQuickEntry(key: string, scope: Scope): QuickEntryDef | un
export function defaultQuickKeys(scope: Scope): string[] { export function defaultQuickKeys(scope: Scope): string[] {
return scope === 'RCSMonitor' return scope === 'RCSMonitor'
? [...DEFAULT_MONITOR_QUICK_KEYS] ? [...DEFAULT_MONITOR_QUICK_KEYS]
: [...DEFAULT_PLATFORM_QUICK_KEYS] : finalizeQuickKeys([...DEFAULT_PLATFORM_QUICK_KEYS], scope)
} }
export function groupQuickEntries(items: QuickEntryDef[]): { group: string; items: QuickEntryDef[] }[] { export function groupQuickEntries(items: QuickEntryDef[]): { group: string; items: QuickEntryDef[] }[] {
@@ -70,7 +70,7 @@
@click="onQuickClick(item)" @click="onQuickClick(item)"
> >
<span <span
v-if="item.key !== 'add'" v-if="item.key !== 'add' && !isMandatoryQuickKey(item.key)"
class="quick-item-remove" class="quick-item-remove"
title="移除" title="移除"
@pointerdown.stop @pointerdown.stop
@@ -407,7 +407,8 @@ const {
openPicker, openPicker,
addKey, addKey,
removeKey, removeKey,
swapKeys swapKeys,
isMandatoryQuickKey
} = useDashboardQuickEntries() } = useDashboardQuickEntries()
interface QuickTile { interface QuickTile {
@@ -41,13 +41,14 @@
:defaults="defaults" :defaults="defaults"
:layer-rows="layerRows" :layer-rows="layerRows"
:selection-kind="selectionKind" :selection-kind="selectionKind"
:car-type="selectionCarType"
:viewport-style="viewportStyle" :viewport-style="viewportStyle"
:viewport-style-loading="viewportStyleLoading" :viewport-style-loading="viewportStyleLoading"
:viewport-syncing="viewportSyncing" :viewport-syncing="viewportSyncing"
@rename="onRename" @rename="onRename"
@change-layer="onChangeLayer" @change-layer="onChangeLayer"
@set-field="onSetField" @set-field="onSetField"
@add-field="onAddField" @delete-field="onDeleteField"
@exec-action="onExecAction" @exec-action="onExecAction"
@save-defaults="onSaveDefaults" @save-defaults="onSaveDefaults"
@layer-toggle="onLayerToggle" @layer-toggle="onLayerToggle"
@@ -241,15 +242,23 @@ let viewportPatchTimer: ReturnType<typeof setTimeout> | null = null
let viewportPatchSeq = 0 let viewportPatchSeq = 0
let pendingViewportPatch: { site?: ViewportStyleSite; track?: ViewportStyleTrack } = {} let pendingViewportPatch: { site?: ViewportStyleSite; track?: ViewportStyleTrack } = {}
/** 单选且为 site/track 时驱动「类型默认」面板;多选或车型等返回 null。 */ /** 单选且为 site/track/car 时驱动属性扩展;类型默认 Tab 仍只支持 site/track。 */
const selectionKind = computed<'site' | 'track' | null>(() => { const selectionKind = computed<'site' | 'track' | 'car' | null>(() => {
const items = selection.items.value const items = selection.items.value
if (items.length !== 1) return null if (items.length !== 1) return null
const k = items[0]!.kind const k = items[0]!.kind
if (k === 'site' || k === 'track') return k if (k === 'site' || k === 'track') return k
if (k === 'car') return 'car'
return null 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']) const knownLayers = ref<string[]>(['g'])
interface LayerRow { name: string; visible: boolean; selectable: boolean; color: string } interface LayerRow { name: string; visible: boolean; selectable: boolean; color: string }
const layerRows = ref<LayerRow[]>([ 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) { if (selection.items.value.length === 0) {
primary.value = null primary.value = null
primaryFields.value = [] primaryFields.value = []
@@ -1147,7 +1156,7 @@ async function refreshPrimary() {
return return
} }
const first = selection.items.value[0]! const first = selection.items.value[0]!
primaryLoadingStart() if (!opts?.silent) primaryLoadingStart()
try { try {
const b = await reflectionApi.getBundle(first.kind, first.id) const b = await reflectionApi.getBundle(first.kind, first.id)
primary.value = b.summary primary.value = b.summary
@@ -1157,13 +1166,32 @@ async function refreshPrimary() {
} catch (err) { } catch (err) {
ElMessage.error(`加载属性失败:${(err as Error).message}`) ElMessage.error(`加载属性失败:${(err as Error).message}`)
} finally { } finally {
primaryLoadingEnd() if (!opts?.silent) primaryLoadingEnd()
} }
} }
function primaryLoadingStart() { propertyLoading.value = true } function primaryLoadingStart() { propertyLoading.value = true }
function primaryLoadingEnd() { propertyLoading.value = false } 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) { async function onRename(name: string) {
if (!primary.value || selection.items.value.length === 0) return if (!primary.value || selection.items.value.length === 0) return
const it = selection.items.value[0]! const it = selection.items.value[0]!
@@ -1205,34 +1233,55 @@ async function onChangeLayer(layer: string) {
async function onSetField(key: string, value: string) { async function onSetField(key: string, value: string) {
if (!primary.value) return if (!primary.value) return
const it = selection.items.value[0]! const it = selection.items.value[0]!
// 抓原值以便 revert 真正回滚 const hadKey = primaryFields.value.some((f) => f.key === key)
let oldValue: string | undefined const oldValue = primaryFields.value.find((f) => f.key === key)?.value
try {
const b = await reflectionApi.getBundle(it.kind, primary.value.id)
oldValue = b.fields?.[key] != null ? String(b.fields[key]) : undefined
} catch { /* 抓不到原值就只能不回滚 */ }
await history.run({ patchPrimaryFieldLocal(key, value)
label: `修改 ${key}`, try {
apply: async () => { await reflectionApi.setField(it.kind, primary.value!.id, key, value) }, await history.run({
revert: async () => { label: hadKey ? `修改 ${key}` : `添加字段 ${key}`,
if (oldValue !== undefined) { apply: async () => { await reflectionApi.setField(it.kind, primary.value!.id, key, value) },
try { await reflectionApi.setField(it.kind, primary.value!.id, key, oldValue) } revert: async () => {
catch (err) { ElMessage.warning(`回滚 ${key} 失败:${(err as Error).message}`) } if (oldValue !== undefined) {
} else { try { await reflectionApi.setField(it.kind, primary.value!.id, key, oldValue) }
ElMessage.warning(`回滚 ${key}:没拿到原值快照,仅本地撤销`) 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}`) }
}
} }
} })
}) } catch (err) {
await refreshPrimary() 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 { try {
const k = (await ElMessageBox.prompt('字段名', '新增字段', { inputPattern: /^[A-Za-z0-9_]+$/ })).value await history.run({
const v = (await ElMessageBox.prompt('字段值', '新增字段', { inputValue: '' })).value label: `删除字段 ${key}`,
await onSetField(k, v) apply: async () => { await reflectionApi.deleteField(it.kind, primary.value!.id, key) },
} catch { /* cancel */ } 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) { async function onExecAction(method: string) {
@@ -1555,8 +1604,7 @@ const stream = useMapEditStream({
}) })
function refreshAfterMutation() { function refreshAfterMutation() {
// 简化:刷新当前选中的属性 scheduleSilentRefreshPrimary()
refreshPrimary()
} }
watch(() => stream.connected.value, (v) => (streamConnected.value = v), { immediate: true }) 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>