增强地图编辑 CAD 能力与反射对象管理体验。

补充对齐/批量建站 API,并优化反射面板、历史栈与字段管理相关交互。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zhaowei.huang
2026-07-26 11:32:30 +08:00
co-authored by Cursor
parent b1dd4f9005
commit 8299c3dc5c
18 changed files with 609 additions and 231 deletions
@@ -36,22 +36,24 @@ public sealed class SimpleFieldController : ControllerBase
/// 新增单条字段;同车型 + 字段类型下 key 不可重复
/// </summary>
[HttpPost]
[Authorize(Policy = "PlatformScope")]
public Task<SimpleField> Create([FromBody] SimpleFieldRequest req) => _service.SaveAsync(req);
/// <summary>
/// 按 id 更新单条字段
/// </summary>
[HttpPut("{id:guid}")]
[Authorize(Policy = "PlatformScope")]
public Task<SimpleField> Update(Guid id, [FromBody] SimpleFieldRequest req) => _service.SaveAsync(req with { Id = id });
/// <summary>
/// 按 id 删除单条字段
/// </summary>
[HttpDelete("{id:guid}")]
[Authorize(Policy = "PlatformScope")]
public async Task<IActionResult> Delete(Guid id)
{
await _service.DeleteAsync(id);
return NoContent();
}
@@ -61,10 +63,10 @@ public sealed class SimpleFieldController : ControllerBase
/// 返回实际写入条数 <c>{ count }</c>。
/// </summary>
[HttpPost("batch")]
[Authorize(Policy = "PlatformScope")]
public async Task<IActionResult> SaveBatch([FromBody] SimpleFieldBatchRequest req)
{
var count = await _service.SaveBatchAsync(req);
return Ok(new { count });
}
}
+7 -19
View File
@@ -7,8 +7,10 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AiAssistantDrawer: typeof import('./src/components/assistant/AiAssistantDrawer.vue')['default']
AiAssistantPanel: typeof import('./src/components/map-editor/AiAssistantPanel.vue')['default']
AiGenerateDialog: typeof import('./src/components/map-editor/AiGenerateDialog.vue')['default']
BetterAddFieldDialog: typeof import('./src/components/map-editor/BetterAddFieldDialog.vue')['default']
ConfigPageBase: typeof import('./src/components/ConfigPageBase.vue')['default']
DataTablePro: typeof import('./src/components/DataTablePro.vue')['default']
EdgeInspector: typeof import('./src/components/workflow/EdgeInspector.vue')['default']
@@ -19,28 +21,19 @@ declare module 'vue' {
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAside: typeof import('element-plus/es')['ElAside']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElBadge: typeof import('element-plus/es')['ElBadge']
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
ElButton: typeof import('element-plus/es')['ElButton']
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol']
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElFooter: typeof import('element-plus/es')['ElFooter']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
@@ -53,15 +46,10 @@ declare module 'vue' {
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSlider: typeof import('element-plus/es')['ElSlider']
ElStatistic: typeof import('element-plus/es')['ElStatistic']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
@@ -69,14 +57,14 @@ declare module 'vue' {
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElUpload: typeof import('element-plus/es')['ElUpload']
FieldMemberEditor: typeof import('./src/components/orchestration/FieldMemberEditor.vue')['default']
FleetAllocationPanel: typeof import('./src/components/fleet/FleetAllocationPanel.vue')['default']
FloatingAlarmCard: typeof import('./src/components/map-monitor/FloatingAlarmCard.vue')['default']
FloatingAlarmStack: typeof import('./src/components/map-monitor/FloatingAlarmStack.vue')['default']
JsonFoldNode: typeof import('./src/components/common/JsonFoldNode.vue')['default']
JsonFoldViewer: typeof import('./src/components/common/JsonFoldViewer.vue')['default']
MapConnectionPanel: typeof import('./src/components/map-manage/MapConnectionPanel.vue')['default']
MapMergePanel: typeof import('./src/components/map-manage/MapMergePanel.vue')['default']
MapMonitorConfigGroup: typeof import('./src/components/config/MapMonitorConfigGroup.vue')['default']
@@ -87,6 +75,7 @@ declare module 'vue' {
PermissionGuard: typeof import('./src/components/PermissionGuard.vue')['default']
PluginListPanel: typeof import('./src/components/reflection/PluginListPanel.vue')['default']
ProjectBrowseDialog: typeof import('./src/components/map-editor/ProjectBrowseDialog.vue')['default']
QuickEntryPickerDialog: typeof import('./src/components/dashboard/QuickEntryPickerDialog.vue')['default']
ReflectionKindTable: typeof import('./src/components/orchestration/ReflectionKindTable.vue')['default']
ReflectionManagerPanel: typeof import('./src/components/reflection/ReflectionManagerPanel.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
@@ -97,6 +86,8 @@ declare module 'vue' {
ThemeCustomizer: typeof import('./src/components/ThemeCustomizer.vue')['default']
ThemeSwitcher: typeof import('./src/components/ThemeSwitcher.vue')['default']
VehicleHealthCard: typeof import('./src/components/fleet/VehicleHealthCard.vue')['default']
VehicleHealthRow: typeof import('./src/components/fleet/VehicleHealthRow.vue')['default']
VehicleMaintenanceSelect: typeof import('./src/components/fleet/VehicleMaintenanceSelect.vue')['default']
VehicleMonitorPanel: typeof import('./src/components/workbench/VehicleMonitorPanel.vue')['default']
WorkbenchSidePanel: typeof import('./src/components/workbench/WorkbenchSidePanel.vue')['default']
WorkflowEditor: typeof import('./src/components/workflow/WorkflowEditor.vue')['default']
@@ -105,7 +96,4 @@ declare module 'vue' {
Workspace3D: typeof import('./src/components/Workspace3D.vue')['default']
WorkspaceCanvasToolbar: typeof import('./src/components/workspace/WorkspaceCanvasToolbar.vue')['default']
}
export interface ComponentCustomProperties {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}
@@ -122,8 +122,17 @@ export interface AssetUploadResult {
url: string
}
export type SelectTrackMode = 'all' | 'straight' | 'curve'
export interface ViewFilterSnapshot {
selectFilter: { sites: boolean; tracks: boolean; cars: boolean; decor: boolean }
selectFilter: {
sites: boolean
tracks: boolean
cars: boolean
decor: boolean
/** 路径子类型:all=全部;straight=仅 UITrackcurve=贝塞尔/弧/NURBS */
trackMode?: SelectTrackMode
}
alignSnap: { sites: boolean; cars: boolean; tracks: boolean }
showViewport: { sceneLabels: boolean; scenePrimitives: boolean; cars: boolean }
}
@@ -195,11 +204,68 @@ export const mapEditApi = {
batch: (ops: BatchOp[]) =>
unwrap<{ count: number; results: unknown[] }>(http.post(`${BASE}/objects/batch`, { ops })),
/**
* CAD 相对包围盒对齐(SimpleLite CadAlignService)。
* mode: left|right|top|bottom|centerH|centerV|center|distributeH|distributeV
*/
cadAlign: (mode: string, targets: Array<{ kind: string; id: number }>) =>
unwrap<{
mode: string
count: number
previous: Array<{ kind: string; id: number; x: number; y: number }>
updated: Array<{ kind: string; id: number; x: number; y: number }>
}>(http.post(`${BASE}/cad/align`, { mode, targets })),
/** 横向/纵向等距建站(CadBatchGenerateService.PlanLinear)。 */
cadBatchLinear: (p: {
horizontal: boolean
x1: number
y1: number
x2: number
y2: number
count: number
layer?: string
namePrefix?: string
}) =>
unwrap<{ count: number; created: Array<{ kind: string; id: number }> }>(
http.post(`${BASE}/cad/batch-linear`, p)
),
/** 矩阵建站(CadBatchGenerateService.PlanMatrix)。 */
cadBatchMatrix: (p: {
x1: number
y1: number
x2: number
y2: number
rows: number
cols: number
layer?: string
namePrefix?: string
}) =>
unwrap<{ count: number; created: Array<{ kind: string; id: number }> }>(
http.post(`${BASE}/cad/batch-matrix`, p)
),
copyFieldsTo: (kind: MapEditKind | string, id: number, fieldNames: string[], targets: Array<{ kind: string; id: number }>) =>
unwrap<{ copied: number }>(http.post(`${BASE}/objects/${kind}/${id}/fields/copy-to`, { fieldNames, targets })),
// 拾取 / 仪表盘
pick: () => unwrap<PickResult>(http.post(`${BASE}/pick`)),
/** 场景已有自定义字段键(Ctrl+I /「复制字段」弹窗候选)。 */
sceneFieldKeys: () =>
unwrap<{ keys: string[] }>(http.get(`${BASE}/scene-field-keys`)),
/** 对多个目标批量写入同一字段值(对齐桌面 Ctrl+I)。 */
batchSetField: (key: string, value: string, targets: Array<{ kind: string; id: number }>) =>
unwrap<{ affected: number }>(http.post(`${BASE}/batch-set-field`, { key, value, targets })),
/** 端点落在给定站点上的路径(删站点会级联删除;撤销前需一并快照)。 */
tracksTouchingSites: (siteIds: number[]) =>
unwrap<{ tracks: Array<{ id: number; typeName: string; siteA: number; siteB: number }> }>(
http.post(`${BASE}/tracks-touching-sites`, { siteIds })
),
// 拾取 / 仪表盘。snap=false 时落点用原始鼠标坐标(文本/图片/站点);布线取端点默认 true。
pick: (opts?: { snap?: boolean }) =>
unwrap<PickResult>(http.post(`${BASE}/pick`, { snap: opts?.snap ?? true })),
dashboardSummary: () =>
unwrap<DashboardSummary>(http.get(`${BASE}/dashboard/summary`)),
@@ -4,11 +4,16 @@ import type { Site, Track } from '@/types/map'
import type { Car, CarState } from '@/types/car'
import type { Mission, MissionStatus } from '@/types/mission'
import { mockSites, mockTracks, mockCars, mockMissions } from '@/mock/server'
import { applyRuntimeEnrichment, deriveCarState } from '@/utils/carRuntime'
/** 设为 true 时使用本地 Mock;默认走 YARP → SimpleLite :8222。
* 与 auth.ts / config.ts / ops.ts 统一走 VITE_USE_MOCK 开关(替代旧的 VITE_PROJECTION_MOCK)。 */
const MOCK = import.meta.env.VITE_USE_MOCK === 'true'
/** 车体 status 缓存:电量 + 告警信号,避免 3s 轮询打爆 reflection。 */
const STATUS_CACHE_TTL_MS = 4000
const statusCache = new Map<number, { at: number; rows: Array<{ key: string; value: string }> | null }>()
export async function listSites(): Promise<Site[]> {
if (MOCK) return mockSites()
const { data } = await http.get<Site[]>('/sl/projection/sites')
@@ -22,14 +27,7 @@ export async function listTracks(): Promise<Track[]> {
}
function mapStatusToCarState(status?: string | null): CarState {
if (!status) return 'idle'
const s = status.toLowerCase()
if (/fault|error|failed|故障|异常|失联|超时|检修/.test(s)) return 'fault'
if (/charg|充电/.test(s)) return 'charging'
if (/pause|暂停|挂起/.test(s)) return 'paused'
if (/offline|离线/.test(s)) return 'offline'
if (/run|busy|working|运行|工作|执行|忙/.test(s)) return 'running'
return 'idle'
return deriveCarState({ state: 'idle', lstatus: status ?? undefined }, null)
}
function mapStatusToMissionStatus(status?: string | null): MissionStatus {
@@ -53,10 +51,11 @@ function carFromReflection(row: ReflectionObject): Car {
x: 0,
y: 0,
theta: 0,
batterySoc: 0.8,
batterySoc: 0,
state: mapStatusToCarState(row.status),
lastUpdate: new Date().toISOString(),
group: row.layer ?? undefined
group: row.layer ?? undefined,
lstatus: row.status ?? undefined
}
}
@@ -82,17 +81,52 @@ async function listMissionsFromReflection(): Promise<Mission[]> {
return rows.map(missionFromReflection)
}
async function loadCarStatus(carId: number): Promise<Array<{ key: string; value: string }> | null> {
const cached = statusCache.get(carId)
const now = Date.now()
if (cached && now - cached.at < STATUS_CACHE_TTL_MS) return cached.rows
try {
const status = await reflectionApi.getStatus('car', carId)
const rows = (status ?? []).map((r) => ({ key: r.key, value: r.value }))
statusCache.set(carId, { at: now, rows })
return rows
} catch {
statusCache.set(carId, { at: now, rows: cached?.rows ?? null })
return cached?.rows ?? null
}
}
/**
* 纠正投影里写死的 batterySoc=0.8,以及把「正常但未初始化」误标成 fault 的 state。
* 数据源:反射 status(车体_Soc / AlarmLevel / driveStatus)。
*/
async function enrichCarsRuntime(cars: Car[]): Promise<Car[]> {
return Promise.all(
cars.map(async (car) => {
const id = car.rawId ?? Number(String(car.id).replace(/^C/i, ''))
// 无车体 status 时也先按 lstatus 纠一次(「正常但未初始化」→ idle)
if (!Number.isFinite(id) || id <= 0) {
return applyRuntimeEnrichment(car, null)
}
const rows = await loadCarStatus(id)
return applyRuntimeEnrichment(car, rows)
})
)
}
/** 优先走 SimpleLite /projection/cars502 或空列表时回退 reflection 对象列表(与 3D 场景同源)。 */
export async function listCars(): Promise<Car[]> {
if (MOCK) return mockCars()
try {
const { data } = await http.get<Car[]>('/sl/projection/cars')
if (Array.isArray(data) && data.length > 0) return data
if (Array.isArray(data) && data.length > 0) {
return enrichCarsRuntime(data)
}
const fallback = await listCarsFromReflection()
if (fallback.length > 0) return fallback
return Array.isArray(data) ? data : []
if (fallback.length > 0) return enrichCarsRuntime(fallback)
return Array.isArray(data) ? enrichCarsRuntime(data) : []
} catch {
return listCarsFromReflection()
return enrichCarsRuntime(await listCarsFromReflection())
}
}
@@ -1,4 +1,5 @@
import http from './http'
import { ElMessageBox } from 'element-plus'
import {
mockReflectionAssemblies,
mockReflectionBundle,
@@ -64,6 +65,8 @@ export interface ReflectionMethod {
returnType: string
hasParams: boolean
params: ReflectionParam[]
requiresPlatformConfirm?: boolean
confirmMessage?: string | null
}
export interface ReflectionObject {
@@ -225,30 +228,94 @@ export function emptyMonitorVisibilityMap(): MonitorVisibilityMap {
// 单独保留命名是为了表达"业务含义不同"——可勾选全集 vs 已勾选白名单。
export const emptyMonitorAvailableMap = emptyMonitorVisibilityMap
export class ReflectionApiError extends Error {
code: number
data: unknown
constructor(message: string, code: number, data?: unknown) {
super(message)
this.name = 'ReflectionApiError'
this.code = code
this.data = data ?? null
}
}
export interface ReflectionExecuteResult {
returnValue?: string | null
accepted?: boolean
completed?: boolean
}
/** 根据 execute 返回区分「已完成」与「已受理(后台继续)」文案。 */
export function formatReflectionExecuteMessage(
label: string,
result: ReflectionExecuteResult
): string {
if (result.returnValue) return `已执行:${result.returnValue}`
if (result.accepted && result.completed === false) {
return `已受理:${label}(后台继续执行,请稍后在 SimpleLite 查看结果)`
}
if (result.accepted) return `已执行 ${label}`
return `已执行 ${label}`
}
export interface ReflectionExecuteOptions {
/** 已在平台侧完成二次确认时带上,对应后端 X-Platform-Confirmed: 1 */
platformConfirmed?: boolean
}
async function get<T>(path: string): Promise<T> {
const { data } = await http.get<ReflectionEnvelope<T>>(`${BASE}${path}`)
if (!data?.success) throw new Error(data?.message ?? `reflection ${path} failed`)
if (!data?.success) throw new ReflectionApiError(data?.message ?? `reflection ${path} failed`, data?.code ?? 500, data?.data)
return data.data as T
}
async function post<T>(path: string, params?: Record<string, string | number | boolean>): Promise<T> {
const { data } = await http.post<ReflectionEnvelope<T>>(`${BASE}${path}`, null, { params })
if (!data?.success) throw new Error(data?.message ?? `reflection ${path} failed`)
async function post<T>(
path: string,
params?: Record<string, string | number | boolean>,
headers?: Record<string, string>
): Promise<T> {
const { data } = await http.post<ReflectionEnvelope<T>>(`${BASE}${path}`, null, { params, headers })
if (!data?.success) throw new ReflectionApiError(data?.message ?? `reflection ${path} failed`, data?.code ?? 500, data?.data)
return data.data as T
}
async function del<T>(path: string): Promise<T> {
const { data } = await http.delete<ReflectionEnvelope<T>>(`${BASE}${path}`)
if (!data?.success) throw new Error(data?.message ?? `reflection ${path} failed`)
if (!data?.success) throw new ReflectionApiError(data?.message ?? `reflection ${path} failed`, data?.code ?? 500, data?.data)
return data.data as T
}
async function patchJson<T>(path: string, body: unknown): Promise<T> {
const { data } = await http.patch<ReflectionEnvelope<T>>(`${BASE}${path}`, body)
if (!data?.success) throw new Error(data?.message ?? `reflection PATCH ${path} failed`)
if (!data?.success) throw new ReflectionApiError(data?.message ?? `reflection PATCH ${path} failed`, data?.code ?? 500, data?.data)
return data.data as T
}
async function executeWithPlatformConfirm<T>(
path: string,
params?: Record<string, string | number | boolean>,
opts?: ReflectionExecuteOptions
): Promise<T> {
const headers = opts?.platformConfirmed ? { 'X-Platform-Confirmed': '1' } : undefined
try {
return await post<T>(path, params, headers)
} catch (e) {
if (e instanceof ReflectionApiError && e.code === 428 && !opts?.platformConfirmed) {
const confirmMessage =
(e.data as { confirmMessage?: string | null } | null)?.confirmMessage?.trim()
|| '此操作需要确认'
await ElMessageBox.confirm(confirmMessage, '确认', {
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消'
})
return await post<T>(path, params, { 'X-Platform-Confirmed': '1' })
}
throw e
}
}
export const reflectionApi = {
listKinds: () => MOCK
? Promise.resolve(mockReflectionKinds())
@@ -371,16 +438,23 @@ export const reflectionApi = {
fieldList?: ReflectionKv[]
}>(`/bundle/${kind}/${id}`),
execute: (kind: ReflectionKind, id: number, method: string, params?: Record<string, string | number | boolean>) => MOCK
execute: (
kind: ReflectionKind,
id: number,
method: string,
params?: Record<string, string | number | boolean>,
opts?: ReflectionExecuteOptions
) => MOCK
? Promise.resolve(mockReflectionExecute(
kind,
id,
method,
Object.fromEntries(Object.entries(params ?? {}).map(([k, v]) => [k, String(v)]))
))
: post<{ returnValue: string }>(
: executeWithPlatformConfirm<ReflectionExecuteResult>(
`/execute/${kind}/${id}/${encodeURIComponent(method)}`,
params
params,
opts
),
/** 车辆前往指定站点(Web「去某地」;不依赖 SimpleUI.GetPoint)。 */
@@ -34,10 +34,15 @@ export interface ToolbarRecordingState {
isRecording: boolean
mode: string
isPlaying: boolean
isAutoPlaying?: boolean
speed?: number
elapsedMs: number
durationMs?: number
frameCount: number
currentFrameIndex?: number
droppedFrames: number
currentRecordingFile: string | null
currentPlaybackFile?: string | null
recordingsDirectory: string
entries: ToolbarRecordingEntry[]
}
@@ -122,6 +127,20 @@ export const workspaceToolbarApi = {
stopPlayback: () => unwrap<ToolbarState>(http.post(`${BASE}/playback/stop`)),
playPause: () => unwrap<ToolbarState>(http.post(`${BASE}/playback/play-pause`, {})),
seekElapsed: (elapsedMs: number) =>
unwrap<ToolbarState>(http.post(`${BASE}/playback/seek`, { elapsedMs, ElapsedMs: elapsedMs })),
seekFrame: (frameIndex: number) =>
unwrap<ToolbarState>(http.post(`${BASE}/playback/seek`, { frameIndex, FrameIndex: frameIndex })),
step: (delta = 1) =>
unwrap<ToolbarState>(http.post(`${BASE}/playback/step`, { delta, Delta: delta })),
setSpeed: (speed: number) =>
unwrap<ToolbarState>(http.post(`${BASE}/playback/speed`, { speed, Speed: speed })),
toggleViewMode: () => unwrap<ToolbarState>(http.post(`${BASE}/view/toggle`)),
setCameraFollow: (carId: number | null, enabled: boolean) =>
@@ -137,10 +137,13 @@ defineExpose({ refresh })
<style scoped>
.plugin-list-card {
background: rgba(30, 14, 55, 0.55) !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
backdrop-filter: blur(12px);
margin-bottom: 12px;
background: rgba(var(--mg-bg-card-rgb, 255, 255, 255), 0.94) !important;
border: 1px solid var(--mg-veil-border, rgba(40, 33, 58, 0.10)) !important;
border-radius: 8px !important;
box-shadow: none !important;
backdrop-filter: none;
margin-bottom: 0;
flex: none;
}
.plg-header {
display: flex;
@@ -148,9 +151,9 @@ defineExpose({ refresh })
gap: 8px;
}
.plg-title {
font-size: 14px;
font-weight: 600;
color: var(--mg-text-light);
font-size: 12.5px;
font-weight: 650;
color: var(--el-text-color-primary, #28213a);
}
.plg-collapse { margin-left: auto; }
.plg-tag { margin-right: 4px; margin-bottom: 2px; }
@@ -3,11 +3,14 @@
<PluginListPanel ref="pluginPanelRef" :visible="true" @changed="refresh" />
<div class="ref-mgr-header">
<h2 class="ref-mgr-title">{{ title }}</h2>
<div class="ref-mgr-heading">
<h2 class="ref-mgr-title">{{ shortTitle }}</h2>
<span class="ref-mgr-count">{{ filteredObjects.length }} / {{ objects.length }}</span>
</div>
<div class="ref-mgr-actions">
<el-input
v-model="search"
:placeholder="`搜索 ${kindLabel} (id / 名称 / 类型)`"
:placeholder="`搜索 ${kindLabel}`"
size="small"
clearable
class="ref-mgr-search"
@@ -77,6 +80,27 @@
</div>
</div>
<div v-if="typeChips.length > 1" class="ref-mgr-chips" role="tablist">
<button
type="button"
class="ref-chip"
:class="{ 'is-active': !typeFilter }"
@click="typeFilter = ''"
>
全部<b>{{ objects.length }}</b>
</button>
<button
v-for="chip in typeChips"
:key="chip.type"
type="button"
class="ref-chip"
:class="{ 'is-active': typeFilter === chip.type }"
@click="typeFilter = typeFilter === chip.type ? '' : chip.type"
>
{{ chip.short }}<b>{{ chip.count }}</b>
</button>
</div>
<div class="ref-mgr-body">
<!-- 列表 -->
<el-card shadow="never" class="ref-mgr-list-card">
@@ -86,15 +110,22 @@
:data="filteredObjects"
row-key="id"
stripe
border
size="small"
highlight-current-row
class="ref-mgr-table"
@current-change="onSelect"
>
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="名称" min-width="160" />
<el-table-column prop="typeName" label="类型" width="160" show-overflow-tooltip />
<el-table-column prop="id" label="ID" width="72">
<template #default="{ row }">
<span class="col-mono">{{ row.id }}</span>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="typeName" label="类型" width="140" show-overflow-tooltip>
<template #default="{ row }">
<span class="col-type">{{ shortTypeName(row.typeName) }}</span>
</template>
</el-table-column>
<el-table-column
v-if="showSummaryColumn"
prop="summary"
@@ -186,7 +217,7 @@
<div class="detail-tab-body">
<template v-if="detailTab === 'properties'">
<div class="field-section field-section--first">
<div class="field-section-title">强类型字段 ({{ typedFields.length }})</div>
<div class="field-section-title"><span>基础属性</span><b>{{ typedFields.length }}</b></div>
<el-descriptions
v-if="typedFields.length > 0"
:column="2"
@@ -238,7 +269,7 @@
<!-- 动态字段Prop.fields -->
<div class="field-section">
<div class="field-section-title">
<span>动态字段 ({{ dynamicFields.length }})</span>
<span>动态字段</span><b>{{ dynamicFields.length }}</b>
<button class="add-field-btn" @click="onAddField"> 添加字段</button>
</div>
<el-descriptions
@@ -472,6 +503,7 @@ import { ElMessage, ElMessageBox, type TableInstance } from 'element-plus'
import { Search, Plus, Refresh, CaretBottom, Connection, Document } from '@element-plus/icons-vue'
import {
reflectionApi,
formatReflectionExecuteMessage,
type ReflectionKind,
type ReflectionMethod,
type ReflectionObject,
@@ -545,6 +577,7 @@ const savingProject = ref(false)
const lastProjectSavePath = ref<string | null>(null)
const creating = ref(false)
const search = ref('')
const typeFilter = ref('')
const scriptActionLoading = ref<'source' | 'status' | null>(null)
const scriptActionLoadingRowId = ref<number | null>(null)
const scriptDialogOpen = ref(false)
@@ -577,6 +610,11 @@ const autoSaveProjectEnabled = computed(
() => props.autoSaveProject ?? isProjectPersistableKind(props.kind)
)
const saveButtonLabel = computed(() => props.saveButtonLabel ?? '保存')
const shortTitle = computed(() => {
const t = (props.title || '').trim()
const cut = t.indexOf('')
return cut > 0 ? t.slice(0, cut) : t
})
const projectSaveTooltip = computed(() => {
if (lastProjectSavePath.value) {
return `将当前内存项目写回 JSON;上次保存:${lastProjectSavePath.value}`
@@ -592,14 +630,38 @@ const rowActionsWidth = computed(() => {
return Math.max(w, 72)
})
function shortTypeName(typeName?: string | null): string {
const raw = (typeName ?? '').trim()
if (!raw) return '—'
const parts = raw.split(/[.+]/)
return parts[parts.length - 1] || raw
}
const typeChips = computed(() => {
const map = new Map<string, number>()
for (const o of objects.value) {
const t = o.typeName || 'Unknown'
map.set(t, (map.get(t) ?? 0) + 1)
}
return [...map.entries()]
.map(([type, count]) => ({ type, short: shortTypeName(type), count }))
.sort((a, b) => b.count - a.count || a.short.localeCompare(b.short))
.slice(0, 8)
})
const filteredObjects = computed(() => {
const q = search.value.trim().toLowerCase()
if (!q) return objects.value
return objects.value.filter((o) =>
String(o.id).includes(q) ||
(o.name ?? '').toLowerCase().includes(q) ||
(o.typeName ?? '').toLowerCase().includes(q)
)
return objects.value.filter((o) => {
if (typeFilter.value && o.typeName !== typeFilter.value) return false
if (!q) return true
return (
String(o.id).includes(q) ||
(o.name ?? '').toLowerCase().includes(q) ||
(o.typeName ?? '').toLowerCase().includes(q) ||
(o.summary ?? '').toLowerCase().includes(q) ||
(o.status ?? '').toLowerCase().includes(q)
)
})
})
// process / car-typed 需要先在下拉里挑插件子类型,再实例化;
@@ -1124,7 +1186,7 @@ async function runMethod(m: ReflectionMethod) {
executing.value = m.methodName
try {
const r = await reflectionApi.execute(realKind, current.value.id, m.methodName, params)
ElMessage.success(`${m.label ?? m.methodName} 完成${r.returnValue ? `${r.returnValue}` : ''}`)
ElMessage.success(formatReflectionExecuteMessage(m.label ?? m.methodName, r))
await onSelect(current.value)
} catch (err) {
ElMessage.error(`执行失败:${(err as Error).message}`)
@@ -1136,7 +1198,7 @@ async function runMethod(m: ReflectionMethod) {
executing.value = m.methodName
try {
const r = await reflectionApi.execute(realKind, current.value.id, m.methodName)
ElMessage.success(`${m.label ?? m.methodName} 完成${r.returnValue ? `${r.returnValue}` : ''}`)
ElMessage.success(formatReflectionExecuteMessage(m.label ?? m.methodName, r))
await onSelect(current.value)
} catch (err) {
ElMessage.error(`执行失败:${(err as Error).message}`)
@@ -1198,7 +1260,11 @@ useMapEditStream({
onObjectBatchChanged: () => refresh()
})
watch(() => props.kind, refresh)
watch(() => props.kind, () => {
typeFilter.value = ''
search.value = ''
void refresh()
})
onMounted(refresh)
</script>
@@ -1207,31 +1273,81 @@ onMounted(refresh)
height: 100%;
display: flex;
flex-direction: column;
gap: 12px;
gap: 8px;
color: var(--mg-text-light);
min-height: 0;
}
.ref-mgr-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 6px 0 4px;
border-bottom: 1px solid var(--mg-divider);
gap: 10px;
min-height: 40px;
padding: 4px 2px 8px;
border-bottom: 1px solid var(--mg-veil-border, var(--mg-divider));
flex: none;
}
.ref-mgr-heading {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
}
.ref-mgr-title {
margin: 0;
font-size: 16px;
font-weight: 600;
/* 用 CSS 变量自适应深色 / 浅色主题,#fff 兜底深色玻璃语境。 */
font-size: 15px;
font-weight: 650;
color: var(--el-text-color-primary, #fff);
letter-spacing: 0.5px;
letter-spacing: 0.2px;
white-space: nowrap;
}
.ref-mgr-count {
font-family: var(--mg-font-mono, ui-monospace, Menlo, Consolas, monospace);
font-variant-numeric: tabular-nums;
font-size: 12px;
color: var(--mg-text-muted, #8a7aa8);
}
.ref-mgr-actions {
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
}
.ref-mgr-search { width: 220px; }
.ref-mgr-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
flex: none;
}
.ref-chip {
appearance: none;
border: 1px solid var(--mg-veil-border, rgba(124, 58, 237, 0.18));
background: rgba(var(--mg-bg-card-rgb, 255, 255, 255), 0.92);
color: var(--mg-text-muted, #756d85);
border-radius: 6px;
padding: 3px 10px;
font-size: 12px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}
.ref-chip b {
font-family: var(--mg-font-mono, ui-monospace, Menlo, Consolas, monospace);
font-variant-numeric: tabular-nums;
font-weight: 650;
color: var(--el-text-color-primary, #2d1b69);
}
.ref-chip:hover { color: var(--el-text-color-primary, #2d1b69); }
.ref-chip.is-active {
color: var(--mg-primary, #7c3aed);
border-color: rgba(124, 58, 237, 0.45);
background: rgba(124, 58, 237, 0.08);
}
.ref-mgr-search { width: 280px; }
.ref-type-asm {
margin-left: 6px;
@@ -1242,69 +1358,103 @@ onMounted(refresh)
.ref-mgr-body {
flex: 1;
display: grid;
grid-template-columns: minmax(380px, 0.9fr) 1.4fr;
gap: 12px;
grid-template-columns: minmax(420px, 1.15fr) minmax(320px, 0.95fr);
gap: 10px;
min-height: 0;
}
.ref-mgr-list-card,
.ref-mgr-detail-card {
background: rgba(30, 14, 55, 0.55) !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
backdrop-filter: blur(12px);
background: rgba(var(--mg-bg-card-rgb, 255, 255, 255), 0.94) !important;
border: 1px solid var(--mg-veil-border, rgba(124, 58, 237, 0.14)) !important;
border-radius: 8px !important;
box-shadow: none !important;
backdrop-filter: none;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.ref-mgr-list-card :deep(.el-card__body),
.ref-mgr-detail-card :deep(.el-card__body) {
flex: 1;
overflow: auto;
padding: 12px;
padding: 8px 10px;
min-height: 0;
}
.ref-mgr-table { flex: 1; min-height: 200px; width: 100%; }
.col-mono {
font-family: var(--mg-font-mono, ui-monospace, Menlo, Consolas, monospace);
font-variant-numeric: tabular-nums;
font-size: 12px;
color: var(--mg-text-muted, #756d85);
}
.col-type {
font-size: 12px;
color: var(--mg-text-muted, #756d85);
}
.ref-mgr-table { flex: 1; min-height: 240px; }
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px dashed var(--mg-veil-border);
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--mg-veil-border, var(--mg-divider));
}
.detail-title {
display: flex;
align-items: baseline;
gap: 10px;
gap: 8px;
min-width: 0;
}
.detail-actions {
display: flex;
align-items: center;
gap: 8px;
flex: none;
}
.detail-id {
color: var(--mg-text-dim);
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 12.5px;
font-family: var(--mg-font-mono, ui-monospace, Menlo, Consolas, monospace);
font-variant-numeric: tabular-nums;
font-size: 12px;
}
.detail-name {
color: var(--el-text-color-primary, #fff);
font-size: 15px;
font-weight: 600;
font-size: 14px;
font-weight: 650;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-tabs :deep(.el-tabs__header) { margin-bottom: 0; }
.detail-tabs :deep(.el-tabs__item) { color: var(--mg-text-muted) !important; }
.detail-tabs :deep(.el-tabs__item.is-active) { color: var(--mg-text-light) !important; }
.detail-tabs :deep(.el-tabs__nav-wrap::after) { height: 1px; }
.detail-tabs :deep(.el-tabs__item) {
color: var(--mg-text-muted) !important;
height: 34px;
line-height: 34px;
font-size: 13px;
padding: 0 14px !important;
}
.detail-tabs :deep(.el-tabs__item.is-active) {
color: var(--mg-primary, #7c3aed) !important;
font-weight: 650;
}
.detail-tabs :deep(.el-tabs__active-bar) {
background: var(--mg-primary, #7c3aed);
height: 2px;
}
.detail-tab-body {
flex: 1;
min-height: 0;
overflow: auto;
padding-top: 10px;
padding-top: 8px;
}
.field-section {
margin-top: 14px;
margin-top: 12px;
}
.field-section--first {
margin-top: 0;
@@ -1312,13 +1462,21 @@ onMounted(refresh)
.field-section-title {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
font-weight: 600;
/* CSS 变量自适应深 / 浅主题,避免白底白字。 */
gap: 8px;
font-size: 12px;
font-weight: 650;
color: var(--el-text-color-primary, rgba(255, 255, 255, 0.85));
margin-bottom: 8px;
letter-spacing: 0.4px;
letter-spacing: 0.3px;
padding-left: 8px;
border-left: 2px solid var(--mg-primary, #7c3aed);
}
.field-section-title b {
font-family: var(--mg-font-mono, ui-monospace, Menlo, Consolas, monospace);
font-variant-numeric: tabular-nums;
font-weight: 650;
color: var(--mg-text-muted, #756d85);
margin-right: auto;
}
.field-empty {
color: var(--mg-text-dim);
@@ -1345,34 +1503,32 @@ onMounted(refresh)
margin-bottom: 8px;
}
/* 动作按钮:紫色渐变 + 白色文字,hover 时加亮 + 阴影。
配色参考 EditPropertyPanel 的 .prop-action-btn 与 EditTopBar 的 .topbar-btn。 */
/* Style A:实色主按钮,去掉霓虹渐变。 */
.method-btn {
appearance: none; border: 0; cursor: pointer;
padding: 8px 12px;
border-radius: 7px;
background: linear-gradient(135deg, rgba(170, 110, 250, 0.30) 0%, rgba(120, 70, 220, 0.30) 100%);
border: 1px solid rgba(170, 110, 250, 0.55);
padding: 7px 10px;
border-radius: 6px;
background: var(--mg-primary, #7c3aed);
border: 1px solid var(--mg-primary, #7c3aed);
color: #fff;
font-size: 12.5px;
font-weight: 500;
letter-spacing: 0.3px;
font-size: 12px;
font-weight: 550;
letter-spacing: 0.2px;
display: flex;
align-items: center;
gap: 6px;
text-align: left;
transition: background .14s ease, border-color .14s ease, transform .12s ease, box-shadow .14s ease;
transition: background .12s ease, border-color .12s ease, transform .1s ease;
}
.method-btn:hover:not(:disabled) {
background: linear-gradient(135deg, rgba(170, 110, 250, 0.72) 0%, rgba(255, 110, 220, 0.62) 100%);
border-color: rgba(255, 130, 220, 0.78);
box-shadow: 0 4px 14px rgba(180, 90, 220, 0.42);
background: var(--mg-primary-hover, #6d28d9);
border-color: var(--mg-primary-hover, #6d28d9);
}
.method-btn:active:not(:disabled) { transform: scale(0.985); }
.method-btn:disabled { opacity: 0.55; cursor: progress; }
.method-btn-icon {
font-size: 10px;
color: rgba(255, 200, 250, 0.85);
color: rgba(255, 255, 255, 0.85);
flex: none;
}
.method-btn-spin {
@@ -1399,39 +1555,38 @@ onMounted(refresh)
/* 「+ 添加字段」按钮:与动作按钮同色系但偏淡,区分等级。 */
.add-field-btn {
appearance: none; border: 0; cursor: pointer;
padding: 4px 10px;
appearance: none;
cursor: pointer;
padding: 3px 8px;
border-radius: 6px;
background: rgba(170, 110, 250, 0.18);
border: 1px dashed rgba(170, 110, 250, 0.45);
color: rgba(255, 230, 255, 0.95);
background: transparent;
border: 1px dashed rgba(124, 58, 237, 0.45);
color: var(--mg-primary, #7c3aed);
font-size: 12px;
font-weight: 500;
transition: background .14s ease, border-color .14s ease, color .14s ease;
font-weight: 550;
transition: background .12s ease, border-color .12s ease;
margin-left: auto;
}
.add-field-btn:hover {
background: rgba(170, 110, 250, 0.34);
border-color: rgba(170, 110, 250, 0.75);
color: #fff;
background: rgba(124, 58, 237, 0.08);
border-color: rgba(124, 58, 237, 0.7);
}
/* 详情头「在 3D 中高亮」按钮:与动作按钮同款样式。 */
.header-action-btn {
appearance: none; border: 0; cursor: pointer;
padding: 6px 14px;
padding: 5px 12px;
border-radius: 6px;
background: linear-gradient(135deg, rgba(170, 110, 250, 0.32) 0%, rgba(120, 70, 220, 0.32) 100%);
border: 1px solid rgba(170, 110, 250, 0.55);
background: var(--mg-primary, #7c3aed);
border: 1px solid var(--mg-primary, #7c3aed);
color: #fff;
font-size: 12.5px;
font-weight: 500;
letter-spacing: 0.4px;
transition: background .14s ease, border-color .14s ease, box-shadow .14s ease;
font-size: 12px;
font-weight: 550;
letter-spacing: 0.2px;
transition: background .12s ease;
}
.header-action-btn:hover {
background: linear-gradient(135deg, rgba(170, 110, 250, 0.72) 0%, rgba(255, 110, 220, 0.62) 100%);
border-color: rgba(255, 130, 220, 0.78);
box-shadow: 0 3px 10px rgba(180, 90, 220, 0.40);
background: var(--mg-primary-hover, #6d28d9);
border-color: var(--mg-primary-hover, #6d28d9);
}
.script-dialog-textarea :deep(.el-textarea__inner) {
@@ -1468,33 +1623,14 @@ onMounted(refresh)
background: var(--mg-veil-1) !important;
}
:deep(.el-table__body tr.current-row > td.el-table__cell) {
background: rgba(170, 110, 250, 0.20) !important;
background: rgba(124, 58, 237, 0.10) !important;
}
:deep(.el-table .el-button.is-text) {
padding: 2px 6px;
}
/* ── 浅紫主题:卡片变白后,半透明渐变动作按钮上的白字会看不清,
改为实色紫底白字(对比 ~6.9:1,过 WCAG AA)。scoped 限本组件,不影响地图编辑画布。 ── */
:root[data-theme="fame-lavender"] .method-btn,
:root[data-theme="fame-lavender"] .header-action-btn {
background: #7c3aed;
border-color: #7c3aed;
color: #fff;
}
:root[data-theme="fame-lavender"] .method-btn:hover:not(:disabled),
:root[data-theme="fame-lavender"] .header-action-btn:hover {
background: #6d28d9;
border-color: #6d28d9;
box-shadow: 0 4px 14px rgba(124, 58, 237, 0.35);
}
:root[data-theme="fame-lavender"] .method-btn-icon { color: rgba(255, 255, 255, 0.85); }
:root[data-theme="fame-lavender"] .method-btn-tag { background: rgba(255, 255, 255, 0.28); }
:root[data-theme="fame-lavender"] .add-field-btn {
background: rgba(124, 58, 237, 0.12);
border-color: rgba(124, 58, 237, 0.5);
color: #6d28d9;
}
:root[data-theme="fame-lavender"] .add-field-btn:hover {
background: rgba(124, 58, 237, 0.22);
border-color: rgba(124, 58, 237, 0.7);
color: #5b21b6;
@media (max-width: 1100px) {
.ref-mgr-body { grid-template-columns: 1fr; }
.ref-mgr-search { width: min(220px, 100%); }
}
</style>
@@ -212,6 +212,11 @@ import { OPS_WHITELIST, type OpsAction } from '@/types/ops'
import { executeOp } from '@/api/ops'
import { useAuthStore } from '@/stores/auth'
import { fetchMonitorConfigCached } from '@/utils/monitorConfigCache'
import {
extractSocFromFieldMap,
extractSocFromKv,
socToPercent
} from '@/utils/batterySoc'
import type { Car } from '@/types/car'
import type { Mission } from '@/types/mission'
import type { SelectedObjectRef } from '@/types/workbench'
@@ -374,16 +379,13 @@ function carHasOccupiedTag(): boolean {
}
function formatBatteryText(): { text: string; low: boolean } {
// 真实车电量在反射 status「车体_Soc」;投影 batterySoc 常被写死成 0.8,不能优先用。
const fromStatus = extractSocFromKv(statusRows.value)
const fromFields = extractSocFromFieldMap(fieldMap.value)
const car = findCarInList()
const raw = pickField('batterySoc', 'battery', '电量')
let pct: number | null = null
if (raw) {
const n = Number(raw)
if (Number.isFinite(n)) pct = n <= 1 ? Math.round(n * 100) : Math.round(n)
} else if (car != null) {
pct = Math.round(car.batterySoc <= 1 ? car.batterySoc * 100 : car.batterySoc)
}
if (pct == null) return { text: '—', low: false }
const ratio = fromStatus ?? fromFields ?? (car != null && car.batterySoc > 0 ? car.batterySoc : null)
if (ratio == null || !Number.isFinite(ratio) || ratio <= 0) return { text: '—', low: false }
const pct = socToPercent(ratio)
return { text: `${pct}%`, low: pct < 20 }
}
@@ -593,12 +595,10 @@ function applyCarListPreview(): boolean {
bundleName.value = car.name
bundleTypeName.value = car.typeName ?? ''
bundleFullTypeName.value = car.typeName ?? ''
const pct = Math.round(car.batterySoc <= 1 ? car.batterySoc * 100 : car.batterySoc)
fieldMap.value = {
x: String(car.x),
y: String(car.y),
th: String(car.theta),
batterySoc: String(pct)
th: String(car.theta)
}
statusRows.value = []
allMethods.value = []
@@ -906,11 +906,11 @@ watch(
/* fame-lavender 浅紫主题:goto(前往站点)按钮在白底下需实色琥珀 + 白字,
否则半透明棕底叠白底→浅棕 + 白字不可读。 */
:root[data-theme="fame-lavender"] .action-btn--goto {
:root[data-shell="outpost"] .action-btn--goto {
border-color: #b45309;
background: #b45309;
}
:root[data-theme="fame-lavender"] .action-btn--goto:hover:not(:disabled) {
:root[data-shell="outpost"] .action-btn--goto:hover:not(:disabled) {
border-color: #92400e;
background: #92400e;
}
@@ -103,6 +103,7 @@ import { getSelectionDetail } from '@/api/workbench'
import { getConfig } from '@/api/config'
import {
reflectionApi,
formatReflectionExecuteMessage,
type ReflectionKv,
type ReflectionKind,
type ReflectionMethod
@@ -399,9 +400,7 @@ async function onExecute(m: ReflectionMethod) {
executing.value = m.methodName
try {
const r = await reflectionApi.execute(rk, idNum, m.methodName, params)
ElMessage.success(
r.returnValue ? `执行成功:${r.returnValue}` : `已执行 ${m.label || m.methodName}`
)
ElMessage.success(formatReflectionExecuteMessage(m.label || m.methodName, r))
} catch (e) {
ElMessage.error(`执行失败:${(e as Error).message}`)
} finally {
@@ -249,6 +249,16 @@ const props = withDefaults(
{ followCarId: null }
)
const emit = defineEmits<{
(e: 'state-changed', state: ToolbarState): void
(e: 'follow-changed', payload: { enabled: boolean; carId: number | null }): void
}>()
function publishState(next: ToolbarState | null) {
if (!next) return
emit('state-changed', next)
}
/**
* 平台 iframe 画布工具栏(对应 SimpleLite `Panel_4` / `WorkspaceBottomBar.DefineForTerminalEmbedMinimal`)。
*
@@ -318,6 +328,7 @@ const followCarId = computed(() => {
async function refresh() {
try {
state.value = await workspaceToolbarApi.getState()
publishState(state.value)
} catch (err) {
ElMessage.error(`同步工具栏状态失败:${(err as Error).message}`)
}
@@ -328,6 +339,7 @@ async function toggleAlign(key: AlignKey) {
busy.align = true
try {
state.value = await workspaceToolbarApi.setAlign(key, !state.value.align[key])
publishState(state.value)
} catch (err) {
ElMessage.error(`切换对齐失败:${(err as Error).message}`)
} finally {
@@ -340,6 +352,7 @@ async function toggleSelect(key: SelectKey) {
busy.select = true
try {
state.value = await workspaceToolbarApi.setSelect(key, !state.value.select[key])
publishState(state.value)
} catch (err) {
ElMessage.error(`切换选择过滤失败:${(err as Error).message}`)
} finally {
@@ -352,6 +365,7 @@ async function toggleDisplay(key: DisplayKey) {
busy.display = true
try {
state.value = await workspaceToolbarApi.setDisplay(key, !state.value.display[key])
publishState(state.value)
} catch (err) {
ElMessage.error(`切换显示内容失败:${(err as Error).message}`)
} finally {
@@ -363,6 +377,7 @@ async function toggleLayer(name: string, visible: boolean) {
busy.layer = true
try {
state.value = await workspaceToolbarApi.setLayerVisibility(name, visible)
publishState(state.value)
} catch (err) {
ElMessage.error(`切换图层失败:${(err as Error).message}`)
} finally {
@@ -467,6 +482,8 @@ async function toggleCameraFollow() {
try {
const enable = !cameraFollowing.value
state.value = await workspaceToolbarApi.setCameraFollow(carId, enable)
publishState(state.value)
emit('follow-changed', { enabled: enable, carId: enable ? carId : null })
if (enable) {
ElMessage.success({ message: `已开始跟随车辆 #${carId}`, duration: 1500, grouping: true })
} else {
@@ -12,11 +12,13 @@ import { ref, computed } from 'vue'
*/
export type EditToolId =
// 选择组
// 选择组(左侧仅保留「仅*」过滤;单选/框选等由画布默认交互承担)
| 'select.single'
| 'select.rect'
| 'select.lasso'
| 'select.byType.site'
| 'select.byType.straight'
| 'select.byType.curve'
| 'select.byType.bezier'
| 'select.byType.polyline'
| 'select.byType.arc'
@@ -28,6 +30,7 @@ export type EditToolId =
| 'draw.site'
| 'draw.sites.continuous'
| 'draw.track.polyline'
| 'draw.tracks.continuous'
| 'draw.track.bezier'
| 'draw.track.arc'
| 'draw.track.nurbs'
@@ -41,7 +44,6 @@ export type EditToolId =
| 'transform.move'
| 'transform.rotate'
| 'transform.scale'
| 'transform.duplicate'
| 'transform.copy'
| 'transform.paste'
| 'transform.delete'
@@ -82,7 +84,8 @@ export interface SnapState {
}
export function useEditTool() {
const activeTool = ref<EditToolId>('transform.move')
// 空闲默认:不落在已移除的「移动」上,侧栏无高亮(画布仍可点选)。
const activeTool = ref<EditToolId>('select.single')
const snap = ref<SnapState>({ grid: false, gridSizeMm: 100, object: true, endpoint: true, midpoint: false, toAngleDeg: undefined })
const showControlPoints = ref(true)
const showControlHandles = ref(true)
@@ -105,17 +108,20 @@ export function useEditTool() {
'select.single': '单选',
'select.rect': '矩形框选',
'select.lasso': '圈选',
'select.byType.site': '按类型选 - 站点',
'select.byType.bezier': '按类型选 - 贝塞尔',
'select.byType.polyline': '按类型选 - 折线',
'select.byType.arc': '按类型选 - 弧',
'select.byType.decor': '按类型选 - 装饰物',
'select.byType.currentLayer': '按类型选 - 当前图层',
'select.byType.site': '站点',
'select.byType.straight': '仅直线',
'select.byType.curve': '仅曲线',
'select.byType.bezier': '仅曲线',
'select.byType.polyline': '仅直线',
'select.byType.arc': '仅曲线',
'select.byType.decor': '仅装饰',
'select.byType.currentLayer': '当前图层',
'select.invert': '反选',
'select.clear': '清空选中',
'draw.site': '添加站点',
'draw.sites.continuous': '连续添加站点',
'draw.track.polyline': '添加线路径',
'draw.track.polyline': '添加线路径',
'draw.tracks.continuous': '连续添加直线路径',
'draw.track.bezier': '添加贝塞尔路径',
'draw.track.arc': '添加弧形路径',
'draw.track.nurbs': '添加 NURBS 路径',
@@ -128,7 +134,6 @@ export function useEditTool() {
'transform.move': '移动',
'transform.rotate': '旋转',
'transform.scale': '缩放',
'transform.duplicate': '复制副本',
'transform.copy': '复制',
'transform.paste': '粘贴',
'transform.delete': '删除',
@@ -98,5 +98,19 @@ export function useHistory(opts: UseHistoryOptions = {}) {
}
}
return { undoStack, redoStack, busy, canUndo, canRedo, run, undo, redo, clear, composite }
return {
undoStack,
redoStack,
busy,
canUndo,
canRedo,
maxDepth,
undoCount: computed(() => undoStack.value.length),
redoCount: computed(() => redoStack.value.length),
run,
undo,
redo,
clear,
composite
}
}
@@ -1,17 +1,17 @@
<template>
<div class="car-page">
<el-tabs v-model="tab" type="border-card" class="car-tabs admin-tabs" @tab-change="onTabChange">
<el-tabs v-model="tab" class="car-tabs admin-tabs admin-tabs--ops" @tab-change="onTabChange">
<el-tab-pane label="车辆列表" name="list">
<ReflectionManagerPanel
v-if="tab === 'list'"
kind="car"
kind-label="车辆"
title="车辆管理Car / AbstractCar,含模拟车、插件车型 reflectionApi 实例化)"
title="车辆管理"
empty-text="当前没有车辆点击右上角新建车辆从已加载的 CarType 中选一个实例化"
enable-project-save
/>
</el-tab-pane>
<el-tab-pane label="车型样式 / 报警颜色" name="style">
<el-tab-pane label="车型样式" name="style">
<CarStyleEditor v-if="tab === 'style'" />
</el-tab-pane>
</el-tabs>
@@ -39,10 +39,11 @@ onMounted(() => {
<style scoped>
.car-page {
height: calc(100vh - 56px);
padding: 12px 14px;
padding: 10px 12px;
box-sizing: border-box;
display: flex;
flex-direction: column;
min-height: 0;
}
.car-tabs {
flex: 1;
@@ -3,7 +3,7 @@
<ReflectionManagerPanel
kind="process"
kind-label="进程"
title="进程管理Mission / Process"
title="进程管理"
empty-text="当前没有进程点击右上角新建进程即可基于已加载的 MissionType 实例化一个"
show-status-column
status-label="状态"
@@ -20,7 +20,8 @@ import ReflectionManagerPanel from '@/components/reflection/ReflectionManagerPan
<style scoped>
.process-page {
height: calc(100vh - 56px);
padding: 12px 14px;
padding: 10px 12px;
box-sizing: border-box;
min-height: 0;
}
</style>
@@ -1,12 +1,12 @@
<template>
<div class="scene-mgr-page">
<el-tabs v-model="activeTab" type="border-card" class="scene-tabs admin-tabs" @tab-change="onTabChange">
<div class="scene-mgr-page ops-console-page">
<el-tabs v-model="activeTab" class="scene-tabs admin-tabs admin-tabs--ops" @tab-change="onTabChange">
<el-tab-pane label="站点" name="site">
<ReflectionManagerPanel
ref="sitePanelRef"
kind="site"
kind-label="站点"
title="站点管理Site / UISite,含禁用/启用、必空点等动作)"
title="站点管理"
empty-text="当前没有站点点击右上角新建站点 x/y 坐标添加"
enable-project-save
/>
@@ -16,7 +16,7 @@
ref="trackPanelRef"
kind="track"
kind-label="路径"
title="路径管理Track / UITrack,含方向、冲突、投影、二分等动作)"
title="路径管理"
empty-text="当前没有路径点击右上角新建路径选起止站点添加"
enable-project-save
/>
@@ -26,7 +26,7 @@
ref="specialPanelRef"
kind="special"
kind-label="装饰物"
title="装饰物管理UI_Image / UI_Text / UI_Model"
title="装饰物管理"
empty-text="当前没有装饰物新建图片/文本/模型可用底部按钮或先在地图编辑里上传资产"
enable-project-save
/>
@@ -37,14 +37,8 @@
<script setup lang="ts">
/**
* 场景管理:3 个 ReflectionManagerPanel 实例承载 site/track/special 三个 subKind
*
* 这里之所以用三个独立 panel 而不是单一 panel + 顶部切换:
* - 每个 subKind 列字段、可创建子类型完全不同(site 要 x/y、track 要 siteA/siteB、
* 装饰物要 imagePath/modelPath 等),共享一组 ref 会让创建表单状态错乱;
* - ReflectionManagerPanel 已经把 list/详情/方法/字段/SSE 全部包好,三份实例几乎零边际成本。
*
* SceneManagerView 自己只负责 tab 切换 + 默认走 site tab。
* 场景管理:3 个 ReflectionManagerPanel 分别承载 site/track/special。
* Style A:外层 underline tabs + 紧凑运维控制台壳。
*/
import { ref } from 'vue'
@@ -57,7 +51,6 @@ const trackPanelRef = ref<InstanceType<typeof ReflectionManagerPanel> | null>(nu
const specialPanelRef = ref<InstanceType<typeof ReflectionManagerPanel> | null>(null)
function onTabChange(name: string | number) {
// 切到对应 tab 时主动 refresh 一次,确保 SSE 没接的情况下也能拿到最新列表。
const target =
name === 'site' ? sitePanelRef.value :
name === 'track' ? trackPanelRef.value :
@@ -67,19 +60,19 @@ function onTabChange(name: string | number) {
</script>
<style scoped>
/* 会话 N+2tabs 通用样式提到全局 `.admin-tabs`(见 styles/theme.css),
这里只保留容器布局,避免 scoped 选择器把全局规则压住。 */
.scene-mgr-page {
height: calc(100vh - 56px);
padding: 12px 14px;
padding: 10px 12px;
box-sizing: border-box;
display: flex;
flex-direction: column;
min-height: 0;
}
.scene-tabs {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
</style>
@@ -11,7 +11,7 @@
<ReflectionManagerPanel
kind="script"
kind-label="脚本"
title="脚本管理CarProgram 运行实例 · 与 SimpleLite 工作台「脚本」页对齐)"
title="脚本管理"
empty-text="当前无 CarProgram 实例CarProgram Mission 运行时编译用户脚本后产生的对象请在 SimpleLite 工作台新建 Mission任务后由调度运行时自动生成"
disable-create
disable-delete
@@ -31,7 +31,8 @@ import ReflectionManagerPanel from '@/components/reflection/ReflectionManagerPan
<style scoped>
.script-page {
height: calc(100vh - 56px);
padding: 12px 14px;
padding: 10px 12px;
box-sizing: border-box;
min-height: 0;
}
</style>
@@ -566,26 +566,37 @@ onMounted(async () => {
<style scoped>
.simple-field-page {
padding: 16px;
height: 100%;
padding: 10px 12px;
height: calc(100vh - 56px);
overflow: auto;
box-sizing: border-box;
}
.page-card {
min-height: calc(100vh - 88px);
border: 1px solid rgba(40, 33, 58, 0.10) !important;
border-radius: 8px !important;
box-shadow: none !important;
background: #fffefd !important;
}
.page-card { min-height: calc(100vh - 88px); }
.page-header {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 16px;
gap: 12px;
}
.page-header h2 { margin: 0 0 4px; font-size: 18px; }
.page-header p { margin: 0; color: var(--el-text-color-secondary); font-size: 13px; }
.header-actions { display: flex; gap: 8px; flex-shrink: 0; }
.page-header h2 { margin: 0 0 2px; font-size: 15px; font-weight: 650; }
.page-header p { margin: 0; color: var(--el-text-color-secondary); font-size: 12px; }
.header-actions { display: flex; gap: 6px; flex-shrink: 0; }
.filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-bottom: 12px;
gap: 10px;
margin-bottom: 10px;
padding: 8px 10px;
border: 1px solid rgba(40, 33, 58, 0.08);
border-radius: 8px;
background: rgba(246, 243, 251, 0.65);
}
.car-type-filter,
.keyword-filter {
@@ -636,7 +647,21 @@ onMounted(async () => {
.bordered-select :deep(.el-select__placeholder) {
line-height: 30px;
}
.field-tabs { margin-top: 4px; }
.field-tabs { margin-top: 2px; }
.field-tabs :deep(.el-tabs__item) {
height: 34px;
line-height: 34px;
font-size: 13px;
}
.field-tabs :deep(.el-tabs__item.is-active) { font-weight: 700; }
.page-card :deep(.el-card__header) {
padding: 10px 14px;
border-bottom: 1px solid rgba(40, 33, 58, 0.08);
}
.page-card :deep(.el-card__body) { padding: 12px 14px; }
.page-card :deep(.el-table .cell) {
font-variant-numeric: tabular-nums;
}
.field-tabs :deep(.search-hit-row > td.el-table__cell) {
background-color: #f5f3ff !important;
}