新增车辆运维 OTA 工作台前端。
支持包管理、车辆同步、任务与自定义文件下发,并按 ops.ota* 对齐可写权限。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,5 +2,7 @@
|
|||||||
# - VITE_USE_MOCK=false:强制走 Platform.Server 真实 API,不再被 const MOCK=true 锁死。
|
# - VITE_USE_MOCK=false:强制走 Platform.Server 真实 API,不再被 const MOCK=true 锁死。
|
||||||
# - VITE_API_BASE 与 dev 保持一致,由 Platform.Server 同源托管。
|
# - VITE_API_BASE 与 dev 保持一致,由 Platform.Server 同源托管。
|
||||||
VITE_API_BASE=/api
|
VITE_API_BASE=/api
|
||||||
VITE_VRENDER_HOST=localhost:8223
|
# 留空:走 defaultVrHost() → window.location.hostname:8223。
|
||||||
|
# 切勿写死 localhost,远程浏览器会去连访问者本机而非服务器。
|
||||||
|
# VITE_VRENDER_HOST=
|
||||||
VITE_USE_MOCK=false
|
VITE_USE_MOCK=false
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import http from './http'
|
||||||
|
import type {
|
||||||
|
OtaJob,
|
||||||
|
OtaPackageInfo,
|
||||||
|
OtaSettings,
|
||||||
|
OtaTarget,
|
||||||
|
OtaVehicleRow
|
||||||
|
} from '@/types/ota'
|
||||||
|
|
||||||
|
export async function getOtaSettings(): Promise<OtaSettings> {
|
||||||
|
const { data } = await http.get<OtaSettings>('/ota/settings')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putOtaSettings(settings: OtaSettings): Promise<OtaSettings> {
|
||||||
|
const { data } = await http.put<OtaSettings>('/ota/settings', settings)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOtaTarget(): Promise<{ target: OtaTarget | null; summary?: Record<string, string> }> {
|
||||||
|
const { data } = await http.get<{ target: OtaTarget | null; summary?: Record<string, string> }>('/ota/target')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listOtaPackages(): Promise<OtaPackageInfo[]> {
|
||||||
|
const { data } = await http.get<OtaPackageInfo[]>('/ota/packages')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function activateOtaPackage(id: string, name?: string): Promise<OtaTarget> {
|
||||||
|
const { data } = await http.post<OtaTarget>(`/ota/packages/${encodeURIComponent(id)}/activate`, null, {
|
||||||
|
params: name ? { name } : undefined
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteOtaPackage(id: string): Promise<void> {
|
||||||
|
await http.delete(`/ota/packages/${encodeURIComponent(id)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pullOtaPackage(carId: string): Promise<OtaPackageInfo> {
|
||||||
|
const { data } = await http.post<OtaPackageInfo>('/ota/packages/pull', { carId }, { timeout: 120000 })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadOtaPackage(file: File): Promise<OtaPackageInfo> {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
const { data } = await http.post<OtaPackageInfo>('/ota/packages/upload', form, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
timeout: 300000
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listOtaVehicles(latency?: boolean): Promise<OtaVehicleRow[]> {
|
||||||
|
const { data } = await http.get<OtaVehicleRow[]>('/ota/vehicles', {
|
||||||
|
params: latency === undefined ? undefined : { latency },
|
||||||
|
timeout: 60000
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listOtaJobs(take = 100): Promise<OtaJob[]> {
|
||||||
|
const { data } = await http.get<OtaJob[]>('/ota/jobs', { params: { take } })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOtaJob(id: string): Promise<OtaJob> {
|
||||||
|
const { data } = await http.get<OtaJob>(`/ota/jobs/${encodeURIComponent(id)}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createOtaSyncJob(body: {
|
||||||
|
carIds: string[]
|
||||||
|
components?: string[]
|
||||||
|
requireLatencyCheck?: boolean
|
||||||
|
}): Promise<OtaJob> {
|
||||||
|
const { data } = await http.post<OtaJob>('/ota/jobs', body)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelOtaJob(id: string): Promise<void> {
|
||||||
|
await http.post(`/ota/jobs/${encodeURIComponent(id)}/cancel`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retryOtaJob(id: string): Promise<OtaJob> {
|
||||||
|
const { data } = await http.post<OtaJob>(`/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<OtaJob> {
|
||||||
|
const { data } = await http.post<OtaJob>('/ota/config/push', body)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pushOtaCustomFile(opts: {
|
||||||
|
carIds: string[]
|
||||||
|
remotePath: string
|
||||||
|
restartOps: number[]
|
||||||
|
files: File[]
|
||||||
|
}): Promise<OtaJob> {
|
||||||
|
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<OtaJob>('/ota/custom-file', form, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
timeout: 300000
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
@@ -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<OtaSettings | null>(null)
|
||||||
|
const target = ref<OtaTarget | null>(null)
|
||||||
|
const targetSummary = ref<Record<string, string>>({})
|
||||||
|
const activeJobCount = ref(0)
|
||||||
|
const loadingMeta = ref(false)
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | 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 }
|
||||||
@@ -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<string, OtaFileArtifact>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OtaPackageInfo {
|
||||||
|
id: string
|
||||||
|
sourceIp?: string
|
||||||
|
createdAt: string
|
||||||
|
totalBytes: number
|
||||||
|
isTarget: boolean
|
||||||
|
components: Record<string, OtaFileArtifact>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
@@ -114,19 +114,15 @@
|
|||||||
|
|
||||||
<FleetAllocationPanel :cars="cardModels" :can-write="canWrite" @saved="onFleetSaved" />
|
<FleetAllocationPanel :cars="cardModels" :can-write="canWrite" @saved="onFleetSaved" />
|
||||||
|
|
||||||
<p class="footnote">故障率 = 报警占用时长 ÷ 自上线以来运行时长(SimpleLite 进程内累计)</p>
|
<p class="footnote">
|
||||||
|
延迟 = 本机到车辆 WatchDog(:9776) 的 TCP 往返;故障率 = 报警占用时长 ÷ 自上线以来运行时长(SimpleLite 进程内累计)
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<el-tab-pane name="maintenance" label="维护策略" lazy>
|
<el-tab-pane name="ota" label="OTA" lazy>
|
||||||
<div class="config-pane">
|
<div class="ota-pane">
|
||||||
<VehicleMaintenanceView />
|
<OtaWorkbenchView :can-write="canWrite" />
|
||||||
</div>
|
|
||||||
</el-tab-pane>
|
|
||||||
|
|
||||||
<el-tab-pane name="fleet" label="车队生命周期" lazy>
|
|
||||||
<div class="config-pane">
|
|
||||||
<FleetLifecycleView />
|
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
@@ -141,8 +137,7 @@ import { Search, Refresh, ArrowDown } from '@element-plus/icons-vue'
|
|||||||
import VehicleHealthCard from '@/components/fleet/VehicleHealthCard.vue'
|
import VehicleHealthCard from '@/components/fleet/VehicleHealthCard.vue'
|
||||||
import VehicleHealthRow from '@/components/fleet/VehicleHealthRow.vue'
|
import VehicleHealthRow from '@/components/fleet/VehicleHealthRow.vue'
|
||||||
import FleetAllocationPanel from '@/components/fleet/FleetAllocationPanel.vue'
|
import FleetAllocationPanel from '@/components/fleet/FleetAllocationPanel.vue'
|
||||||
import VehicleMaintenanceView from '@/views/admin/config/VehicleMaintenanceView.vue'
|
import OtaWorkbenchView from '@/views/shared/ota/OtaWorkbenchView.vue'
|
||||||
import FleetLifecycleView from '@/views/admin/config/FleetLifecycleView.vue'
|
|
||||||
import { useVehicleHub } from '@/composables/useVehicleHub'
|
import { useVehicleHub } from '@/composables/useVehicleHub'
|
||||||
import { useFleetGroups } from '@/composables/useFleetGroups'
|
import { useFleetGroups } from '@/composables/useFleetGroups'
|
||||||
import { setVehicleMaintenance, type VehicleMaintenanceMode } from '@/api/vehicleOps'
|
import { setVehicleMaintenance, type VehicleMaintenanceMode } from '@/api/vehicleOps'
|
||||||
@@ -150,21 +145,31 @@ import type { CarState, VehicleCardModel } from '@/types/car'
|
|||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
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 已下线,统一进车辆运维)。
|
// Tab 与 URL ?tab= 同步,支持深链接(旧 /config/vehicle、/config/fleet 已下线,统一进车辆运维)。
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const TAB_NAMES = ['overview', 'maintenance', 'fleet'] as const
|
const TAB_NAMES = ['overview', 'ota'] as const
|
||||||
type TabName = (typeof TAB_NAMES)[number]
|
type TabName = (typeof TAB_NAMES)[number]
|
||||||
|
const LEGACY_TABS = new Set(['maintenance', 'fleet'])
|
||||||
function readTab(): TabName {
|
function readTab(): TabName {
|
||||||
const q = route.query.tab
|
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'
|
return typeof q === 'string' && (TAB_NAMES as readonly string[]).includes(q) ? (q as TabName) : 'overview'
|
||||||
}
|
}
|
||||||
const activeTab = ref<TabName>(readTab())
|
const activeTab = ref<TabName>(readTab())
|
||||||
watch(activeTab, (t) => {
|
watch(
|
||||||
if (route.query.tab !== t) router.replace({ query: { ...route.query, tab: t } })
|
activeTab,
|
||||||
})
|
(t) => {
|
||||||
|
if (route.query.tab !== t) router.replace({ query: { ...route.query, tab: t } })
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
watch(() => route.query.tab, () => {
|
watch(() => route.query.tab, () => {
|
||||||
const next = readTab()
|
const next = readTab()
|
||||||
if (next !== activeTab.value) activeTab.value = next
|
if (next !== activeTab.value) activeTab.value = next
|
||||||
@@ -326,7 +331,8 @@ async function onBatchCommand(cmd: string) {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-pane {
|
.config-pane,
|
||||||
|
.ota-pane {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,491 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ota-config">
|
||||||
|
<div class="split">
|
||||||
|
<!-- 左:车辆列表 -->
|
||||||
|
<section class="pane left">
|
||||||
|
<div class="pane-head">
|
||||||
|
<el-input v-model="carSearch" size="small" clearable placeholder="名称 / IP" class="grow" />
|
||||||
|
<el-button size="small" :icon="Refresh" :loading="carsLoading" @click="loadCars" />
|
||||||
|
</div>
|
||||||
|
<el-table
|
||||||
|
v-loading="carsLoading"
|
||||||
|
:data="filteredCars"
|
||||||
|
size="small"
|
||||||
|
height="520"
|
||||||
|
highlight-current-row
|
||||||
|
row-key="id"
|
||||||
|
@current-change="onPickCar"
|
||||||
|
>
|
||||||
|
<el-table-column prop="name" label="名称" min-width="100" />
|
||||||
|
<el-table-column prop="ip" label="IP" width="120">
|
||||||
|
<template #default="{ row }"><span class="mono">{{ row.ip || '-' }}</span></template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 右:JSON(对齐参考 OTA:选节点 → 选择 JSON 数据 → 修改 → 同步小车) -->
|
||||||
|
<section class="pane right">
|
||||||
|
<el-tabs v-model="app" @tab-change="onTabChange">
|
||||||
|
<el-tab-pane label="Medulla 配置" name="medulla" />
|
||||||
|
<el-tab-pane label="Detour 配置" name="detour" />
|
||||||
|
<el-tab-pane label="Clumsy 配置" name="clumsy" />
|
||||||
|
</el-tabs>
|
||||||
|
|
||||||
|
<p class="flow-hint">
|
||||||
|
流程:左侧点选车辆 → 树上点选要改的字段 →「选择 JSON 数据」编辑 →「同步小车」勾选目标车下发。也可直接「编辑全部配置」。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-loading="jsonLoading" class="json-body">
|
||||||
|
<el-empty v-if="!currentCar" description="请先在左侧点选一台有 IP 的车" :image-size="72" />
|
||||||
|
<el-empty v-else-if="!jsonData" description="无数据(该车无 IP 或 WatchDog 不可达)" :image-size="72" />
|
||||||
|
<template v-else>
|
||||||
|
<div class="json-scroll">
|
||||||
|
<el-tree
|
||||||
|
:data="treeData"
|
||||||
|
node-key="path"
|
||||||
|
highlight-current
|
||||||
|
:current-node-key="selectedPath || undefined"
|
||||||
|
:default-expand-all="false"
|
||||||
|
:default-expanded-keys="defaultExpanded"
|
||||||
|
:expand-on-click-node="false"
|
||||||
|
@node-click="onNodeClick"
|
||||||
|
>
|
||||||
|
<template #default="{ data }">
|
||||||
|
<span
|
||||||
|
class="tree-node"
|
||||||
|
:class="{
|
||||||
|
disabled: !isSelectable(data),
|
||||||
|
selected: data.path === selectedPath || data.path === selectedSourcePath
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<span class="radio" :class="{ on: data.path === selectedPath || data.path === selectedSourcePath }" />
|
||||||
|
<em>{{ data.label }}</em>
|
||||||
|
<span class="preview">{{ data.valuePreview }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-tree>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 固定底栏:不被长树顶出视口 -->
|
||||||
|
<div class="json-actions">
|
||||||
|
<div class="sel-block">
|
||||||
|
<span class="label">已选路径</span>
|
||||||
|
<code class="sel mono">{{ selectedPath || '(尚未选择)' }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="btns">
|
||||||
|
<el-button size="small" :disabled="!jsonData || !canWrite" @click="openEditAll">
|
||||||
|
编辑全部配置
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:disabled="!selectedPath || !canWrite"
|
||||||
|
@click="openEdit"
|
||||||
|
>
|
||||||
|
选择 JSON 数据
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 修改 JSON -->
|
||||||
|
<el-dialog v-model="editVisible" title="修改 JSON" width="640px" destroy-on-close align-center>
|
||||||
|
<p class="dlg-hint">编辑下方 JSON 片段(WatchDog 会与车上配置深度合并)。改完后点「同步小车」选择下发目标。</p>
|
||||||
|
<el-input v-model="editText" type="textarea" :rows="18" class="edit-area" />
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="editVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :disabled="!canWrite" @click="openCarSelect">同步小车</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 选择车辆 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="carSelectVisible"
|
||||||
|
title="选择车辆"
|
||||||
|
width="560px"
|
||||||
|
destroy-on-close
|
||||||
|
align-center
|
||||||
|
@opened="onCarSelectOpened"
|
||||||
|
>
|
||||||
|
<p class="dlg-hint">勾选要接收该参数的车辆(需有 IP)。默认已勾选当前查看的车。</p>
|
||||||
|
<el-input v-model="pushSearch" size="small" clearable placeholder="筛选名称 / IP" class="mb8" />
|
||||||
|
<el-table
|
||||||
|
ref="pushTableRef"
|
||||||
|
:data="pushFiltered"
|
||||||
|
size="small"
|
||||||
|
height="320"
|
||||||
|
row-key="id"
|
||||||
|
class="push-table"
|
||||||
|
@selection-change="onPushSelection"
|
||||||
|
>
|
||||||
|
<el-table-column type="selection" width="48" />
|
||||||
|
<el-table-column prop="name" label="名称" min-width="120" />
|
||||||
|
<el-table-column prop="ip" label="IP" width="140">
|
||||||
|
<template #default="{ row }"><span class="mono">{{ row.ip }}</span></template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<p class="sel-count">已选 {{ pushIds.length }} 台</p>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="carSelectVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="pushing" :disabled="!pushIds.length" @click="confirmPush">
|
||||||
|
确定下发
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||||
|
import { Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage, type ElTable } from 'element-plus'
|
||||||
|
import { getOtaConfig, listOtaVehicles, pushOtaConfig } from '@/api/ota'
|
||||||
|
import type { OtaVehicleRow } from '@/types/ota'
|
||||||
|
import { resolvePathPartial, jsonToTree, type JsonTreeNode } from './jsonPath'
|
||||||
|
|
||||||
|
const props = defineProps<{ canWrite: boolean }>()
|
||||||
|
const emit = defineEmits<{ jobCreated: [] }>()
|
||||||
|
|
||||||
|
const cars = ref<OtaVehicleRow[]>([])
|
||||||
|
const carsLoading = ref(false)
|
||||||
|
const carSearch = ref('')
|
||||||
|
const currentCar = ref<OtaVehicleRow | null>(null)
|
||||||
|
const app = ref<'medulla' | 'detour' | 'clumsy'>('clumsy')
|
||||||
|
const jsonData = ref<any>(null)
|
||||||
|
const jsonLoading = ref(false)
|
||||||
|
const treeData = ref<JsonTreeNode[]>([])
|
||||||
|
const selectedPath = ref('')
|
||||||
|
const selectedSourcePath = ref('')
|
||||||
|
const defaultExpanded = ref<string[]>([])
|
||||||
|
const editVisible = ref(false)
|
||||||
|
const editText = ref('')
|
||||||
|
const editPartial = ref<Record<string, unknown>>({})
|
||||||
|
const carSelectVisible = ref(false)
|
||||||
|
const pushSearch = ref('')
|
||||||
|
const pushIds = ref<string[]>([])
|
||||||
|
const pushing = ref(false)
|
||||||
|
const pushTableRef = ref<InstanceType<typeof ElTable>>()
|
||||||
|
|
||||||
|
const filteredCars = computed(() => {
|
||||||
|
const q = carSearch.value.trim().toLowerCase()
|
||||||
|
if (!q) return cars.value
|
||||||
|
return cars.value.filter((c) => [c.name, c.ip, c.id].some((x) => (x || '').toLowerCase().includes(q)))
|
||||||
|
})
|
||||||
|
|
||||||
|
const pushFiltered = computed(() => {
|
||||||
|
const q = pushSearch.value.trim().toLowerCase()
|
||||||
|
const withIp = cars.value.filter((c) => !!c.ip)
|
||||||
|
if (!q) return withIp
|
||||||
|
return withIp.filter((c) => [c.name, c.ip, c.id].some((x) => (x || '').toLowerCase().includes(q)))
|
||||||
|
})
|
||||||
|
|
||||||
|
function isSelectable(data: JsonTreeNode) {
|
||||||
|
return data.selectable || /\[\d+\]$/.test(data.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCars() {
|
||||||
|
carsLoading.value = true
|
||||||
|
try {
|
||||||
|
cars.value = await listOtaVehicles(false)
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || '加载车辆失败')
|
||||||
|
} finally {
|
||||||
|
carsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadJson() {
|
||||||
|
if (!currentCar.value?.ip) {
|
||||||
|
jsonData.value = null
|
||||||
|
treeData.value = []
|
||||||
|
selectedPath.value = ''
|
||||||
|
selectedSourcePath.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonLoading.value = true
|
||||||
|
selectedPath.value = ''
|
||||||
|
selectedSourcePath.value = ''
|
||||||
|
try {
|
||||||
|
const res = await getOtaConfig(currentCar.value.id, app.value)
|
||||||
|
jsonData.value = typeof res.json === 'string' ? JSON.parse(res.json) : res.json
|
||||||
|
treeData.value = jsonToTree(jsonData.value)
|
||||||
|
// 默认展开第一层,避免整树铺满
|
||||||
|
defaultExpanded.value = treeData.value.map((n) => n.path)
|
||||||
|
} catch (e: any) {
|
||||||
|
jsonData.value = null
|
||||||
|
treeData.value = []
|
||||||
|
ElMessage.error(e?.message || '拉取配置失败')
|
||||||
|
} finally {
|
||||||
|
jsonLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPickCar(row: OtaVehicleRow | null) {
|
||||||
|
currentCar.value = row
|
||||||
|
void loadJson()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTabChange() {
|
||||||
|
void loadJson()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对齐参考 OTA:点到数组或其子项时改为选中整个数组字段,便于深度合并 */
|
||||||
|
function resolveSelectPath(data: JsonTreeNode): string | null {
|
||||||
|
const path = data.path
|
||||||
|
const arr = path.match(/^(.*?)\[\d+\]/)
|
||||||
|
if (arr) return arr[1] || null
|
||||||
|
if (!data.selectable) return null
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNodeClick(data: JsonTreeNode) {
|
||||||
|
const path = resolveSelectPath(data)
|
||||||
|
if (!path) {
|
||||||
|
selectedPath.value = ''
|
||||||
|
selectedSourcePath.value = ''
|
||||||
|
ElMessage.warning('请选择对象字段(或数组元素,将同步整个数组)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectedSourcePath.value = data.path
|
||||||
|
selectedPath.value = path
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit() {
|
||||||
|
if (!selectedPath.value || !jsonData.value) {
|
||||||
|
ElMessage.warning('请先在树上点选要修改的配置字段')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!props.canWrite) {
|
||||||
|
ElMessage.warning('当前账号无写权限')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const partial = resolvePathPartial(jsonData.value, selectedPath.value)
|
||||||
|
editPartial.value = partial
|
||||||
|
editText.value = JSON.stringify(partial, null, 2)
|
||||||
|
editVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditAll() {
|
||||||
|
if (!jsonData.value) return
|
||||||
|
if (!props.canWrite) {
|
||||||
|
ElMessage.warning('当前账号无写权限')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectedPath.value = 'root'
|
||||||
|
selectedSourcePath.value = 'root'
|
||||||
|
editPartial.value = jsonData.value
|
||||||
|
editText.value = JSON.stringify(jsonData.value, null, 2)
|
||||||
|
editVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCarSelect() {
|
||||||
|
try {
|
||||||
|
editPartial.value = JSON.parse(editText.value)
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('JSON 格式无效')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pushIds.value = currentCar.value?.id ? [currentCar.value.id] : []
|
||||||
|
carSelectVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPushSelection(rows: OtaVehicleRow[]) {
|
||||||
|
pushIds.value = rows.map((r) => r.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCarSelectOpened() {
|
||||||
|
await nextTick()
|
||||||
|
const table = pushTableRef.value
|
||||||
|
if (!table) return
|
||||||
|
table.clearSelection()
|
||||||
|
const prefer = new Set(pushIds.value)
|
||||||
|
for (const row of pushFiltered.value) {
|
||||||
|
if (prefer.has(row.id)) table.toggleRowSelection(row, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmPush() {
|
||||||
|
if (!pushIds.value.length) {
|
||||||
|
ElMessage.warning('请至少选择一台车')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pushing.value = true
|
||||||
|
try {
|
||||||
|
await pushOtaConfig({
|
||||||
|
carIds: pushIds.value,
|
||||||
|
app: app.value,
|
||||||
|
json: JSON.stringify(editPartial.value)
|
||||||
|
})
|
||||||
|
ElMessage.success('任务已创建,可在「任务进度」查看')
|
||||||
|
carSelectVisible.value = false
|
||||||
|
editVisible.value = false
|
||||||
|
emit('jobCreated')
|
||||||
|
void loadJson()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || '下发失败')
|
||||||
|
} finally {
|
||||||
|
pushing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => void loadCars())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ota-config {
|
||||||
|
color: rgb(var(--mg-text-tint-rgb));
|
||||||
|
}
|
||||||
|
.split {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(260px, 1fr) minmax(360px, 1.35fr);
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 560px;
|
||||||
|
}
|
||||||
|
.pane {
|
||||||
|
border: 1px solid rgba(var(--mg-primary-rgb), 0.12);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.pane-head {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.grow {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.flow-hint {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: rgba(var(--mg-text-hi-rgb), 0.62);
|
||||||
|
}
|
||||||
|
.json-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 420px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid rgba(var(--mg-primary-rgb), 0.08);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.json-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 280px;
|
||||||
|
max-height: 420px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
.tree-node {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 1px 0;
|
||||||
|
}
|
||||||
|
.tree-node .radio {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px solid rgba(var(--mg-primary-rgb), 0.45);
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.tree-node .radio.on {
|
||||||
|
border-color: var(--mg-primary);
|
||||||
|
background: radial-gradient(circle at center, var(--mg-primary) 0 40%, transparent 42%);
|
||||||
|
}
|
||||||
|
.tree-node.selected em {
|
||||||
|
color: var(--mg-primary);
|
||||||
|
}
|
||||||
|
.tree-node em {
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgb(var(--mg-text-tint-rgb));
|
||||||
|
}
|
||||||
|
.tree-node .preview {
|
||||||
|
color: rgba(var(--mg-text-hi-rgb), 0.55);
|
||||||
|
font-family: var(--mg-font-mono);
|
||||||
|
}
|
||||||
|
.tree-node.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.json-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-top: 1px solid rgba(var(--mg-primary-rgb), 0.12);
|
||||||
|
background: rgba(var(--mg-primary-rgb), 0.04);
|
||||||
|
}
|
||||||
|
.sel-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.sel-block .label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(var(--mg-text-hi-rgb), 0.5);
|
||||||
|
}
|
||||||
|
.sel {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mg-primary);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.btns {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.mono {
|
||||||
|
font-family: var(--mg-font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.edit-area :deep(textarea) {
|
||||||
|
font-family: var(--mg-font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.dlg-hint {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(var(--mg-text-hi-rgb), 0.65);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.mb8 {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.sel-count {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(var(--mg-text-hi-rgb), 0.55);
|
||||||
|
}
|
||||||
|
.push-table :deep(.el-checkbox__inner) {
|
||||||
|
width: 16px !important;
|
||||||
|
height: 16px !important;
|
||||||
|
border: 1.5px solid #7c3aed !important;
|
||||||
|
background: #fff !important;
|
||||||
|
}
|
||||||
|
.push-table :deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
|
||||||
|
background: #7c3aed !important;
|
||||||
|
border-color: #7c3aed !important;
|
||||||
|
}
|
||||||
|
.push-table :deep(.el-checkbox__input.is-checked .el-checkbox__inner::after) {
|
||||||
|
border-color: #fff !important;
|
||||||
|
}
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.split {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ota-custom">
|
||||||
|
<header class="head">
|
||||||
|
<h3>自定义文件同步</h3>
|
||||||
|
<p>支持多文件批量推送;目标路径为小车内目录,各文件保留原文件名。对齐 CarOTA 自定义同步。</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="split">
|
||||||
|
<section class="left">
|
||||||
|
<div class="step">
|
||||||
|
<h4>1. 本地文件(可多选)</h4>
|
||||||
|
<div class="row">
|
||||||
|
<el-button size="small" @click="clearFiles">清空</el-button>
|
||||||
|
<el-upload
|
||||||
|
:auto-upload="false"
|
||||||
|
:show-file-list="false"
|
||||||
|
multiple
|
||||||
|
:on-change="onFileChange"
|
||||||
|
>
|
||||||
|
<el-button size="small" type="primary" plain>添加文件…</el-button>
|
||||||
|
</el-upload>
|
||||||
|
</div>
|
||||||
|
<ul class="file-list">
|
||||||
|
<li v-for="(f, i) in files" :key="f.uid">
|
||||||
|
<span class="mono">{{ f.name }}</span>
|
||||||
|
<button type="button" class="x" @click="removeFile(i)">×</button>
|
||||||
|
</li>
|
||||||
|
<li v-if="!files.length" class="empty">尚未添加文件</li>
|
||||||
|
</ul>
|
||||||
|
<p class="muted">已选 {{ files.length }} 个文件</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step">
|
||||||
|
<h4>2. 小车内目标路径</h4>
|
||||||
|
<el-select
|
||||||
|
v-model="remotePath"
|
||||||
|
size="small"
|
||||||
|
filterable
|
||||||
|
allow-create
|
||||||
|
default-first-option
|
||||||
|
class="w-full"
|
||||||
|
placeholder="例如 C:\\Program Files\\Medulla\\plugins"
|
||||||
|
>
|
||||||
|
<el-option v-for="h in pathHistory" :key="h" :label="h" :value="h" />
|
||||||
|
</el-select>
|
||||||
|
<p class="muted">所有文件推到同一目录,各自保留文件名</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step">
|
||||||
|
<h4>3. 更新后重启(可多选)</h4>
|
||||||
|
<p class="warn">不勾选则只推送文件。WatchDog 更新建议勾选重启。</p>
|
||||||
|
<el-checkbox v-model="restartMedulla">重启 Medulla</el-checkbox>
|
||||||
|
<el-checkbox v-model="restartClumsy">重启 Clumsy</el-checkbox>
|
||||||
|
<el-checkbox v-model="restartDetour">重启 Detour</el-checkbox>
|
||||||
|
<el-checkbox v-model="restartWatchDog">重启 WatchDog</el-checkbox>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="right">
|
||||||
|
<div class="right-head">
|
||||||
|
<h4>4. 选择车辆</h4>
|
||||||
|
<el-checkbox v-model="selectAll" :indeterminate="indeterminate" @change="onSelectAll">全选</el-checkbox>
|
||||||
|
</div>
|
||||||
|
<p class="muted">已选 {{ selectedIds.length }} / {{ carsWithIp.length }} 台</p>
|
||||||
|
<div class="car-cards">
|
||||||
|
<button
|
||||||
|
v-for="c in carsWithIp"
|
||||||
|
:key="c.id"
|
||||||
|
type="button"
|
||||||
|
class="car-card"
|
||||||
|
:class="{ on: selectedIds.includes(c.id) }"
|
||||||
|
:aria-pressed="selectedIds.includes(c.id)"
|
||||||
|
@click="toggleCar(c.id)"
|
||||||
|
>
|
||||||
|
<span class="tick" :class="{ on: selectedIds.includes(c.id) }" aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>{{ c.name }}</strong>
|
||||||
|
<span class="mono">{{ c.ip }}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="foot">
|
||||||
|
<el-button @click="reset">取消</el-button>
|
||||||
|
<el-button type="primary" :disabled="!canWrite" :loading="pushing" @click="startSync">开始同步</el-button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import type { CheckboxValueType, UploadFile, UploadFiles } from 'element-plus'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { listOtaVehicles, pushOtaCustomFile } from '@/api/ota'
|
||||||
|
import type { OtaVehicleRow } from '@/types/ota'
|
||||||
|
|
||||||
|
defineProps<{ canWrite: boolean }>()
|
||||||
|
const emit = defineEmits<{ jobCreated: [] }>()
|
||||||
|
|
||||||
|
const HIST_KEY = 'migu.ota.custom.remotePathHistory'
|
||||||
|
|
||||||
|
const cars = ref<OtaVehicleRow[]>([])
|
||||||
|
const files = ref<UploadFile[]>([])
|
||||||
|
const remotePath = ref('')
|
||||||
|
const pathHistory = ref<string[]>([])
|
||||||
|
const restartMedulla = ref(false)
|
||||||
|
const restartClumsy = ref(false)
|
||||||
|
const restartDetour = ref(false)
|
||||||
|
const restartWatchDog = ref(false)
|
||||||
|
const selectedIds = ref<string[]>([])
|
||||||
|
const pushing = ref(false)
|
||||||
|
|
||||||
|
const carsWithIp = computed(() => cars.value.filter((c) => !!c.ip))
|
||||||
|
const selectAll = ref(false)
|
||||||
|
const indeterminate = computed(() => {
|
||||||
|
const n = selectedIds.value.length
|
||||||
|
return n > 0 && n < carsWithIp.value.length
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(carsWithIp, (list) => {
|
||||||
|
if (!selectedIds.value.length && list.length) {
|
||||||
|
selectedIds.value = list.map((c) => c.id)
|
||||||
|
selectAll.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function onSelectAll(v: CheckboxValueType) {
|
||||||
|
selectedIds.value = v ? carsWithIp.value.map((c) => c.id) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCar(id: string) {
|
||||||
|
const set = new Set(selectedIds.value)
|
||||||
|
if (set.has(id)) set.delete(id)
|
||||||
|
else set.add(id)
|
||||||
|
selectedIds.value = [...set]
|
||||||
|
selectAll.value = selectedIds.value.length === carsWithIp.value.length
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileChange(_file: UploadFile, fileList: UploadFiles) {
|
||||||
|
// 合并去重
|
||||||
|
const map = new Map<string, UploadFile>()
|
||||||
|
for (const f of [...files.value, ...fileList]) {
|
||||||
|
if (f.raw) map.set(`${f.name}:${f.size}`, f)
|
||||||
|
}
|
||||||
|
files.value = [...map.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFile(i: number) {
|
||||||
|
files.value.splice(i, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFiles() {
|
||||||
|
files.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
clearFiles()
|
||||||
|
restartMedulla.value = false
|
||||||
|
restartClumsy.value = false
|
||||||
|
restartDetour.value = false
|
||||||
|
restartWatchDog.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOps(): number[] {
|
||||||
|
const ops: number[] = []
|
||||||
|
if (restartMedulla.value) ops.push(0)
|
||||||
|
if (restartClumsy.value) ops.push(1)
|
||||||
|
if (restartDetour.value) ops.push(2)
|
||||||
|
if (restartWatchDog.value) ops.push(3)
|
||||||
|
return ops.length ? ops : [-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadHistory() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(HIST_KEY)
|
||||||
|
pathHistory.value = raw ? (JSON.parse(raw) as string[]) : []
|
||||||
|
if (pathHistory.value[0]) remotePath.value = pathHistory.value[0]
|
||||||
|
} catch {
|
||||||
|
pathHistory.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveHistory(path: string) {
|
||||||
|
const next = [path, ...pathHistory.value.filter((p) => p !== path)].slice(0, 20)
|
||||||
|
pathHistory.value = next
|
||||||
|
localStorage.setItem(HIST_KEY, JSON.stringify(next))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startSync() {
|
||||||
|
if (!files.value.length) {
|
||||||
|
ElMessage.warning('请至少添加一个本地文件')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!remotePath.value.trim()) {
|
||||||
|
ElMessage.warning('请填写小车内目标路径')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!selectedIds.value.length) {
|
||||||
|
ElMessage.warning('请至少选择一台车')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const names = files.value.map((f) => f.name)
|
||||||
|
const preview = names.slice(0, 8).join('\n') + (names.length > 8 ? `\n…共 ${names.length} 个` : '')
|
||||||
|
const ops = buildOps()
|
||||||
|
const opLabel = ops[0] === -1 && ops.length === 1
|
||||||
|
? '不重启'
|
||||||
|
: ops.map((o) => ({ 0: 'Medulla', 1: 'Clumsy', 2: 'Detour', 3: 'WatchDog' }[o] || String(o))).join('+')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`文件:\n${preview}\n\n路径:${remotePath.value}\n车辆:${selectedIds.value.length} 台\n重启:${opLabel}`,
|
||||||
|
'确认自定义同步',
|
||||||
|
{ type: 'warning', confirmButtonText: '开始同步', cancelButtonText: '取消' }
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawFiles = files.value.map((f) => f.raw!).filter(Boolean)
|
||||||
|
pushing.value = true
|
||||||
|
try {
|
||||||
|
saveHistory(remotePath.value.trim())
|
||||||
|
await pushOtaCustomFile({
|
||||||
|
carIds: selectedIds.value,
|
||||||
|
remotePath: remotePath.value.trim(),
|
||||||
|
restartOps: ops,
|
||||||
|
files: rawFiles
|
||||||
|
})
|
||||||
|
ElMessage.success('任务已创建,可在任务进度查看')
|
||||||
|
emit('jobCreated')
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || '同步失败')
|
||||||
|
} finally {
|
||||||
|
pushing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loadHistory()
|
||||||
|
cars.value = await listOtaVehicles(false)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ota-custom {
|
||||||
|
color: rgb(var(--mg-text-tint-rgb));
|
||||||
|
}
|
||||||
|
.head h3 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.head p {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(var(--mg-text-hi-rgb), 0.55);
|
||||||
|
}
|
||||||
|
.split {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.35fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.left,
|
||||||
|
.right {
|
||||||
|
border: 1px solid rgba(var(--mg-primary-rgb), 0.12);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.step {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.step h4,
|
||||||
|
.right-head h4 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.right-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.file-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
max-height: 150px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid rgba(var(--mg-primary-rgb), 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(var(--mg-primary-rgb), 0.03);
|
||||||
|
}
|
||||||
|
.file-list li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-bottom: 1px solid rgba(var(--mg-primary-rgb), 0.06);
|
||||||
|
}
|
||||||
|
.file-list li.empty {
|
||||||
|
color: rgba(var(--mg-text-hi-rgb), 0.45);
|
||||||
|
}
|
||||||
|
.x {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
color: rgba(var(--mg-text-hi-rgb), 0.55);
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(var(--mg-text-hi-rgb), 0.5);
|
||||||
|
}
|
||||||
|
.warn {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #c47f00;
|
||||||
|
}
|
||||||
|
.step :deep(.el-checkbox) {
|
||||||
|
display: flex;
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
.w-full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.car-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 380px;
|
||||||
|
overflow: auto;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.car-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(var(--mg-primary-rgb), 0.18);
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.car-card.on {
|
||||||
|
background: rgba(124, 58, 237, 0.08);
|
||||||
|
border-color: #7c3aed;
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
.car-card .tick {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1.5px solid #7c3aed;
|
||||||
|
background: #fff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.car-card .tick.on {
|
||||||
|
background: #7c3aed;
|
||||||
|
border-color: #7c3aed;
|
||||||
|
}
|
||||||
|
.car-card .tick.on::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 5px;
|
||||||
|
top: 2px;
|
||||||
|
width: 5px;
|
||||||
|
height: 9px;
|
||||||
|
border: solid #fff;
|
||||||
|
border-width: 0 2px 2px 0;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
.car-card strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.car-card .mono {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(var(--mg-text-hi-rgb), 0.55);
|
||||||
|
}
|
||||||
|
.mono {
|
||||||
|
font-family: var(--mg-font-mono);
|
||||||
|
}
|
||||||
|
.foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.split {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ota-jobs">
|
||||||
|
<el-alert :title="jobsHint" type="info" :closable="false" show-icon class="jobs-hint" />
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-button size="small" :icon="Refresh" :loading="loading" @click="reload">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" :data="jobs" size="small" height="220" highlight-current-row @current-change="onPick">
|
||||||
|
<el-table-column prop="id" label="任务" min-width="160">
|
||||||
|
<template #default="{ row }"><span class="mono">{{ row.id }}</span></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="kind" label="类型" width="100" />
|
||||||
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" :type="statusType(row.status)">{{ row.status }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="进度" width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-progress
|
||||||
|
:percentage="pct(row)"
|
||||||
|
:status="row.status === 'failed' ? 'exception' : row.status === 'succeeded' ? 'success' : undefined"
|
||||||
|
:stroke-width="10"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="时间" width="160">
|
||||||
|
<template #default="{ row }">{{ formatTs(row.createdAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="140" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
link
|
||||||
|
size="small"
|
||||||
|
:disabled="!canWrite || !['pending', 'probing', 'running'].includes(row.status)"
|
||||||
|
@click="onCancel(row)"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:disabled="!canWrite || !row.steps?.some((s: any) => s.status === 'failed')"
|
||||||
|
@click="onRetry(row)"
|
||||||
|
>
|
||||||
|
重试失败
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="detail">
|
||||||
|
<h4>步骤明细 {{ current?.id || '' }}</h4>
|
||||||
|
<el-empty v-if="!current" description="选择任务查看进度" :image-size="56" />
|
||||||
|
<el-table v-else :data="current.steps" size="small" max-height="240">
|
||||||
|
<el-table-column prop="carId" label="车辆" width="120" />
|
||||||
|
<el-table-column prop="ip" label="IP" width="120" />
|
||||||
|
<el-table-column prop="component" label="组件" width="120" />
|
||||||
|
<el-table-column prop="status" label="状态" width="100" />
|
||||||
|
<el-table-column prop="error" label="错误" min-width="180" show-overflow-tooltip />
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { cancelOtaJob, listOtaJobs, retryOtaJob } from '@/api/ota'
|
||||||
|
import type { OtaJob } from '@/types/ota'
|
||||||
|
import { OTA_COPY } from './otaCopy'
|
||||||
|
|
||||||
|
defineProps<{ canWrite: boolean }>()
|
||||||
|
const jobsHint = OTA_COPY.jobsHint
|
||||||
|
|
||||||
|
const jobs = ref<OtaJob[]>([])
|
||||||
|
const current = ref<OtaJob | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
function pct(row: OtaJob) {
|
||||||
|
if (!row.totalSteps) return 0
|
||||||
|
return Math.min(100, Math.round((row.doneSteps / row.totalSteps) * 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusType(s: string) {
|
||||||
|
if (s === 'succeeded') return 'success'
|
||||||
|
if (s === 'failed') return 'danger'
|
||||||
|
if (s === 'partial') return 'warning'
|
||||||
|
if (s === 'cancelled') return 'info'
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTs(v?: string) {
|
||||||
|
if (!v) return '—'
|
||||||
|
try {
|
||||||
|
return new Date(v).toLocaleString()
|
||||||
|
} catch {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
jobs.value = await listOtaJobs(80)
|
||||||
|
if (current.value) {
|
||||||
|
current.value = jobs.value.find((j) => j.id === current.value!.id) || current.value
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || '加载任务失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPick(row: OtaJob | null) {
|
||||||
|
current.value = row
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCancel(row: OtaJob) {
|
||||||
|
try {
|
||||||
|
await cancelOtaJob(row.id)
|
||||||
|
ElMessage.success('已取消')
|
||||||
|
await reload()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || '取消失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRetry(row: OtaJob) {
|
||||||
|
try {
|
||||||
|
const j = await retryOtaJob(row.id)
|
||||||
|
ElMessage.success(`已创建重试任务 ${j.id}`)
|
||||||
|
await reload()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || '重试失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void reload()
|
||||||
|
timer = setInterval(() => void reload(), 4000)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (timer) clearInterval(timer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.jobs-hint {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.detail {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.detail h4 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.mono {
|
||||||
|
font-family: var(--mg-font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ota-packages">
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-select v-model="pullCarId" size="small" filterable clearable placeholder="从车辆拉取" class="car-select">
|
||||||
|
<el-option v-for="c in cars" :key="c.id" :label="`${c.name} (${c.ip || '无IP'})`" :value="c.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-button size="small" type="primary" :disabled="!canWrite || !pullCarId" :loading="pulling" @click="onPull">
|
||||||
|
拉取
|
||||||
|
</el-button>
|
||||||
|
<el-upload :show-file-list="false" :disabled="!canWrite" :http-request="onUpload" accept=".zip,.exe,.dll">
|
||||||
|
<el-button size="small" :disabled="!canWrite" :loading="uploading">本地上传</el-button>
|
||||||
|
</el-upload>
|
||||||
|
<el-button size="small" :icon="Refresh" :loading="loading" @click="reload" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="split">
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="packages"
|
||||||
|
size="small"
|
||||||
|
height="400"
|
||||||
|
highlight-current-row
|
||||||
|
@current-change="onCurrent"
|
||||||
|
>
|
||||||
|
<el-table-column label="包 ID" min-width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="mono">{{ row.id }}</span>
|
||||||
|
<el-tag v-if="row.isTarget" size="small" type="success" class="tag">目标</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="sourceIp" label="来源" width="110" />
|
||||||
|
<el-table-column label="大小" width="90">
|
||||||
|
<template #default="{ row }">{{ formatBytes(row.totalBytes) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="160" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" size="small" :disabled="!canWrite || row.isTarget" @click="onActivate(row)">
|
||||||
|
设为目标
|
||||||
|
</el-button>
|
||||||
|
<el-button link type="danger" size="small" :disabled="!canWrite || row.isTarget" @click="onDelete(row)">
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="detail">
|
||||||
|
<h4>组件明细</h4>
|
||||||
|
<el-empty v-if="!current" description="选择一个版本包" :image-size="64" />
|
||||||
|
<ul v-else>
|
||||||
|
<li v-for="(art, key) in current.components" :key="key">
|
||||||
|
<span class="key">{{ key }}</span>
|
||||||
|
<span class="mono">{{ art.hash?.slice(0, 12) }}</span>
|
||||||
|
<span class="dim">{{ formatBytes(art.size) }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import type { UploadRequestOptions } from 'element-plus'
|
||||||
|
import {
|
||||||
|
activateOtaPackage,
|
||||||
|
deleteOtaPackage,
|
||||||
|
listOtaPackages,
|
||||||
|
listOtaVehicles,
|
||||||
|
pullOtaPackage,
|
||||||
|
uploadOtaPackage
|
||||||
|
} from '@/api/ota'
|
||||||
|
import type { OtaPackageInfo, OtaVehicleRow } from '@/types/ota'
|
||||||
|
|
||||||
|
defineProps<{ canWrite: boolean }>()
|
||||||
|
const emit = defineEmits<{ changed: [] }>()
|
||||||
|
|
||||||
|
const packages = ref<OtaPackageInfo[]>([])
|
||||||
|
const cars = ref<OtaVehicleRow[]>([])
|
||||||
|
const current = ref<OtaPackageInfo | null>(null)
|
||||||
|
const pullCarId = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const pulling = ref(false)
|
||||||
|
const uploading = ref(false)
|
||||||
|
|
||||||
|
function formatBytes(n: number) {
|
||||||
|
if (!n) return '0 B'
|
||||||
|
if (n < 1024) return `${n} B`
|
||||||
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
||||||
|
return `${(n / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
packages.value = await listOtaPackages()
|
||||||
|
cars.value = await listOtaVehicles(false)
|
||||||
|
if (current.value) {
|
||||||
|
current.value = packages.value.find((p) => p.id === current.value!.id) || null
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || '加载版本库失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCurrent(row: OtaPackageInfo | null) {
|
||||||
|
current.value = row
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPull() {
|
||||||
|
if (!pullCarId.value) return
|
||||||
|
pulling.value = true
|
||||||
|
try {
|
||||||
|
const pkg = await pullOtaPackage(pullCarId.value)
|
||||||
|
ElMessage.success(pkg.components && Object.keys(pkg.components).length ? '拉取完成' : '拉取结束,但未收到文件')
|
||||||
|
emit('changed')
|
||||||
|
await reload()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || '拉取失败')
|
||||||
|
} finally {
|
||||||
|
pulling.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUpload(opt: UploadRequestOptions) {
|
||||||
|
uploading.value = true
|
||||||
|
try {
|
||||||
|
await uploadOtaPackage(opt.file as File)
|
||||||
|
ElMessage.success('上传完成')
|
||||||
|
emit('changed')
|
||||||
|
await reload()
|
||||||
|
opt.onSuccess?.({})
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || '上传失败')
|
||||||
|
opt.onError?.(e)
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onActivate(row: OtaPackageInfo) {
|
||||||
|
try {
|
||||||
|
await activateOtaPackage(row.id)
|
||||||
|
ElMessage.success('已设为目标版本')
|
||||||
|
emit('changed')
|
||||||
|
await reload()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || '激活失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(row: OtaPackageInfo) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`删除版本包 ${row.id}?`, '确认删除', { type: 'warning' })
|
||||||
|
await deleteOtaPackage(row.id)
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
emit('changed')
|
||||||
|
await reload()
|
||||||
|
} catch {
|
||||||
|
/* cancel */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => void reload())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ota-packages {
|
||||||
|
color: rgb(var(--mg-text-tint-rgb));
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.car-select {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
.split {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.4fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.detail {
|
||||||
|
border: 1px solid rgba(var(--mg-primary-rgb), 0.14);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
min-height: 200px;
|
||||||
|
background: rgba(var(--mg-primary-rgb), 0.03);
|
||||||
|
}
|
||||||
|
.detail h4 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgb(var(--mg-text-tint-rgb));
|
||||||
|
}
|
||||||
|
.detail ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.detail li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 70px 1fr auto;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.key {
|
||||||
|
color: var(--mg-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.mono {
|
||||||
|
font-family: var(--mg-font-mono);
|
||||||
|
}
|
||||||
|
.dim {
|
||||||
|
color: rgba(var(--mg-text-hi-rgb), 0.55);
|
||||||
|
}
|
||||||
|
.tag {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.split {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<template>
|
||||||
|
<div v-loading="loading" class="ota-settings">
|
||||||
|
<el-alert type="info" :closable="false" show-icon class="receive-alert" :title="receiveHint" />
|
||||||
|
<el-form v-if="form" label-width="140px" size="small" class="form">
|
||||||
|
<h4>传输</h4>
|
||||||
|
<el-form-item label="带宽限制 (kb/s)">
|
||||||
|
<el-input-number v-model="form.bandwidthKbps" :min="0" :max="100000" />
|
||||||
|
<span class="hint">0 = 不限速</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="并发车辆数">
|
||||||
|
<el-input-number v-model="form.maxCar" :min="1" :max="50" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<h4>网络延迟检测</h4>
|
||||||
|
<el-form-item label="默认开启">
|
||||||
|
<el-switch v-model="form.latencyEnabled" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="RTT 阈值 (ms)">
|
||||||
|
<el-input-number v-model="form.rttThresholdMs" :min="10" :max="10000" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="超限策略">
|
||||||
|
<el-radio-group v-model="form.overThreshold">
|
||||||
|
<el-radio value="skip">跳过该车</el-radio>
|
||||||
|
<el-radio value="confirm">仍允许(需确认)</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<h4>备份</h4>
|
||||||
|
<el-form-item label="周期 (分钟)">
|
||||||
|
<el-input-number v-model="form.backupPeriodMinutes" :min="0" :max="10080" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备份可执行文件">
|
||||||
|
<el-switch v-model="form.backupExe" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<h4>展示</h4>
|
||||||
|
<el-form-item label="目标版本名称">
|
||||||
|
<el-input v-model="form.newVersionName" placeholder="可选显示名" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :disabled="!canWrite" :loading="saving" @click="onSave">保存设置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { getOtaSettings, putOtaSettings } from '@/api/ota'
|
||||||
|
import type { OtaSettings } from '@/types/ota'
|
||||||
|
import { OTA_COPY } from './otaCopy'
|
||||||
|
|
||||||
|
defineProps<{ canWrite: boolean }>()
|
||||||
|
const emit = defineEmits<{ saved: [] }>()
|
||||||
|
|
||||||
|
const receiveHint = OTA_COPY.receiveHint
|
||||||
|
const form = ref<OtaSettings | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
form.value = await getOtaSettings()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || '加载设置失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
if (!form.value) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
form.value = await putOtaSettings(form.value)
|
||||||
|
ElMessage.success('已保存')
|
||||||
|
emit('saved')
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || '保存失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => void load())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ota-settings {
|
||||||
|
color: rgb(var(--mg-text-tint-rgb));
|
||||||
|
}
|
||||||
|
.receive-alert {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
.form {
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
h4 {
|
||||||
|
margin: 16px 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--mg-primary);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
.form h4:first-of-type {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
margin-left: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(var(--mg-text-hi-rgb), 0.55);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ota-vehicles">
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-input v-model="search" size="small" clearable :placeholder="t.searchPh" class="search" />
|
||||||
|
<el-checkbox v-model="onlyMismatch">{{ t.onlyMismatch }}</el-checkbox>
|
||||||
|
<div class="latency-switch">
|
||||||
|
<span>{{ t.latency }}</span>
|
||||||
|
<el-switch v-model="latencyOn" @change="onLatencyToggle" />
|
||||||
|
</div>
|
||||||
|
<el-button size="small" plain :icon="Refresh" :loading="loading" @click="reload">{{ t.refresh }}</el-button>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
type="success"
|
||||||
|
plain
|
||||||
|
:disabled="!canWrite || selectedRows.length !== 1"
|
||||||
|
:loading="pulling"
|
||||||
|
@click="pullSelected"
|
||||||
|
>
|
||||||
|
{{ t.pullFromCar }}
|
||||||
|
</el-button>
|
||||||
|
<el-dropdown :disabled="!canWrite || !selectedRows.length" @command="onSyncCommand">
|
||||||
|
<el-button size="small" type="primary" :disabled="!canWrite || !selectedRows.length">
|
||||||
|
{{ t.sync }}
|
||||||
|
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item command="all">{{ t.syncAll }}</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="M.exe">{{ t.syncM }}</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="D.exe">{{ t.syncD }}</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="C.exe">{{ t.syncC }}</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="hint-line">{{ t.vehiclesHint }}</p>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
ref="tableRef"
|
||||||
|
v-loading="loading"
|
||||||
|
class="ota-table"
|
||||||
|
:data="filtered"
|
||||||
|
size="small"
|
||||||
|
height="420"
|
||||||
|
row-key="id"
|
||||||
|
stripe
|
||||||
|
@selection-change="onSelect"
|
||||||
|
>
|
||||||
|
<el-table-column type="selection" width="48" />
|
||||||
|
<el-table-column :label="t.vehicle" min-width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="car-cell">
|
||||||
|
<strong>{{ row.name }}</strong>
|
||||||
|
<span class="mono dim">{{ row.id }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="ip" label="IP" width="120">
|
||||||
|
<template #default="{ row }"><span class="mono">{{ row.ip || '-' }}</span></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="Medulla" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="match" :class="matchState(row, 'M.exe')" :title="row.medulla?.exe?.version || ''">
|
||||||
|
<i /><span>{{ shortHash(row.medulla?.exe?.version) }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="Detour" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="match" :class="matchState(row, 'D.exe')" :title="row.detour?.exe?.version || ''">
|
||||||
|
<i /><span>{{ shortHash(row.detour?.exe?.version) }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="Clumsy" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="match" :class="matchState(row, 'C.exe')" :title="row.clumsy?.exe?.version || ''">
|
||||||
|
<i /><span>{{ shortHash(row.clumsy?.exe?.version) }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="RTT" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :class="rttClass(row)">{{ rttLabel(row) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="state" :label="t.state" width="90" />
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div v-if="selectedRows.length" class="action-bar">
|
||||||
|
<span>{{ t.selectedPrefix }} <b>{{ selectedRows.length }}</b> {{ t.selectedSuffix }}</span>
|
||||||
|
<span class="dim">{{ t.batchPrefix }} {{ Math.ceil(selectedRows.length / Math.max(1, settings?.maxCar ?? 2)) }}</span>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
type="success"
|
||||||
|
plain
|
||||||
|
:disabled="!canWrite || selectedRows.length !== 1"
|
||||||
|
:loading="pulling"
|
||||||
|
@click="pullSelected"
|
||||||
|
>
|
||||||
|
{{ t.pullFromCar }}
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" size="small" :disabled="!canWrite" @click="confirmSync()">{{ t.startDeploy }}</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { ArrowDown, Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
createOtaSyncJob,
|
||||||
|
getOtaSettings,
|
||||||
|
listOtaVehicles,
|
||||||
|
pullOtaPackage,
|
||||||
|
putOtaSettings
|
||||||
|
} from '@/api/ota'
|
||||||
|
import type { OtaSettings, OtaVehicleRow } from '@/types/ota'
|
||||||
|
import { useOtaWorkbench } from '@/composables/useOtaWorkbench'
|
||||||
|
import { OTA_COPY } from './otaCopy'
|
||||||
|
|
||||||
|
defineProps<{ canWrite: boolean }>()
|
||||||
|
const emit = defineEmits<{ jobCreated: []; pulled: [] }>()
|
||||||
|
|
||||||
|
const t = OTA_COPY
|
||||||
|
const { refreshMeta } = useOtaWorkbench()
|
||||||
|
const rows = ref<OtaVehicleRow[]>([])
|
||||||
|
const selectedRows = ref<OtaVehicleRow[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const pulling = ref(false)
|
||||||
|
const search = ref('')
|
||||||
|
const onlyMismatch = ref(false)
|
||||||
|
const latencyOn = ref(false)
|
||||||
|
const settings = ref<OtaSettings | null>(null)
|
||||||
|
const pendingComponents = ref<string[] | undefined>(undefined)
|
||||||
|
|
||||||
|
function shortHash(h?: string) {
|
||||||
|
if (!h) return '-'
|
||||||
|
return h.length > 8 ? h.slice(0, 8) : h
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchState(row: OtaVehicleRow, key: string) {
|
||||||
|
if (!row.reachable) return 'offline'
|
||||||
|
return row.match?.[key] || 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = computed(() => {
|
||||||
|
const q = search.value.trim().toLowerCase()
|
||||||
|
return rows.value.filter((r) => {
|
||||||
|
if (onlyMismatch.value) {
|
||||||
|
const bad = Object.values(r.match || {}).some((v) => v === 'mismatch')
|
||||||
|
if (!bad) return false
|
||||||
|
}
|
||||||
|
if (!q) return true
|
||||||
|
return [r.id, r.name, r.ip].some((x) => (x || '').toLowerCase().includes(q))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function rttLabel(row: OtaVehicleRow) {
|
||||||
|
if (!latencyOn.value) return '-'
|
||||||
|
if (row.rttMs == null) return 'timeout'
|
||||||
|
return `${row.rttMs}ms`
|
||||||
|
}
|
||||||
|
|
||||||
|
function rttClass(row: OtaVehicleRow) {
|
||||||
|
if (!latencyOn.value || row.rttMs == null) return 'mono dim'
|
||||||
|
const th = settings.value?.rttThresholdMs ?? 200
|
||||||
|
return row.rttMs > th ? 'mono danger' : 'mono ok'
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelect(list: OtaVehicleRow[]) {
|
||||||
|
selectedRows.value = list
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
settings.value = await getOtaSettings()
|
||||||
|
latencyOn.value = !!settings.value.latencyEnabled
|
||||||
|
rows.value = await listOtaVehicles(latencyOn.value)
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || t.loadFail)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onLatencyToggle(v: string | number | boolean) {
|
||||||
|
const on = !!v
|
||||||
|
try {
|
||||||
|
if (!settings.value) settings.value = await getOtaSettings()
|
||||||
|
settings.value = await putOtaSettings({ ...settings.value, latencyEnabled: on })
|
||||||
|
await reload()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || t.latencySaveFail)
|
||||||
|
latencyOn.value = !on
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pullSelected() {
|
||||||
|
if (selectedRows.value.length !== 1) {
|
||||||
|
ElMessage.warning(t.pullNeedOne)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const car = selectedRows.value[0]
|
||||||
|
if (!car.ip) {
|
||||||
|
ElMessage.warning(t.pullNeedIp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pulling.value = true
|
||||||
|
try {
|
||||||
|
const pkg = await pullOtaPackage(car.id)
|
||||||
|
const n = Object.keys(pkg.components || {}).length
|
||||||
|
if (!n) {
|
||||||
|
ElMessage.warning(t.pullEmpty)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessage.success(t.pullOk)
|
||||||
|
emit('pulled')
|
||||||
|
void refreshMeta()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || t.pullFail)
|
||||||
|
} finally {
|
||||||
|
pulling.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSyncCommand(cmd: string) {
|
||||||
|
pendingComponents.value = cmd === 'all' ? undefined : [cmd]
|
||||||
|
void confirmSync(pendingComponents.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmSync(components?: string[]) {
|
||||||
|
if (!selectedRows.value.length) return
|
||||||
|
const comps = components === undefined ? pendingComponents.value : components
|
||||||
|
const compsLabel = comps?.length ? comps.join(', ') : t.compsAll
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
t.confirmBody(selectedRows.value.length, compsLabel),
|
||||||
|
t.confirmTitle,
|
||||||
|
{ type: 'warning', confirmButtonText: t.confirmOk, cancelButtonText: t.confirmCancel }
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let carIds = selectedRows.value.map((c) => c.id)
|
||||||
|
const th = settings.value?.rttThresholdMs ?? 200
|
||||||
|
if (latencyOn.value && settings.value?.overThreshold === 'skip') {
|
||||||
|
const skipped = selectedRows.value.filter((c) => c.rttMs == null || c.rttMs > th)
|
||||||
|
if (skipped.length) {
|
||||||
|
ElMessage.warning(t.skipLatency(skipped.length))
|
||||||
|
carIds = selectedRows.value.filter((c) => c.rttMs != null && c.rttMs <= th).map((c) => c.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!carIds.length) {
|
||||||
|
ElMessage.error(t.noCars)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createOtaSyncJob({
|
||||||
|
carIds,
|
||||||
|
components: comps,
|
||||||
|
requireLatencyCheck: latencyOn.value
|
||||||
|
})
|
||||||
|
ElMessage.success(t.jobCreated)
|
||||||
|
emit('jobCreated')
|
||||||
|
void refreshMeta()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || t.jobFail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => void reload())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ota-vehicles {
|
||||||
|
--ota-fg: rgb(var(--mg-text-tint-rgb));
|
||||||
|
--ota-fg-muted: rgba(var(--mg-text-hi-rgb), 0.55);
|
||||||
|
--ota-line: rgba(var(--mg-primary-rgb), 0.12);
|
||||||
|
color: var(--ota-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-line {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ota-fg-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.latency-switch {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ota-fg);
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(var(--mg-primary-rgb), 0.08);
|
||||||
|
border: 1px solid rgba(var(--mg-primary-rgb), 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.car-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.car-cell strong {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ota-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: var(--mg-font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dim {
|
||||||
|
color: var(--ota-fg-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ok {
|
||||||
|
color: var(--mg-status-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
color: var(--mg-status-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-bar {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(var(--mg-bg-card-rgb), 0.98);
|
||||||
|
border: 1px solid rgba(var(--mg-primary-rgb), 0.22);
|
||||||
|
box-shadow: 0 8px 20px rgba(var(--mg-primary-rgb), 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: var(--mg-font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ota-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match i {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--mg-status-idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match.match i {
|
||||||
|
background: var(--mg-status-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match.mismatch i {
|
||||||
|
background: var(--mg-status-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match.offline i,
|
||||||
|
.match.unknown i {
|
||||||
|
background: var(--mg-status-idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ota-table) {
|
||||||
|
--el-table-bg-color: #fff;
|
||||||
|
--el-table-tr-bg-color: #fff;
|
||||||
|
--el-table-header-bg-color: rgba(var(--mg-primary-rgb), 0.06);
|
||||||
|
--el-table-row-hover-bg-color: rgba(var(--mg-primary-rgb), 0.06);
|
||||||
|
--el-table-text-color: rgb(var(--mg-text-tint-rgb));
|
||||||
|
--el-table-header-text-color: rgba(var(--mg-text-hi-rgb), 0.9);
|
||||||
|
--el-table-border-color: rgba(var(--mg-primary-rgb), 0.12);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--ota-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ota-table .el-checkbox__inner) {
|
||||||
|
width: 16px !important;
|
||||||
|
height: 16px !important;
|
||||||
|
border: 1.5px solid #7c3aed !important;
|
||||||
|
background: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ota-table .el-checkbox__input.is-checked .el-checkbox__inner),
|
||||||
|
:deep(.ota-table .el-checkbox__input.is-indeterminate .el-checkbox__inner) {
|
||||||
|
background: #7c3aed !important;
|
||||||
|
border-color: #7c3aed !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ota-table .el-checkbox__input.is-checked .el-checkbox__inner::after) {
|
||||||
|
border-color: #fff !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ota-workbench">
|
||||||
|
<aside class="ota-nav" aria-label="OTA sections">
|
||||||
|
<button
|
||||||
|
v-for="item in nav"
|
||||||
|
:key="item.key"
|
||||||
|
type="button"
|
||||||
|
class="ota-nav__item"
|
||||||
|
:class="{ active: pane === item.key }"
|
||||||
|
@click="pane = item.key"
|
||||||
|
>
|
||||||
|
<span class="ota-nav__label">{{ item.label }}</span>
|
||||||
|
<span v-if="item.key === 'jobs' && activeJobCount" class="ota-nav__badge">{{ activeJobCount }}</span>
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="ota-main">
|
||||||
|
<header class="ota-top">
|
||||||
|
<div class="ota-top__target">
|
||||||
|
<span class="ota-top__kicker">TARGET</span>
|
||||||
|
<strong class="ota-top__name">{{ targetLabel }}</strong>
|
||||||
|
<div v-if="target" class="ota-top__hashes">
|
||||||
|
<span v-for="(h, k) in displaySummary" :key="k" class="hash-chip">
|
||||||
|
<em>{{ k }}</em>{{ h }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span v-else class="ota-top__hint">{{ hintUnset }}</span>
|
||||||
|
</div>
|
||||||
|
<el-button size="small" plain :icon="Refresh" :loading="loadingMeta" @click="refreshMeta">
|
||||||
|
{{ labelRefresh }}
|
||||||
|
</el-button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="ota-body">
|
||||||
|
<OtaVehiclesPane
|
||||||
|
v-if="pane === 'vehicles'"
|
||||||
|
:can-write="canWrite"
|
||||||
|
@job-created="onJobCreated"
|
||||||
|
@pulled="onPulled"
|
||||||
|
/>
|
||||||
|
<OtaPackagesPane v-else-if="pane === 'packages'" :can-write="canWrite" @changed="refreshMeta" />
|
||||||
|
<OtaJobsPane v-else-if="pane === 'jobs'" :can-write="canWrite" />
|
||||||
|
<OtaConfigPane v-else-if="pane === 'config'" :can-write="canWrite" @job-created="onJobCreated" />
|
||||||
|
<OtaCustomFilePane v-else-if="pane === 'custom'" :can-write="canWrite" @job-created="onJobCreated" />
|
||||||
|
<OtaSettingsPane v-else-if="pane === 'settings'" :can-write="canWrite" @saved="refreshMeta" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { useOtaWorkbench } from '@/composables/useOtaWorkbench'
|
||||||
|
import type { OtaPane } from '@/types/ota'
|
||||||
|
import { OTA_COPY } from './otaCopy'
|
||||||
|
import OtaVehiclesPane from './OtaVehiclesPane.vue'
|
||||||
|
import OtaPackagesPane from './OtaPackagesPane.vue'
|
||||||
|
import OtaJobsPane from './OtaJobsPane.vue'
|
||||||
|
import OtaConfigPane from './OtaConfigPane.vue'
|
||||||
|
import OtaCustomFilePane from './OtaCustomFilePane.vue'
|
||||||
|
import OtaSettingsPane from './OtaSettingsPane.vue'
|
||||||
|
|
||||||
|
defineProps<{ canWrite: boolean }>()
|
||||||
|
|
||||||
|
const pane = ref<OtaPane>('vehicles')
|
||||||
|
const {
|
||||||
|
target,
|
||||||
|
targetSummary,
|
||||||
|
targetLabel,
|
||||||
|
activeJobCount,
|
||||||
|
loadingMeta,
|
||||||
|
refreshMeta,
|
||||||
|
startPolling,
|
||||||
|
stopPolling
|
||||||
|
} = useOtaWorkbench()
|
||||||
|
|
||||||
|
onMounted(startPolling)
|
||||||
|
onUnmounted(stopPolling)
|
||||||
|
|
||||||
|
const labelRefresh = OTA_COPY.refresh
|
||||||
|
const hintUnset = OTA_COPY.activateHint
|
||||||
|
|
||||||
|
const nav: { key: OtaPane; label: string }[] = [
|
||||||
|
{ key: 'vehicles', label: OTA_COPY.navVehicles },
|
||||||
|
{ key: 'packages', label: OTA_COPY.navPackages },
|
||||||
|
{ key: 'jobs', label: OTA_COPY.navJobs },
|
||||||
|
{ key: 'config', label: OTA_COPY.navConfig },
|
||||||
|
{ key: 'custom', label: OTA_COPY.navCustom },
|
||||||
|
{ key: 'settings', label: OTA_COPY.navSettings }
|
||||||
|
]
|
||||||
|
|
||||||
|
const displaySummary = computed(() => {
|
||||||
|
const s = targetSummary.value
|
||||||
|
const keys = ['M.exe', 'D.exe', 'C.exe']
|
||||||
|
const out: Record<string, string> = {}
|
||||||
|
for (const k of keys) {
|
||||||
|
if (s[k]) out[k.replace('.exe', '')] = s[k]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
function onJobCreated() {
|
||||||
|
pane.value = 'jobs'
|
||||||
|
void refreshMeta()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPulled() {
|
||||||
|
pane.value = 'packages'
|
||||||
|
void refreshMeta()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ota-workbench {
|
||||||
|
--ota-fg: rgb(var(--mg-text-tint-rgb));
|
||||||
|
--ota-fg-soft: rgba(var(--mg-text-hi-rgb), 0.78);
|
||||||
|
--ota-fg-muted: rgba(var(--mg-text-hi-rgb), 0.55);
|
||||||
|
--ota-line: rgba(var(--mg-primary-rgb), 0.14);
|
||||||
|
--ota-surface: rgba(var(--mg-bg-card-rgb), 0.96);
|
||||||
|
--ota-rail: rgba(var(--mg-primary-rgb), 0.04);
|
||||||
|
--ota-rail-active: rgba(var(--mg-primary-rgb), 0.12);
|
||||||
|
--ota-chip: rgba(var(--mg-primary-rgb), 0.1);
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 152px minmax(0, 1fr);
|
||||||
|
min-height: min(72vh, 820px);
|
||||||
|
border: 1px solid var(--ota-line);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--ota-surface);
|
||||||
|
box-shadow: 0 10px 28px rgba(var(--mg-primary-rgb), 0.06);
|
||||||
|
color: var(--ota-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ota-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px 10px;
|
||||||
|
background: var(--ota-rail);
|
||||||
|
border-right: 1px solid var(--ota-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ota-nav__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ota-fg-soft);
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ota-nav__item:hover {
|
||||||
|
background: rgba(var(--mg-primary-rgb), 0.08);
|
||||||
|
color: var(--mg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ota-nav__item.active {
|
||||||
|
background: var(--ota-rail-active);
|
||||||
|
color: var(--mg-primary);
|
||||||
|
box-shadow: inset 3px 0 0 var(--mg-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ota-nav__badge {
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: var(--mg-status-warning);
|
||||||
|
color: #1f1408;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--mg-font-mono);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ota-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ota-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 18px 14px;
|
||||||
|
border-bottom: 1px solid var(--ota-line);
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(var(--mg-primary-rgb), 0.04) 0%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ota-top__kicker {
|
||||||
|
display: block;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ota-fg-muted);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ota-top__name {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--ota-fg);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ota-top__hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mg-status-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ota-top__hashes {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hash-chip {
|
||||||
|
font-family: var(--mg-font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--ota-chip);
|
||||||
|
color: var(--mg-primary);
|
||||||
|
border: 1px solid rgba(var(--mg-primary-rgb), 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hash-chip em {
|
||||||
|
font-style: normal;
|
||||||
|
margin-right: 6px;
|
||||||
|
opacity: 0.65;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ota-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 14px 18px 18px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- 非 scoped:保证表格勾选 / 卡片勾选在浅紫主题下可见(覆盖 theme 白底 !important) -->
|
||||||
|
<style>
|
||||||
|
.ota-workbench .el-checkbox__inner {
|
||||||
|
width: 16px !important;
|
||||||
|
height: 16px !important;
|
||||||
|
background: #fff !important;
|
||||||
|
border: 1.5px solid #7c3aed !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
}
|
||||||
|
.ota-workbench .el-checkbox__inner::after {
|
||||||
|
border-width: 0 0 2px 2px !important;
|
||||||
|
height: 7px !important;
|
||||||
|
left: 4px !important;
|
||||||
|
top: 1px !important;
|
||||||
|
width: 4px !important;
|
||||||
|
}
|
||||||
|
.ota-workbench .el-checkbox__input.is-checked .el-checkbox__inner,
|
||||||
|
.ota-workbench .el-checkbox__input.is-indeterminate .el-checkbox__inner {
|
||||||
|
background: #7c3aed !important;
|
||||||
|
border-color: #7c3aed !important;
|
||||||
|
}
|
||||||
|
.ota-workbench .el-checkbox__input.is-checked .el-checkbox__inner::after {
|
||||||
|
border-color: #fff !important;
|
||||||
|
}
|
||||||
|
.ota-workbench .el-checkbox__input.is-indeterminate .el-checkbox__inner::before {
|
||||||
|
background-color: #fff !important;
|
||||||
|
height: 2px !important;
|
||||||
|
}
|
||||||
|
.ota-workbench .el-table .el-checkbox {
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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<string, unknown> {
|
||||||
|
const raw = path.replace(/^root\.?/, '')
|
||||||
|
if (!raw) {
|
||||||
|
if (data != null && typeof data === 'object' && !Array.isArray(data)) {
|
||||||
|
return { ...(data as Record<string, unknown>) }
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
const keys = raw.split(/\.|\[|\]/).filter(Boolean)
|
||||||
|
if (!keys.length) return {}
|
||||||
|
let result = data
|
||||||
|
const currentObject: Record<string, unknown> = {}
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user