优化地图编辑中的字段管理
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
+273
-83
@@ -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>
|
||||||
Reference in New Issue
Block a user