新增车辆运维 OTA 工作台前端。

支持包管理、车辆同步、任务与自定义文件下发,并按 ops.ota* 对齐可写权限。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zhaowei.huang
2026-07-26 11:31:52 +08:00
co-authored by Cursor
parent 4223a572c5
commit 7056cf0872
14 changed files with 2567 additions and 19 deletions
@@ -2,5 +2,7 @@
# - VITE_USE_MOCK=false:强制走 Platform.Server 真实 API,不再被 const MOCK=true 锁死。
# - VITE_API_BASE 与 dev 保持一致,由 Platform.Server 同源托管。
VITE_API_BASE=/api
VITE_VRENDER_HOST=localhost:8223
# 留空:走 defaultVrHost() → window.location.hostname:8223
# 切勿写死 localhost,远程浏览器会去连访问者本机而非服务器。
# VITE_VRENDER_HOST=
VITE_USE_MOCK=false
@@ -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" />
<p class="footnote">故障率 = 报警占用时长 ÷ 自上线以来运行时长SimpleLite 进程内累计</p>
<p class="footnote">
延迟 = 本机到车辆 WatchDog(:9776) TCP 往返故障率 = 报警占用时长 ÷ 自上线以来运行时长SimpleLite 进程内累计
</p>
</div>
</el-tab-pane>
<el-tab-pane name="maintenance" label="维护策略" lazy>
<div class="config-pane">
<VehicleMaintenanceView />
</div>
</el-tab-pane>
<el-tab-pane name="fleet" label="车队生命周期" lazy>
<div class="config-pane">
<FleetLifecycleView />
<el-tab-pane name="ota" label="OTA" lazy>
<div class="ota-pane">
<OtaWorkbenchView :can-write="canWrite" />
</div>
</el-tab-pane>
</el-tabs>
@@ -141,8 +137,7 @@ import { Search, Refresh, ArrowDown } from '@element-plus/icons-vue'
import VehicleHealthCard from '@/components/fleet/VehicleHealthCard.vue'
import VehicleHealthRow from '@/components/fleet/VehicleHealthRow.vue'
import FleetAllocationPanel from '@/components/fleet/FleetAllocationPanel.vue'
import VehicleMaintenanceView from '@/views/admin/config/VehicleMaintenanceView.vue'
import FleetLifecycleView from '@/views/admin/config/FleetLifecycleView.vue'
import OtaWorkbenchView from '@/views/shared/ota/OtaWorkbenchView.vue'
import { useVehicleHub } from '@/composables/useVehicleHub'
import { useFleetGroups } from '@/composables/useFleetGroups'
import { setVehicleMaintenance, type VehicleMaintenanceMode } from '@/api/vehicleOps'
@@ -150,21 +145,31 @@ import type { CarState, VehicleCardModel } from '@/types/car'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const canWrite = computed(() => auth.scope === 'Platform' || (auth.effectivePermissions?.allowedOps ?? []).includes('*'))
const canWrite = computed(() => {
if (auth.scope === 'Platform') return true
const ops = auth.effectivePermissions?.allowedOps ?? []
return ops.includes('*') || ops.some((o) => o === 'ops.ota' || o.startsWith('ops.ota.'))
})
// Tab 与 URL ?tab= 同步,支持深链接(旧 /config/vehicle、/config/fleet 已下线,统一进车辆运维)。
const route = useRoute()
const router = useRouter()
const TAB_NAMES = ['overview', 'maintenance', 'fleet'] as const
const TAB_NAMES = ['overview', 'ota'] as const
type TabName = (typeof TAB_NAMES)[number]
const LEGACY_TABS = new Set(['maintenance', 'fleet'])
function readTab(): TabName {
const q = route.query.tab
if (typeof q === 'string' && LEGACY_TABS.has(q)) return 'ota'
return typeof q === 'string' && (TAB_NAMES as readonly string[]).includes(q) ? (q as TabName) : 'overview'
}
const activeTab = ref<TabName>(readTab())
watch(activeTab, (t) => {
watch(
activeTab,
(t) => {
if (route.query.tab !== t) router.replace({ query: { ...route.query, tab: t } })
})
},
{ immediate: true }
)
watch(() => route.query.tab, () => {
const next = readTab()
if (next !== activeTab.value) activeTab.value = next
@@ -326,7 +331,8 @@ async function onBatchCommand(cmd: string) {
overflow: hidden;
}
.config-pane {
.config-pane,
.ota-pane {
height: 100%;
overflow: auto;
}
@@ -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