diff --git a/frontends/apps/simple-platform-vue/.env.production b/frontends/apps/simple-platform-vue/.env.production index c9f5b85..02596b8 100644 --- a/frontends/apps/simple-platform-vue/.env.production +++ b/frontends/apps/simple-platform-vue/.env.production @@ -2,5 +2,7 @@ # - VITE_USE_MOCK=false:强制走 Platform.Server 真实 API,不再被 const MOCK=true 锁死。 # - VITE_API_BASE 与 dev 保持一致,由 Platform.Server 同源托管。 VITE_API_BASE=/api -VITE_VRENDER_HOST=localhost:8223 +# 留空:走 defaultVrHost() → window.location.hostname:8223。 +# 切勿写死 localhost,远程浏览器会去连访问者本机而非服务器。 +# VITE_VRENDER_HOST= VITE_USE_MOCK=false diff --git a/frontends/apps/simple-platform-vue/src/api/ota.ts b/frontends/apps/simple-platform-vue/src/api/ota.ts new file mode 100644 index 0000000..f74f6f0 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/api/ota.ts @@ -0,0 +1,123 @@ +import http from './http' +import type { + OtaJob, + OtaPackageInfo, + OtaSettings, + OtaTarget, + OtaVehicleRow +} from '@/types/ota' + +export async function getOtaSettings(): Promise { + const { data } = await http.get('/ota/settings') + return data +} + +export async function putOtaSettings(settings: OtaSettings): Promise { + const { data } = await http.put('/ota/settings', settings) + return data +} + +export async function getOtaTarget(): Promise<{ target: OtaTarget | null; summary?: Record }> { + const { data } = await http.get<{ target: OtaTarget | null; summary?: Record }>('/ota/target') + return data +} + +export async function listOtaPackages(): Promise { + const { data } = await http.get('/ota/packages') + return data +} + +export async function activateOtaPackage(id: string, name?: string): Promise { + const { data } = await http.post(`/ota/packages/${encodeURIComponent(id)}/activate`, null, { + params: name ? { name } : undefined + }) + return data +} + +export async function deleteOtaPackage(id: string): Promise { + await http.delete(`/ota/packages/${encodeURIComponent(id)}`) +} + +export async function pullOtaPackage(carId: string): Promise { + const { data } = await http.post('/ota/packages/pull', { carId }, { timeout: 120000 }) + return data +} + +export async function uploadOtaPackage(file: File): Promise { + const form = new FormData() + form.append('file', file) + const { data } = await http.post('/ota/packages/upload', form, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 300000 + }) + return data +} + +export async function listOtaVehicles(latency?: boolean): Promise { + const { data } = await http.get('/ota/vehicles', { + params: latency === undefined ? undefined : { latency }, + timeout: 60000 + }) + return data +} + +export async function listOtaJobs(take = 100): Promise { + const { data } = await http.get('/ota/jobs', { params: { take } }) + return data +} + +export async function getOtaJob(id: string): Promise { + const { data } = await http.get(`/ota/jobs/${encodeURIComponent(id)}`) + return data +} + +export async function createOtaSyncJob(body: { + carIds: string[] + components?: string[] + requireLatencyCheck?: boolean +}): Promise { + const { data } = await http.post('/ota/jobs', body) + return data +} + +export async function cancelOtaJob(id: string): Promise { + await http.post(`/ota/jobs/${encodeURIComponent(id)}/cancel`) +} + +export async function retryOtaJob(id: string): Promise { + const { data } = await http.post(`/ota/jobs/${encodeURIComponent(id)}/retry`) + return data +} + +export async function getOtaConfig(carId: string, app: string): Promise<{ json: string }> { + const { data } = await http.get<{ json: string }>(`/ota/config/${encodeURIComponent(carId)}/${encodeURIComponent(app)}`) + return data +} + +export async function pushOtaConfig(body: { + carIds: string[] + app: string + json: string + requireLatencyCheck?: boolean +}): Promise { + const { data } = await http.post('/ota/config/push', body) + return data +} + +export async function pushOtaCustomFile(opts: { + carIds: string[] + remotePath: string + restartOps: number[] + files: File[] +}): Promise { + const form = new FormData() + form.append('carIds', JSON.stringify(opts.carIds)) + form.append('remotePath', opts.remotePath) + form.append('restartOps', JSON.stringify(opts.restartOps.length ? opts.restartOps : [-1])) + for (const f of opts.files) form.append('files', f) + const { data } = await http.post('/ota/custom-file', form, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 300000 + }) + return data +} diff --git a/frontends/apps/simple-platform-vue/src/composables/useOtaWorkbench.ts b/frontends/apps/simple-platform-vue/src/composables/useOtaWorkbench.ts new file mode 100644 index 0000000..8eef0a3 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/composables/useOtaWorkbench.ts @@ -0,0 +1,69 @@ +import { computed, ref } from 'vue' +import { getOtaSettings, getOtaTarget, listOtaJobs } from '@/api/ota' +import type { OtaJob, OtaSettings, OtaTarget } from '@/types/ota' +import { OTA_COPY } from '@/views/shared/ota/otaCopy' + +const settings = ref(null) +const target = ref(null) +const targetSummary = ref>({}) +const activeJobCount = ref(0) +const loadingMeta = ref(false) +let pollTimer: ReturnType | null = null +let started = false + +export async function refreshOtaMeta() { + loadingMeta.value = true + try { + const [s, t, jobs] = await Promise.all([ + getOtaSettings(), + getOtaTarget(), + listOtaJobs(30) + ]) + settings.value = s + target.value = t.target + targetSummary.value = t.summary ?? {} + activeJobCount.value = jobs.filter((j) => + ['pending', 'probing', 'running'].includes(j.status) + ).length + } finally { + loadingMeta.value = false + } +} + +export function startOtaMetaPolling() { + if (started) return + started = true + void refreshOtaMeta() + pollTimer = setInterval(() => { + void refreshOtaMeta() + }, 8000) +} + +export function stopOtaMetaPolling() { + started = false + if (pollTimer) { + clearInterval(pollTimer) + pollTimer = null + } +} + +export function useOtaWorkbench() { + const targetLabel = computed(() => { + if (!target.value) return OTA_COPY.unsetTarget + return target.value.name || target.value.packageId + }) + + return { + settings, + target, + targetSummary, + targetLabel, + activeJobCount, + loadingMeta, + refreshMeta: refreshOtaMeta, + startPolling: startOtaMetaPolling, + stopPolling: stopOtaMetaPolling + } +} + +export type { OtaJob, OtaSettings, OtaTarget } diff --git a/frontends/apps/simple-platform-vue/src/types/ota.ts b/frontends/apps/simple-platform-vue/src/types/ota.ts new file mode 100644 index 0000000..3847359 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/types/ota.ts @@ -0,0 +1,88 @@ +export interface OtaSettings { + bandwidthKbps: number + maxCar: number + latencyEnabled: boolean + rttThresholdMs: number + overThreshold: 'skip' | 'confirm' | string + backupPeriodMinutes: number + backupExe: boolean + newVersionName?: string | null +} + +export interface OtaFileArtifact { + hash: string + path: string + fileName: string + time?: string + size: number +} + +export interface OtaTarget { + packageId: string + name?: string + activatedAt: string + components: Record +} + +export interface OtaPackageInfo { + id: string + sourceIp?: string + createdAt: string + totalBytes: number + isTarget: boolean + components: Record +} + +export interface OtaComponentVersion { + version?: string + time?: string +} + +export interface OtaAppVersions { + exe?: OtaComponentVersion + dll?: OtaComponentVersion + pdb?: OtaComponentVersion +} + +export interface OtaVehicleRow { + id: string + name: string + ip?: string + state?: string + group?: string + reachable: boolean + rttMs?: number | null + medulla?: OtaAppVersions + detour?: OtaAppVersions + clumsy?: OtaAppVersions + match?: Record +} + +export interface OtaJobStep { + carId: string + ip?: string + component: string + status: string + error?: string +} + +export interface OtaJob { + id: string + kind: string + status: string + createdAt: string + finishedAt?: string + createdBy?: string + packageId?: string + carIds: string[] + components: string[] + requireLatencyCheck: boolean + doneSteps: number + totalSteps: number + steps: OtaJobStep[] + message?: string +} + +export const OTA_COMPONENT_KEYS = ['M.exe', 'M.dll', 'M.pdb', 'D.exe', 'C.exe', 'C.dll', 'C.pdb'] as const + +export type OtaPane = 'vehicles' | 'packages' | 'jobs' | 'config' | 'custom' | 'settings' diff --git a/frontends/apps/simple-platform-vue/src/views/shared/VehicleHubView.vue b/frontends/apps/simple-platform-vue/src/views/shared/VehicleHubView.vue index 16275c3..1be40bc 100644 --- a/frontends/apps/simple-platform-vue/src/views/shared/VehicleHubView.vue +++ b/frontends/apps/simple-platform-vue/src/views/shared/VehicleHubView.vue @@ -114,19 +114,15 @@ -

