diff --git a/MiGu.Server/Controllers/SimpleFieldController.cs b/MiGu.Server/Controllers/SimpleFieldController.cs index 9f08616..8159ed2 100644 --- a/MiGu.Server/Controllers/SimpleFieldController.cs +++ b/MiGu.Server/Controllers/SimpleFieldController.cs @@ -36,22 +36,24 @@ public sealed class SimpleFieldController : ControllerBase /// 新增单条字段;同车型 + 字段类型下 key 不可重复 /// [HttpPost] + [Authorize(Policy = "PlatformScope")] public Task Create([FromBody] SimpleFieldRequest req) => _service.SaveAsync(req); /// /// 按 id 更新单条字段 /// [HttpPut("{id:guid}")] + [Authorize(Policy = "PlatformScope")] public Task Update(Guid id, [FromBody] SimpleFieldRequest req) => _service.SaveAsync(req with { Id = id }); /// /// 按 id 删除单条字段 /// [HttpDelete("{id:guid}")] + [Authorize(Policy = "PlatformScope")] public async Task Delete(Guid id) { await _service.DeleteAsync(id); - return NoContent(); } @@ -61,10 +63,10 @@ public sealed class SimpleFieldController : ControllerBase /// 返回实际写入条数 { count }。 /// [HttpPost("batch")] + [Authorize(Policy = "PlatformScope")] public async Task SaveBatch([FromBody] SimpleFieldBatchRequest req) { var count = await _service.SaveBatchAsync(req); - return Ok(new { count }); } } diff --git a/frontends/apps/simple-platform-vue/components.d.ts b/frontends/apps/simple-platform-vue/components.d.ts index 1892a7c..a7b6129 100644 --- a/frontends/apps/simple-platform-vue/components.d.ts +++ b/frontends/apps/simple-platform-vue/components.d.ts @@ -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'] - } } diff --git a/frontends/apps/simple-platform-vue/src/api/mapEdit.ts b/frontends/apps/simple-platform-vue/src/api/mapEdit.ts index a0adbef..8269e65 100644 --- a/frontends/apps/simple-platform-vue/src/api/mapEdit.ts +++ b/frontends/apps/simple-platform-vue/src/api/mapEdit.ts @@ -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=仅 UITrack;curve=贝塞尔/弧/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(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(http.post(`${BASE}/pick`, { snap: opts?.snap ?? true })), dashboardSummary: () => unwrap(http.get(`${BASE}/dashboard/summary`)), diff --git a/frontends/apps/simple-platform-vue/src/api/projection.ts b/frontends/apps/simple-platform-vue/src/api/projection.ts index 26476e7..394d191 100644 --- a/frontends/apps/simple-platform-vue/src/api/projection.ts +++ b/frontends/apps/simple-platform-vue/src/api/projection.ts @@ -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 | null }>() + export async function listSites(): Promise { if (MOCK) return mockSites() const { data } = await http.get('/sl/projection/sites') @@ -22,14 +27,7 @@ export async function listTracks(): Promise { } 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 { return rows.map(missionFromReflection) } +async function loadCarStatus(carId: number): Promise | 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 { + 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/cars;502 或空列表时回退 reflection 对象列表(与 3D 场景同源)。 */ export async function listCars(): Promise { if (MOCK) return mockCars() try { const { data } = await http.get('/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()) } } diff --git a/frontends/apps/simple-platform-vue/src/api/reflection.ts b/frontends/apps/simple-platform-vue/src/api/reflection.ts index e5da923..2f64ebf 100644 --- a/frontends/apps/simple-platform-vue/src/api/reflection.ts +++ b/frontends/apps/simple-platform-vue/src/api/reflection.ts @@ -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(path: string): Promise { const { data } = await http.get>(`${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(path: string, params?: Record): Promise { - const { data } = await http.post>(`${BASE}${path}`, null, { params }) - if (!data?.success) throw new Error(data?.message ?? `reflection ${path} failed`) +async function post( + path: string, + params?: Record, + headers?: Record +): Promise { + const { data } = await http.post>(`${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(path: string): Promise { const { data } = await http.delete>(`${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(path: string, body: unknown): Promise { const { data } = await http.patch>(`${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( + path: string, + params?: Record, + opts?: ReflectionExecuteOptions +): Promise { + const headers = opts?.platformConfirmed ? { 'X-Platform-Confirmed': '1' } : undefined + try { + return await post(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(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) => MOCK + execute: ( + kind: ReflectionKind, + id: number, + method: string, + params?: Record, + opts?: ReflectionExecuteOptions + ) => MOCK ? Promise.resolve(mockReflectionExecute( kind, id, method, Object.fromEntries(Object.entries(params ?? {}).map(([k, v]) => [k, String(v)])) )) - : post<{ returnValue: string }>( + : executeWithPlatformConfirm( `/execute/${kind}/${id}/${encodeURIComponent(method)}`, - params + params, + opts ), /** 车辆前往指定站点(Web「去某地」;不依赖 SimpleUI.GetPoint)。 */ diff --git a/frontends/apps/simple-platform-vue/src/api/workspaceToolbar.ts b/frontends/apps/simple-platform-vue/src/api/workspaceToolbar.ts index 17363f6..9aed3fa 100644 --- a/frontends/apps/simple-platform-vue/src/api/workspaceToolbar.ts +++ b/frontends/apps/simple-platform-vue/src/api/workspaceToolbar.ts @@ -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(http.post(`${BASE}/playback/stop`)), + playPause: () => unwrap(http.post(`${BASE}/playback/play-pause`, {})), + + seekElapsed: (elapsedMs: number) => + unwrap(http.post(`${BASE}/playback/seek`, { elapsedMs, ElapsedMs: elapsedMs })), + + seekFrame: (frameIndex: number) => + unwrap(http.post(`${BASE}/playback/seek`, { frameIndex, FrameIndex: frameIndex })), + + step: (delta = 1) => + unwrap(http.post(`${BASE}/playback/step`, { delta, Delta: delta })), + + setSpeed: (speed: number) => + unwrap(http.post(`${BASE}/playback/speed`, { speed, Speed: speed })), + toggleViewMode: () => unwrap(http.post(`${BASE}/view/toggle`)), setCameraFollow: (carId: number | null, enabled: boolean) => diff --git a/frontends/apps/simple-platform-vue/src/components/reflection/PluginListPanel.vue b/frontends/apps/simple-platform-vue/src/components/reflection/PluginListPanel.vue index 0a3781c..b4aad05 100644 --- a/frontends/apps/simple-platform-vue/src/components/reflection/PluginListPanel.vue +++ b/frontends/apps/simple-platform-vue/src/components/reflection/PluginListPanel.vue @@ -137,10 +137,13 @@ defineExpose({ refresh }) diff --git a/frontends/apps/simple-platform-vue/src/components/workbench/MonitorSelectionPanel.vue b/frontends/apps/simple-platform-vue/src/components/workbench/MonitorSelectionPanel.vue index 6b5528c..7f2fac1 100644 --- a/frontends/apps/simple-platform-vue/src/components/workbench/MonitorSelectionPanel.vue +++ b/frontends/apps/simple-platform-vue/src/components/workbench/MonitorSelectionPanel.vue @@ -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; } diff --git a/frontends/apps/simple-platform-vue/src/components/workbench/SelectionDetailPanel.vue b/frontends/apps/simple-platform-vue/src/components/workbench/SelectionDetailPanel.vue index c0005a6..ec2f4e1 100644 --- a/frontends/apps/simple-platform-vue/src/components/workbench/SelectionDetailPanel.vue +++ b/frontends/apps/simple-platform-vue/src/components/workbench/SelectionDetailPanel.vue @@ -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 { diff --git a/frontends/apps/simple-platform-vue/src/components/workspace/WorkspaceCanvasToolbar.vue b/frontends/apps/simple-platform-vue/src/components/workspace/WorkspaceCanvasToolbar.vue index f1b61de..d17b443 100644 --- a/frontends/apps/simple-platform-vue/src/components/workspace/WorkspaceCanvasToolbar.vue +++ b/frontends/apps/simple-platform-vue/src/components/workspace/WorkspaceCanvasToolbar.vue @@ -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 { diff --git a/frontends/apps/simple-platform-vue/src/composables/useEditTool.ts b/frontends/apps/simple-platform-vue/src/composables/useEditTool.ts index 41bb84b..6eae460 100644 --- a/frontends/apps/simple-platform-vue/src/composables/useEditTool.ts +++ b/frontends/apps/simple-platform-vue/src/composables/useEditTool.ts @@ -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('transform.move') + // 空闲默认:不落在已移除的「移动」上,侧栏无高亮(画布仍可点选)。 + const activeTool = ref('select.single') const snap = ref({ 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': '删除', diff --git a/frontends/apps/simple-platform-vue/src/composables/useHistory.ts b/frontends/apps/simple-platform-vue/src/composables/useHistory.ts index 382ab39..f57cae3 100644 --- a/frontends/apps/simple-platform-vue/src/composables/useHistory.ts +++ b/frontends/apps/simple-platform-vue/src/composables/useHistory.ts @@ -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 + } } diff --git a/frontends/apps/simple-platform-vue/src/views/admin/CarPanelView.vue b/frontends/apps/simple-platform-vue/src/views/admin/CarPanelView.vue index 26a391b..acd29cf 100644 --- a/frontends/apps/simple-platform-vue/src/views/admin/CarPanelView.vue +++ b/frontends/apps/simple-platform-vue/src/views/admin/CarPanelView.vue @@ -1,17 +1,17 @@