故障率 = 报警占用时长 ÷ 自上线以来运行时长(SimpleLite 进程内累计)

+

+ 延迟 = 本机到车辆 WatchDog(:9776) 的 TCP 往返;故障率 = 报警占用时长 ÷ 自上线以来运行时长(SimpleLite 进程内累计) +

- -
- -
-
- - -
- + +
+
@@ -141,8 +137,7 @@ import { Search, Refresh, ArrowDown } from '@element-plus/icons-vue' import VehicleHealthCard from '@/components/fleet/VehicleHealthCard.vue' import VehicleHealthRow from '@/components/fleet/VehicleHealthRow.vue' import FleetAllocationPanel from '@/components/fleet/FleetAllocationPanel.vue' -import VehicleMaintenanceView from '@/views/admin/config/VehicleMaintenanceView.vue' -import FleetLifecycleView from '@/views/admin/config/FleetLifecycleView.vue' +import OtaWorkbenchView from '@/views/shared/ota/OtaWorkbenchView.vue' import { useVehicleHub } from '@/composables/useVehicleHub' import { useFleetGroups } from '@/composables/useFleetGroups' import { setVehicleMaintenance, type VehicleMaintenanceMode } from '@/api/vehicleOps' @@ -150,21 +145,31 @@ import type { CarState, VehicleCardModel } from '@/types/car' import { useAuthStore } from '@/stores/auth' const auth = useAuthStore() -const canWrite = computed(() => auth.scope === 'Platform' || (auth.effectivePermissions?.allowedOps ?? []).includes('*')) +const canWrite = computed(() => { + if (auth.scope === 'Platform') return true + const ops = auth.effectivePermissions?.allowedOps ?? [] + return ops.includes('*') || ops.some((o) => o === 'ops.ota' || o.startsWith('ops.ota.')) +}) // Tab 与 URL ?tab= 同步,支持深链接(旧 /config/vehicle、/config/fleet 已下线,统一进车辆运维)。 const route = useRoute() const router = useRouter() -const TAB_NAMES = ['overview', 'maintenance', 'fleet'] as const +const TAB_NAMES = ['overview', 'ota'] as const type TabName = (typeof TAB_NAMES)[number] +const LEGACY_TABS = new Set(['maintenance', 'fleet']) function readTab(): TabName { const q = route.query.tab + if (typeof q === 'string' && LEGACY_TABS.has(q)) return 'ota' return typeof q === 'string' && (TAB_NAMES as readonly string[]).includes(q) ? (q as TabName) : 'overview' } const activeTab = ref(readTab()) -watch(activeTab, (t) => { - if (route.query.tab !== t) router.replace({ query: { ...route.query, tab: t } }) -}) +watch( + activeTab, + (t) => { + if (route.query.tab !== t) router.replace({ query: { ...route.query, tab: t } }) + }, + { immediate: true } +) watch(() => route.query.tab, () => { const next = readTab() if (next !== activeTab.value) activeTab.value = next @@ -326,7 +331,8 @@ async function onBatchCommand(cmd: string) { overflow: hidden; } -.config-pane { +.config-pane, +.ota-pane { height: 100%; overflow: auto; } diff --git a/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaConfigPane.vue b/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaConfigPane.vue new file mode 100644 index 0000000..82cc672 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaConfigPane.vue @@ -0,0 +1,491 @@ + + + + + diff --git a/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaCustomFilePane.vue b/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaCustomFilePane.vue new file mode 100644 index 0000000..ff9d8c3 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaCustomFilePane.vue @@ -0,0 +1,406 @@ + + + + + diff --git a/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaJobsPane.vue b/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaJobsPane.vue new file mode 100644 index 0000000..ab0acf5 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaJobsPane.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaPackagesPane.vue b/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaPackagesPane.vue new file mode 100644 index 0000000..f3b2d97 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaPackagesPane.vue @@ -0,0 +1,234 @@ + + + + + diff --git a/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaSettingsPane.vue b/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaSettingsPane.vue new file mode 100644 index 0000000..d9673dd --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaSettingsPane.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaVehiclesPane.vue b/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaVehiclesPane.vue new file mode 100644 index 0000000..0e5afd2 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaVehiclesPane.vue @@ -0,0 +1,419 @@ + + + + + diff --git a/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaWorkbenchView.vue b/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaWorkbenchView.vue new file mode 100644 index 0000000..026b786 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/views/shared/ota/OtaWorkbenchView.vue @@ -0,0 +1,295 @@ + + + + + + + + diff --git a/frontends/apps/simple-platform-vue/src/views/shared/ota/jsonPath.ts b/frontends/apps/simple-platform-vue/src/views/shared/ota/jsonPath.ts new file mode 100644 index 0000000..a827d06 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/views/shared/ota/jsonPath.ts @@ -0,0 +1,82 @@ +/** Build nested partial object from path like root.Network.Timeout (aligned with OTA Electron resolvePath). */ +export function resolvePathPartial(data: any, path: string): Record { + const raw = path.replace(/^root\.?/, '') + if (!raw) { + if (data != null && typeof data === 'object' && !Array.isArray(data)) { + return { ...(data as Record) } + } + return {} + } + const keys = raw.split(/\.|\[|\]/).filter(Boolean) + if (!keys.length) return {} + let result = data + const currentObject: Record = {} + keys.reduce((acc: any, key: string, index: number, array: string[]) => { + if (index === array.length - 1) { + acc[key] = result?.[key] + } else { + acc[key] = {} + } + result = result?.[key] + return acc[key] + }, currentObject) + return currentObject +} + +export interface JsonTreeNode { + label: string + path: string + valuePreview: string + selectable: boolean + children?: JsonTreeNode[] +} + +export function jsonToTree(data: unknown, path = 'root', depth = 0): JsonTreeNode[] { + if (data == null || typeof data !== 'object') return [] + if (Array.isArray(data)) { + return data.map((item, i) => { + const p = `${path}[${i}]` + const isObj = item != null && typeof item === 'object' + return { + label: `[${i}]`, + path: p, + valuePreview: preview(item), + selectable: false, + children: isObj && depth < 8 ? jsonToTree(item, p, depth + 1) : undefined + } + }) + } + return Object.keys(data as object).map((key) => { + const val = (data as any)[key] + const p = path === 'root' ? `root.${key}` : `${path}.${key}` + const isObj = val != null && typeof val === 'object' + return { + label: key, + path: p, + valuePreview: preview(val), + selectable: true, + children: isObj && depth < 8 ? jsonToTree(val, p, depth + 1) : undefined + } + }) +} + +function preview(v: unknown): string { + if (v == null) return 'null' + if (typeof v === 'string') return v.length > 40 ? `"${v.slice(0, 40)}…"` : `"${v}"` + if (typeof v === 'number' || typeof v === 'boolean') return String(v) + if (Array.isArray(v)) return `Array(${v.length})` + return `Object(${Object.keys(v as object).length})` +} + +export function getValueAtPath(data: any, path: string): unknown { + const keys = path + .replace(/^root\.?/, '') + .split(/\.|\[|\]/) + .filter(Boolean) + let cur = data + for (const k of keys) { + if (cur == null) return undefined + cur = cur[k] + } + return cur +} diff --git a/frontends/apps/simple-platform-vue/src/views/shared/ota/otaCopy.ts b/frontends/apps/simple-platform-vue/src/views/shared/ota/otaCopy.ts new file mode 100644 index 0000000..d38aa00 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/views/shared/ota/otaCopy.ts @@ -0,0 +1,47 @@ +/** OTA UI copy — kept in one UTF-8 module to avoid editor/encoding corruption in templates. */ +export const OTA_COPY = { + searchPh: '搜索 ID / 名称 / IP', + onlyMismatch: '仅显示不一致', + latency: '网络延迟检测', + refresh: '刷新', + sync: '同步', + syncAll: '同步全部组件', + syncM: '仅 Medulla.exe', + syncD: '仅 Detour.exe', + syncC: '仅 Clumsy.exe', + vehicle: '车辆', + state: '状态', + selectedPrefix: '已选', + selectedSuffix: '台', + batchPrefix: '批次约', + startDeploy: '开始下发', + loadFail: '加载车辆失败', + latencySaveFail: '保存延迟开关失败', + confirmTitle: '确认 OTA 下发', + confirmOk: '开始下发', + confirmCancel: '取消', + confirmBody: (n: number, comps: string) => `将向 ${n} 台车下发${comps}。确认继续?`, + compsAll: '全部组件', + skipLatency: (n: number) => `已排除 ${n} 台延迟超限车辆`, + noCars: '没有可下发的车辆', + jobCreated: '任务已创建', + jobFail: '创建任务失败', + unsetTarget: '未设置目标版本', + activateHint: '请先在版本库激活一个包', + navVehicles: '车辆升级', + navPackages: '版本库', + navJobs: '任务进度', + navConfig: '参数配置', + navCustom: '自定义同步', + navSettings: '设置', + pullFromCar: '拉取选中车版本', + pullNeedOne: '请先勾选恰好一台车辆再拉取', + pullNeedIp: '该车无 IP,无法拉取', + pullOk: '拉取完成,可到版本库设为目标', + pullEmpty: '未收到文件:请把车上 WatchDog 的 serverIP 设为本机局域网 IP(回传端口 8000)', + pullFail: '拉取失败', + receiveHint: + '拉包回传:WatchDog 固定 POST 到 http://{serverIP}:8000/upload-mdcs/*。请在各车 watch_dog.json 把 serverIP 设为运行迷毂的电脑局域网 IP;迷毂已同时监听 8000 接收。', + jobsHint: '任务进度用于跟踪「同步升级 / 参数下发 / 自定义文件」的执行结果,支持查看明细、取消未开始批次、重试失败车辆。', + vehiclesHint: '勾选车辆后可「拉取选中车版本」或「同步」;拉取请只选一台有 IP 的车。' +} as const