Changes Initial commit

This commit is contained in:
2026-04-23 15:05:23 +08:00
parent de2dcc414d
commit c5013f3f68
21 changed files with 5716 additions and 0 deletions

90
auto-imports.d.ts vendored Normal file
View File

@@ -0,0 +1,90 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const ElMessage: typeof import('element-plus/es')['ElMessage']
const ElMessageBox: typeof import('element-plus/es')['ElMessageBox']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const createPinia: typeof import('pinia')['createPinia']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineStore: typeof import('pinia')['defineStore']
const effectScope: typeof import('vue')['effectScope']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
const mapStores: typeof import('pinia')['mapStores']
const mapWritableState: typeof import('pinia')['mapWritableState']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useId: typeof import('vue')['useId']
const useLink: typeof import('vue-router')['useLink']
const useModel: typeof import('vue')['useModel']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

44
components.d.ts vendored Normal file
View File

@@ -0,0 +1,44 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ElAlert: typeof import('element-plus/es')['ElAlert']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTag: typeof import('element-plus/es')['ElTag']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
export interface ComponentCustomProperties {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}

2
env.example Normal file
View File

@@ -0,0 +1,2 @@
VITE_API_BASE_URL=http://127.0.0.1:8080
VITE_OTA_TOKEN=dev-token

15
index.html Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OTA Server UI</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+SC:wght@400;500;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
<script type="module" src="/src/main.js"></script>
</head>
<body>
<div id="app"></div>
</body>
</html>

2556
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "ota-server-ui",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.9.0",
"element-plus": "^2.9.8",
"pinia": "^2.1.7",
"vue": "^3.5.13",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.4",
"unplugin-auto-import": "^0.18.6",
"unplugin-vue-components": "^0.27.5",
"vite": "^5.4.19"
}
}

3
src/App.vue Normal file
View File

@@ -0,0 +1,3 @@
<template>
<router-view />
</template>

123
src/layouts/MainLayout.vue Normal file
View File

@@ -0,0 +1,123 @@
<template>
<div class="shell-layout">
<aside class="side-panel">
<div class="brand-block">
<span class="brand-tag">NOBLELIFT</span>
<h1>OTA Server UI</h1>
<p>版本发布车辆管理Agent 联调一体化控制面板</p>
</div>
<el-menu :default-active="route.path" class="side-menu" router>
<el-menu-item index="/">
<el-icon><HomeFilled /></el-icon>
<span>总览</span>
</el-menu-item>
<el-menu-item index="/releases">
<el-icon><Promotion /></el-icon>
<span>版本详情</span>
</el-menu-item>
<el-menu-item index="/vehicles">
<el-icon><Van /></el-icon>
<span>车辆详情</span>
</el-menu-item>
<el-menu-item index="/tasks">
<el-icon><Operation /></el-icon>
<span>任务详情</span>
</el-menu-item>
<el-menu-item v-if="canAccessAgent" index="/agent-debug">
<el-icon><Connection /></el-icon>
<span>Agent 联调</span>
</el-menu-item>
<el-menu-item index="/settings">
<el-icon><Setting /></el-icon>
<span>系统配置</span>
</el-menu-item>
</el-menu>
</aside>
<section class="content-panel">
<header class="topbar">
<div>
<h2>{{ title }}</h2>
<p>{{ subtitle }}</p>
</div>
<div class="topbar-actions">
<el-dropdown placement="bottom-end" popper-class="user-dropdown-popper">
<div class="user-dropdown-trigger">
<div class="user-avatar">{{ userInitial }}</div>
<div class="user-meta">
<strong>{{ nickname }}</strong>
<span>{{ rolesText }}</span>
</div>
<el-icon class="user-dropdown-arrow"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu class="user-dropdown-menu">
<div class="user-dropdown-panel">
<div class="user-dropdown-name">{{ nickname }}</div>
<div class="user-dropdown-role">{{ rolesText }}</div>
<div class="user-dropdown-api">{{ apiBaseUrl }}</div>
</div>
<div class="user-dropdown-logout" @click="logoutAction">
<div class="user-dropdown-logout-text">退出登录</div>
</div>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
<div class="router-page">
<router-view />
</div>
</section>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowDown, Connection, HomeFilled, Operation, Promotion, Setting, Van } from '@element-plus/icons-vue'
import { logout } from '../services/api'
const route = useRoute()
const router = useRouter()
const titleMap = {
'/': '控制台总览',
'/releases': '版本详情',
'/vehicles': '车辆详情',
'/tasks': '任务详情',
'/agent-debug': 'Agent 联调',
'/settings': '系统配置'
}
const subtitleMap = {
'/': '统一查看 OTA 发布、车辆注册与升级任务状态',
'/releases': '查看发布记录、组件镜像与更新说明',
'/vehicles': '查看车辆版本、在线状态与运行信息',
'/tasks': '查看待确认、升级中、成功失败等任务状态',
'/agent-debug': '直接从前端模拟 Agent 调用心跳、检查更新、确认与上报',
'/settings': '配置后端地址、Token 与界面首选项'
}
const title = computed(() => titleMap[route.path] ?? 'OTA Server UI')
const subtitle = computed(() => subtitleMap[route.path] ?? 'OTA 管理后台')
const apiBaseUrl = computed(() => localStorage.getItem('ota-ui-api-base-url') || import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:8080')
const nickname = computed(() => localStorage.getItem('ota-ui-nickname') || localStorage.getItem('ota-ui-username') || '未登录')
const roles = computed(() => JSON.parse(localStorage.getItem('ota-ui-roles') || '[]'))
const rolesText = computed(() => roles.value.join(', ') || 'NO_ROLE')
const userInitial = computed(() => nickname.value.slice(0, 1).toUpperCase())
const canAccessAgent = computed(() => roles.value.some((role) => ['SUPER_ADMIN', 'ADMIN', 'AGENT'].includes(role)))
async function logoutAction() {
try {
await logout()
} catch (error) {
}
localStorage.removeItem('ota-ui-token')
localStorage.removeItem('ota-ui-roles')
localStorage.removeItem('ota-ui-username')
localStorage.removeItem('ota-ui-nickname')
router.push('/login')
ElMessage.success('已退出登录')
}
</script>

11
src/main.js Normal file
View File

@@ -0,0 +1,11 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './styles.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

76
src/router/index.js Normal file
View File

@@ -0,0 +1,76 @@
import { createRouter, createWebHistory } from 'vue-router'
const MainLayout = () => import('../layouts/MainLayout.vue')
const DashboardView = () => import('../views/DashboardView.vue')
const LoginView = () => import('../views/LoginView.vue')
const SettingsView = () => import('../views/SettingsView.vue')
const ReleasesView = () => import('../views/ReleasesView.vue')
const VehiclesView = () => import('../views/VehiclesView.vue')
const TasksView = () => import('../views/TasksView.vue')
const AgentDebugView = () => import('../views/AgentDebugView.vue')
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'login',
component: LoginView
},
{
path: '/',
component: MainLayout,
children: [
{
path: '',
name: 'dashboard',
component: DashboardView
},
{
path: 'releases',
name: 'releases',
component: ReleasesView
},
{
path: 'vehicles',
name: 'vehicles',
component: VehiclesView
},
{
path: 'tasks',
name: 'tasks',
component: TasksView
},
{
path: 'settings',
name: 'settings',
component: SettingsView
},
{
path: 'agent-debug',
name: 'agent-debug',
component: AgentDebugView,
meta: { roles: ['SUPER_ADMIN', 'ADMIN', 'AGENT'] }
}
]
}
]
})
router.beforeEach((to) => {
const token = localStorage.getItem('ota-ui-token')
const roles = JSON.parse(localStorage.getItem('ota-ui-roles') || '[]')
const isLoggedIn = !!token
if (to.path !== '/login' && !isLoggedIn) {
return '/login'
}
if (to.path === '/login' && isLoggedIn) {
return '/'
}
const requiredRoles = to.meta.roles || []
if (requiredRoles.length && !requiredRoles.some((role) => roles.includes(role))) {
return '/'
}
})
export default router

201
src/services/api.js Normal file
View File

@@ -0,0 +1,201 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '../router'
const RELEASE_API_PREFIX = '/api/releases'
const VEHICLE_API_PREFIX = '/api/vehicles'
const ASSIGNMENT_API_PREFIX = '/api/assignments'
const AGENT_API_PREFIX = '/api/agent'
const AUTH_API_PREFIX = '/api/auth'
export const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL ?? 'http://127.0.0.1:8080',
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
})
api.interceptors.request.use((config) => {
const savedBaseUrl = localStorage.getItem('ota-ui-api-base-url')
const savedToken = localStorage.getItem('ota-ui-token')
if (savedBaseUrl) {
config.baseURL = savedBaseUrl
}
const headers = config.headers ?? {}
if (savedToken) {
headers['X-OTA-TOKEN'] = savedToken
}
config.headers = headers
return config
})
let isSessionExpiredHandling = false
let sessionExpiredMessageInstance = null
api.interceptors.response.use(
(response) => response,
(error) => {
if (error?.response?.status === 401) {
localStorage.removeItem('ota-ui-token')
localStorage.removeItem('ota-ui-roles')
localStorage.removeItem('ota-ui-username')
localStorage.removeItem('ota-ui-nickname')
if (!isSessionExpiredHandling) {
isSessionExpiredHandling = true
if (!sessionExpiredMessageInstance) {
sessionExpiredMessageInstance = ElMessage.error({
message: '登录已过期,请重新登录',
duration: 3000,
onClose: () => {
sessionExpiredMessageInstance = null
}
})
}
const currentPath = router.currentRoute.value.path
const redirectPromise = currentPath === '/login' ? Promise.resolve() : router.replace('/login')
redirectPromise.finally(() => {
window.setTimeout(() => {
isSessionExpiredHandling = false
}, 300)
})
}
} else {
const message = error?.response?.data?.message
if (message) {
ElMessage.error(message)
}
}
return Promise.reject(error)
}
)
export async function login(payload) {
const { data } = await api.post(`${AUTH_API_PREFIX}/login`, payload)
return data
}
export async function logout() {
const { data } = await api.post(`${AUTH_API_PREFIX}/logout`)
return data
}
export async function fetchCurrentUser() {
const { data } = await api.get(`${AUTH_API_PREFIX}/me`)
return data
}
export async function fetchReleases() {
const { data } = await api.get(RELEASE_API_PREFIX)
return data
}
export async function fetchVehicles() {
const { data } = await api.get(VEHICLE_API_PREFIX)
return data
}
export async function fetchAssignments() {
const { data } = await api.get(ASSIGNMENT_API_PREFIX)
return data
}
export async function createRelease(payload) {
const { data } = await api.post(RELEASE_API_PREFIX, payload)
return data
}
export async function updateRelease(releaseVersion, payload) {
const { data } = await api.put(`${RELEASE_API_PREFIX}/${encodeURIComponent(releaseVersion)}`, payload)
return data
}
export async function deleteRelease(releaseVersion) {
const { data } = await api.delete(`${RELEASE_API_PREFIX}/${encodeURIComponent(releaseVersion)}`)
return data
}
export async function registerVehicle(payload) {
const { data } = await api.post(VEHICLE_API_PREFIX, payload)
return data
}
export async function updateVehicle(vehicleId, payload) {
const { data } = await api.put(`${VEHICLE_API_PREFIX}/${encodeURIComponent(vehicleId)}`, payload)
return data
}
export async function deleteVehicle(vehicleId) {
const { data } = await api.delete(`${VEHICLE_API_PREFIX}/${encodeURIComponent(vehicleId)}`)
return data
}
export async function assignRelease(releaseVersion, vehicleIds) {
const { data } = await api.post(ASSIGNMENT_API_PREFIX, {
releaseVersion,
vehicleIds
})
return data
}
export async function updateAssignment(vehicleId, releaseVersion) {
const { data } = await api.put(`${ASSIGNMENT_API_PREFIX}/${encodeURIComponent(vehicleId)}`, {
releaseVersion,
vehicleIds: [vehicleId]
})
return data
}
export async function deleteAssignment(vehicleId) {
const { data } = await api.delete(`${ASSIGNMENT_API_PREFIX}/${encodeURIComponent(vehicleId)}`)
return data
}
export async function callHeartbeat(payload) {
const { data } = await api.post(`${AGENT_API_PREFIX}/heartbeat`, {
vehicleId: payload.vehicleId,
vin: payload.vin,
currentRelease: payload.currentRelease,
agentStatus: payload.agentStatus ?? 'IDLE'
})
return data
}
export async function callUpdateCheck(payload) {
const { data } = await api.post(`${AGENT_API_PREFIX}/update-check`, {
vehicleId: payload.vehicleId,
vin: payload.vin,
currentRelease: payload.currentRelease
})
return data
}
export async function callConfirm(payload) {
const { data } = await api.post(`${AGENT_API_PREFIX}/confirm`, {
vehicleId: payload.vehicleId,
vin: payload.vin,
currentRelease: payload.currentRelease
})
return data
}
export async function callPostpone(payload) {
const { data } = await api.post(`${AGENT_API_PREFIX}/postpone`, {
vehicleId: payload.vehicleId,
vin: payload.vin,
currentRelease: payload.currentRelease
})
return data
}
export async function callReport(payload) {
const { data } = await api.post(`${AGENT_API_PREFIX}/report`, {
vehicleId: payload.vehicleId,
releaseVersion: payload.releaseVersion,
agentStatus: payload.agentStatus ?? 'SUCCESS',
success: payload.success ?? true,
message: payload.message ?? 'debug report from UI'
})
return data
}

133
src/stores/dashboard.js Normal file
View File

@@ -0,0 +1,133 @@
import { defineStore } from 'pinia'
import {
assignRelease,
createRelease,
deleteAssignment,
deleteRelease,
deleteVehicle,
fetchAssignments,
fetchReleases,
fetchVehicles,
registerVehicle,
updateAssignment,
updateRelease,
updateVehicle
} from '../services/api'
export const useDashboardStore = defineStore('dashboard', {
state: () => ({
releases: [],
vehicles: [],
assignments: [],
loading: false,
releaseLoading: false,
vehicleLoading: false,
assignmentLoading: false
}),
getters: {
latestRelease(state) {
return state.releases[0]?.manifest ?? null
},
onlineVehicles(state) {
return state.vehicles.filter((item) => item.online).length
},
waitingTasks(state) {
return state.assignments.filter((item) => item.taskStatus === 'WAITING_CONFIRM').length
},
successTasks(state) {
return state.assignments.filter((item) => item.taskStatus === 'SUCCESS').length
}
},
actions: {
async loadAll() {
this.loading = true
this.releaseLoading = true
this.vehicleLoading = true
this.assignmentLoading = true
try {
const [releases, vehicles, assignments] = await Promise.all([
fetchReleases(),
fetchVehicles(),
fetchAssignments()
])
this.releases = releases
this.vehicles = vehicles
this.assignments = assignments
} finally {
this.loading = false
this.releaseLoading = false
this.vehicleLoading = false
this.assignmentLoading = false
}
},
async loadReleases() {
this.releaseLoading = true
try {
this.releases = await fetchReleases()
} finally {
this.releaseLoading = false
}
},
async loadVehicles() {
this.vehicleLoading = true
try {
this.vehicles = await fetchVehicles()
} finally {
this.vehicleLoading = false
}
},
async loadAssignments() {
this.assignmentLoading = true
try {
this.assignments = await fetchAssignments()
} finally {
this.assignmentLoading = false
}
},
async submitRelease(payload) {
await createRelease(payload)
await this.loadReleases()
},
async saveRelease(originalReleaseVersion, payload) {
await updateRelease(originalReleaseVersion, payload)
await this.loadReleases()
},
async removeRelease(releaseVersion) {
await deleteRelease(releaseVersion)
await this.loadReleases()
},
async submitVehicle(payload) {
await registerVehicle(payload)
await this.loadVehicles()
},
async saveVehicle(originalVehicleId, payload) {
await updateVehicle(originalVehicleId, payload)
await this.loadVehicles()
},
async removeVehicle(vehicleId) {
await deleteVehicle(vehicleId)
await this.loadVehicles()
},
async submitAssignment(releaseVersion, vehicleIds) {
await assignRelease(releaseVersion, vehicleIds)
await this.loadAssignments()
},
async saveAssignment(vehicleId, releaseVersion) {
await updateAssignment(vehicleId, releaseVersion)
await this.loadAssignments()
},
async removeAssignment(vehicleId) {
await deleteAssignment(vehicleId)
await this.loadAssignments()
},
getReleaseByVersion(releaseVersion) {
return this.releases.find((item) => item.manifest.releaseVersion === releaseVersion)
},
getVehicleById(vehicleId) {
return this.vehicles.find((item) => item.vehicleId === vehicleId)
},
getTaskByVehicleId(vehicleId) {
return this.assignments.find((item) => item.vehicleId === vehicleId)
}
}
})

1189
src/styles.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,86 @@
<template>
<div class="agent-grid">
<el-card class="dark-card span-5">
<template #header>Agent 调试参数</template>
<el-form label-position="top">
<el-form-item label="车辆 ID"><el-input v-model="form.vehicleId" /></el-form-item>
<el-form-item label="VIN"><el-input v-model="form.vin" /></el-form-item>
<el-form-item label="当前版本"><el-input v-model="form.currentRelease" /></el-form-item>
<el-form-item label="目标版本"><el-input v-model="form.releaseVersion" /></el-form-item>
<el-form-item label="Agent 状态">
<el-select v-model="form.agentStatus">
<el-option label="IDLE" value="IDLE" />
<el-option label="WAIT_USER_CONFIRM" value="WAIT_USER_CONFIRM" />
<el-option label="PULLING_IMAGE" value="PULLING_IMAGE" />
<el-option label="RESTARTING_SERVICE" value="RESTARTING_SERVICE" />
<el-option label="HEALTH_CHECKING" value="HEALTH_CHECKING" />
<el-option label="SUCCESS" value="SUCCESS" />
<el-option label="FAILED" value="FAILED" />
<el-option label="ROLLED_BACK" value="ROLLED_BACK" />
</el-select>
</el-form-item>
<el-form-item label="结果说明"><el-input v-model="form.message" type="textarea" :rows="3" /></el-form-item>
<el-form-item>
<el-switch v-model="form.success" active-text="成功" inactive-text="失败" />
</el-form-item>
<div class="agent-actions">
<el-button @click="fillDemo">填充示例</el-button>
<el-button type="primary" @click="callApi('heartbeat')">心跳</el-button>
<el-button type="primary" @click="callApi('update-check')">检查更新</el-button>
<el-button type="warning" @click="callApi('confirm')">确认升级</el-button>
<el-button type="info" @click="callApi('postpone')">稍后升级</el-button>
<el-button type="success" @click="callApi('report')">上报结果</el-button>
</div>
</el-form>
</el-card>
<el-card class="dark-card span-7">
<template #header>联调结果</template>
<el-empty v-if="!result" description="执行左侧调试操作后,这里显示接口返回结果" />
<pre v-else class="result-panel">{{ result }}</pre>
</el-card>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { callConfirm, callHeartbeat, callPostpone, callReport, callUpdateCheck } from '../services/api'
const form = reactive({
vehicleId: '',
vin: '',
currentRelease: '',
releaseVersion: '',
agentStatus: 'IDLE',
success: true,
message: 'debug report from UI'
})
const result = ref('')
function fillDemo() {
form.vehicleId = 'PT20V-001'
form.vin = 'NBL202604140001'
form.currentRelease = 'vehicle-release-2026.04.13-a'
form.releaseVersion = 'vehicle-release-2026.04.20-b'
form.agentStatus = 'SUCCESS'
form.success = true
form.message = 'Agent 调试上报成功'
}
async function callApi(type) {
try {
let response = null
if (type === 'heartbeat') response = await callHeartbeat(form)
if (type === 'update-check') response = await callUpdateCheck(form)
if (type === 'confirm') response = await callConfirm(form)
if (type === 'postpone') response = await callPostpone(form)
if (type === 'report') response = await callReport(form)
result.value = JSON.stringify(response, null, 2)
ElMessage.success(`调用 ${type} 成功`)
} catch (error) {
result.value = JSON.stringify(error, null, 2)
ElMessage.error(`调用 ${type} 失败`)
}
}
</script>

313
src/views/DashboardView.vue Normal file
View File

@@ -0,0 +1,313 @@
<template>
<div class="dashboard-grid" v-loading="store.loading">
<section class="dashboard-overview dark-card">
<div class="dashboard-overview-main">
<span class="dashboard-overview-kicker">运营概览</span>
<h1>OTA 运营总控台</h1>
<p>集中处理版本发布车辆登记升级任务分配与升级态势追踪适配当前半自动 OTA 发布流程</p>
</div>
<div class="dashboard-overview-actions">
<el-button @click="store.loadAll()">刷新数据</el-button>
</div>
</section>
<section class="metrics-grid">
<article class="metric-card dark-card">
<span class="metric-label">版本总数</span>
<strong>{{ store.releases.length }}</strong>
<p>已纳入 OTA 平台管理的发布版本数量</p>
</article>
<article class="metric-card dark-card">
<span class="metric-label">在线车辆</span>
<strong>{{ store.onlineVehicles }}</strong>
<p>最近仍保持心跳和上线记录的车辆数量</p>
</article>
<article class="metric-card dark-card">
<span class="metric-label">待确认任务</span>
<strong>{{ store.waitingTasks }}</strong>
<p>等待车端人工确认的升级任务数量</p>
</article>
<article class="metric-card dark-card metric-card-accent">
<span class="metric-label">成功任务</span>
<strong>{{ store.successTasks }}</strong>
<p>已经完成并确认成功的升级任务数量</p>
</article>
</section>
<section class="admin-section-grid">
<el-card class="dark-card admin-panel-card span-6">
<template #header>
<div class="admin-card-header">
<div>
<span class="admin-card-kicker">Release</span>
<h3>发布新版本</h3>
</div>
<el-button text @click="fillReleaseDemo">填充示例</el-button>
</div>
</template>
<el-form label-position="top" class="admin-form">
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="发布版本">
<el-input v-model="releaseForm.releaseVersion" placeholder="vehicle-release-2026.04.20-b" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="升级模式">
<el-select v-model="releaseForm.upgradeMode">
<el-option label="manual_confirm" value="MANUAL_CONFIRM" />
<el-option label="force_confirm" value="FORCE_CONFIRM" />
<el-option label="auto" value="AUTO" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="更新说明">
<el-input v-model="releaseForm.releaseNotes" type="textarea" :rows="4" />
</el-form-item>
<el-form-item>
<el-checkbox v-model="releaseForm.parkingRequired">必须停车状态执行</el-checkbox>
</el-form-item>
<el-form-item label="components JSON">
<el-input
v-model="releaseForm.componentsText"
type="textarea"
:rows="12"
placeholder='例如:{"images":{"BACKEND_IMAGE":"repo/backend:v1.0.2"}}'
/>
</el-form-item>
<div class="json-editor-toolbar">
<div class="json-editor-hint">支持动态嵌套结构创建时会按 JSON 原样提交到后端</div>
<el-button size="small" @click="formatCreateComponents">格式化 JSON</el-button>
</div>
<div class="admin-form-actions">
<el-button type="primary" @click="onCreateRelease">创建版本</el-button>
</div>
</el-form>
</el-card>
<el-card class="dark-card admin-panel-card span-6">
<template #header>
<div class="admin-card-header">
<div>
<span class="admin-card-kicker">Vehicle</span>
<h3>注册车辆</h3>
</div>
<el-button text @click="fillVehicleDemo">填充示例</el-button>
</div>
</template>
<el-form label-position="top" class="admin-form">
<el-row :gutter="12">
<el-col :span="12"><el-form-item label="车辆 ID"><el-input v-model="vehicleForm.vehicleId" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="VIN"><el-input v-model="vehicleForm.vin" /></el-form-item></el-col>
</el-row>
<el-form-item label="当前 Release"><el-input v-model="vehicleForm.currentRelease" /></el-form-item>
<el-form-item label="Backend"><el-input v-model="vehicleForm.currentBackendVersion" /></el-form-item>
<el-form-item label="Frontend"><el-input v-model="vehicleForm.currentFrontendVersion" /></el-form-item>
<el-form-item label="ROS2"><el-input v-model="vehicleForm.currentRosVersion" /></el-form-item>
<div class="admin-form-actions">
<el-button type="primary" @click="onRegisterVehicle">注册车辆</el-button>
</div>
</el-form>
</el-card>
<el-card class="dark-card admin-panel-card span-12">
<template #header>
<div class="admin-card-header">
<div>
<span class="admin-card-kicker">Assignment</span>
<h3>版本分配</h3>
</div>
<el-button text @click="selectAllVehicles">全选车辆</el-button>
</div>
</template>
<el-form label-position="top" class="admin-form">
<el-row :gutter="20">
<el-col :span="4">
<el-form-item label="目标版本">
<el-select v-model="assignmentForm.releaseVersion" placeholder="请选择版本">
<el-option
v-for="release in store.releases"
:key="release.manifest.releaseVersion"
:label="release.manifest.releaseVersion"
:value="release.manifest.releaseVersion"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="20">
<el-form-item label="选择车辆">
<el-checkbox-group v-model="assignmentForm.vehicleIds" class="vehicle-checkbox-grid">
<el-checkbox v-for="vehicle in store.vehicles" :key="vehicle.vehicleId" :value="vehicle.vehicleId">
{{ vehicle.vehicleId }} / {{ vehicle.currentRelease }}
</el-checkbox>
</el-checkbox-group>
</el-form-item>
</el-col>
</el-row>
<div class="admin-form-actions">
<el-button type="primary" @click="onAssignRelease">下发可升级版本</el-button>
</div>
</el-form>
</el-card>
</section>
</div>
<el-dialog v-model="successDialog.visible" width="420px" class="feedback-dialog" align-center>
<div class="feedback-dialog-body feedback-dialog-success">
<div class="feedback-dialog-icon"></div>
<h3>{{ successDialog.title }}</h3>
<p>{{ successDialog.message }}</p>
</div>
<template #footer>
<el-button type="primary" @click="successDialog.visible = false">我知道了</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { onMounted, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { useDashboardStore } from '../stores/dashboard'
const store = useDashboardStore()
const successDialog = reactive({
visible: false,
title: '',
message: ''
})
const releaseForm = reactive({
releaseVersion: '',
releaseNotes: '',
upgradeMode: 'MANUAL_CONFIRM',
parkingRequired: true,
componentsText: '{}'
})
const vehicleForm = reactive({
vehicleId: '',
vin: '',
currentRelease: '',
currentBackendVersion: '',
currentFrontendVersion: '',
currentRosVersion: ''
})
const assignmentForm = reactive({
releaseVersion: '',
vehicleIds: []
})
function openSuccessDialog(title, message) {
successDialog.title = title
successDialog.message = message
successDialog.visible = true
}
function prettyJson(text) {
return JSON.stringify(JSON.parse(text || '{}'), null, 2)
}
function formatCreateComponents() {
try {
releaseForm.componentsText = prettyJson(releaseForm.componentsText)
ElMessage.success('components JSON 已格式化')
} catch {
ElMessage.error('components JSON 格式不正确')
}
}
function fillReleaseDemo() {
releaseForm.releaseVersion = 'vehicle-release-2026.04.20-b'
releaseForm.releaseNotes = '后端修复任务同步问题前端优化操作页ROS2 程序修复定位异常'
releaseForm.upgradeMode = 'MANUAL_CONFIRM'
releaseForm.parkingRequired = true
releaseForm.componentsText = JSON.stringify(
{
images: {
BACKEND_IMAGE: 'repo/backend:v1.0.1',
FRONTEND1_IMAGE: 'repo/frontend1:v1.0.1',
FRONTEND2_IMAGE: 'repo/frontend2:v1.0.1',
ROS2NODE1_IMAGE: 'repo/ros2node1:v1.0.1',
ROS2NODE2_IMAGE: 'repo/ros2node2:v1.0.1'
}
},
null,
2
)
}
function fillVehicleDemo() {
vehicleForm.vehicleId = 'PT20V-001'
vehicleForm.vin = 'NBL202604140001'
vehicleForm.currentRelease = 'vehicle-release-2026.04.13-a'
vehicleForm.currentBackendVersion = 'repo/backend:v1.0.0'
vehicleForm.currentFrontendVersion = 'repo/frontend1:v1.0.0'
vehicleForm.currentRosVersion = 'repo/ros2node1:v1.0.0'
}
function selectAllVehicles() {
assignmentForm.vehicleIds = store.vehicles.map((item) => item.vehicleId)
}
async function onCreateRelease() {
if (!releaseForm.releaseVersion || !releaseForm.releaseNotes) {
ElMessage.error('请填写完整版本信息')
return
}
let components
try {
releaseForm.componentsText = prettyJson(releaseForm.componentsText)
components = JSON.parse(releaseForm.componentsText)
} catch {
ElMessage.error('components JSON 格式不正确')
return
}
await store.submitRelease({
releaseVersion: releaseForm.releaseVersion,
releaseNotes: releaseForm.releaseNotes,
upgradeMode: releaseForm.upgradeMode,
parkingRequired: releaseForm.parkingRequired,
components
})
openSuccessDialog('创建成功', `版本 ${releaseForm.releaseVersion} 已创建并加入列表。`)
releaseForm.releaseVersion = ''
releaseForm.releaseNotes = ''
releaseForm.upgradeMode = 'MANUAL_CONFIRM'
releaseForm.parkingRequired = true
releaseForm.componentsText = '{}'
}
async function onRegisterVehicle() {
if (!vehicleForm.vehicleId || !vehicleForm.vin || !vehicleForm.currentRelease) {
ElMessage.error('请填写完整车辆信息')
return
}
await store.submitVehicle({ ...vehicleForm })
openSuccessDialog('注册成功', `车辆 ${vehicleForm.vehicleId} 已完成注册。`)
vehicleForm.vehicleId = ''
vehicleForm.vin = ''
vehicleForm.currentRelease = ''
vehicleForm.currentBackendVersion = ''
vehicleForm.currentFrontendVersion = ''
vehicleForm.currentRosVersion = ''
}
async function onAssignRelease() {
if (!assignmentForm.releaseVersion || !assignmentForm.vehicleIds.length) {
ElMessage.error('请选择目标版本和车辆')
return
}
await store.submitAssignment(assignmentForm.releaseVersion, assignmentForm.vehicleIds)
openSuccessDialog('下发成功', `版本 ${assignmentForm.releaseVersion} 已下发给 ${assignmentForm.vehicleIds.length} 台车辆。`)
}
onMounted(async () => {
await store.loadAll()
})
</script>

62
src/views/LoginView.vue Normal file
View File

@@ -0,0 +1,62 @@
<template>
<div class="login-page">
<div class="login-card">
<div class="login-brand">
<span>OTA SERVER</span>
<h1>登录控制中心</h1>
</div>
<el-form :model="form" label-position="top" @submit.prevent>
<el-form-item label="用户名">
<el-input v-model="form.username" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" type="password" show-password/>
</el-form-item>
<el-form-item label="后端地址">
<el-input v-model="form.baseUrl" placeholder="http://127.0.0.1:8080" />
</el-form-item>
<div class="login-actions">
<el-button type="primary" :loading="loading" @click="loginAction">登录</el-button>
</div>
</el-form>
</div>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { login } from '../services/api'
const router = useRouter()
const loading = ref(false)
const form = reactive({
username: '',
password: '',
baseUrl: localStorage.getItem('ota-ui-api-base-url') || import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:8080'
})
async function loginAction() {
if (!form.username || !form.password) {
ElMessage.error('请输入用户名和密码')
return
}
loading.value = true
try {
localStorage.setItem('ota-ui-api-base-url', form.baseUrl)
const result = await login({ username: form.username, password: form.password })
localStorage.setItem('ota-ui-token', result.token)
localStorage.setItem('ota-ui-roles', JSON.stringify(result.roles || []))
localStorage.setItem('ota-ui-username', result.username)
localStorage.setItem('ota-ui-nickname', result.nickname)
ElMessage.success('登录成功')
router.push('/')
} catch (error) {
ElMessage.error(error?.response?.data?.message || '登录失败')
} finally {
loading.value = false
}
}
</script>

229
src/views/ReleasesView.vue Normal file
View File

@@ -0,0 +1,229 @@
<template>
<div class="page-grid">
<el-card class="dark-card span-7" v-loading="store.releaseLoading">
<template #header>版本列表</template>
<el-table :data="store.releases" height="520" @row-click="onSelect">
<el-table-column prop="manifest.releaseVersion" label="版本" min-width="240" />
<el-table-column prop="manifest.upgradeMode" label="模式" width="150" />
<el-table-column prop="manifest.publishedAt" label="发布时间" min-width="200" />
</el-table>
</el-card>
<el-card class="dark-card span-5 detail-card" v-loading="store.releaseLoading">
<template #header>
<div class="card-header-inline detail-header-inline">
<span>版本详情</span>
<div v-if="selected" class="detail-actions">
<el-button size="small" :loading="saving" :disabled="deleting" @click="openEdit">修改</el-button>
<el-button size="small" type="danger" :loading="deleting" :disabled="saving" @click="onDelete">删除</el-button>
</div>
</div>
</template>
<div v-if="selected" class="detail-stack detail-readable">
<el-descriptions :column="1" border>
<el-descriptions-item label="版本号">{{ selected.manifest.releaseVersion }}</el-descriptions-item>
<el-descriptions-item label="升级模式">{{ selected.manifest.upgradeMode }}</el-descriptions-item>
<el-descriptions-item label="发布时间">{{ selected.manifest.publishedAt }}</el-descriptions-item>
<el-descriptions-item label="停车要求">{{ selected.manifest.parkingRequired ? '是' : '否' }}</el-descriptions-item>
<el-descriptions-item label="更新说明">{{ selected.manifest.releaseNotes }}</el-descriptions-item>
</el-descriptions>
<el-card class="inner-card">
<template #header>组件配置</template>
<pre class="components-json-view">{{ formatComponents(selected.manifest.components) }}</pre>
</el-card>
</div>
<el-empty v-else description="点击左侧版本查看详情" />
</el-card>
</div>
<el-dialog v-model="editVisible" title="修改版本" width="720px">
<el-form label-position="top" v-if="editForm.releaseVersion">
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="发布版本">
<el-input v-model="editForm.releaseVersion" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="升级模式">
<el-select v-model="editForm.upgradeMode">
<el-option label="manual_confirm" value="MANUAL_CONFIRM" />
<el-option label="force_confirm" value="FORCE_CONFIRM" />
<el-option label="auto" value="AUTO" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="更新说明">
<el-input v-model="editForm.releaseNotes" type="textarea" :rows="4" />
</el-form-item>
<el-form-item>
<el-checkbox v-model="editForm.parkingRequired">必须停车状态执行</el-checkbox>
</el-form-item>
<el-form-item label="components JSON">
<el-input
v-model="editForm.componentsText"
type="textarea"
:rows="12"
placeholder='例如:{"images":{"BACKEND_IMAGE":"repo/backend:v1.0.2"}}'
/>
</el-form-item>
<div class="json-editor-toolbar">
<div class="json-editor-hint">支持动态嵌套结构保存时会按 JSON 原样提交到后端</div>
<el-button size="small" @click="formatEditComponents">格式化 JSON</el-button>
</div>
</el-form>
<template #footer>
<el-button @click="editVisible = false" :disabled="saving">取消</el-button>
<el-button type="primary" :loading="saving" @click="onSave">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="deleteVisible" title="删除确认" width="520px">
<div class="delete-dialog-copy">
<p>确认删除版本 {{ selected?.manifest.releaseVersion }} </p>
<p>若该版本已被任务引用将禁止删除</p>
</div>
<template #footer>
<el-button @click="deleteVisible = false" :disabled="deleting">取消</el-button>
<el-button type="danger" :loading="deleting" @click="confirmDelete">确认删除</el-button>
</template>
</el-dialog>
<el-dialog v-model="successDialog.visible" width="420px" class="feedback-dialog" align-center>
<div class="feedback-dialog-body feedback-dialog-success">
<div class="feedback-dialog-icon"></div>
<h3>{{ successDialog.title }}</h3>
<p>{{ successDialog.message }}</p>
</div>
<template #footer>
<el-button type="primary" @click="successDialog.visible = false">我知道了</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { computed, onMounted, onActivated, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useDashboardStore } from '../stores/dashboard'
const store = useDashboardStore()
const selectedVersion = ref('')
const editVisible = ref(false)
const deleteVisible = ref(false)
const saving = ref(false)
const deleting = ref(false)
const successDialog = reactive({
visible: false,
title: '',
message: ''
})
const editForm = reactive({
releaseVersion: '',
releaseNotes: '',
upgradeMode: 'MANUAL_CONFIRM',
parkingRequired: true,
componentsText: '{}'
})
const selected = computed(() => store.releases.find((item) => item.manifest.releaseVersion === selectedVersion.value))
function onSelect(row) {
selectedVersion.value = row.manifest.releaseVersion
}
function prettyJson(text) {
return JSON.stringify(JSON.parse(text || '{}'), null, 2)
}
function openSuccessDialog(title, message) {
successDialog.title = title
successDialog.message = message
successDialog.visible = true
}
function formatComponents(components) {
return JSON.stringify(components ?? {}, null, 2)
}
function syncSelectedRelease() {
if (selectedVersion.value && store.releases.some((item) => item.manifest.releaseVersion === selectedVersion.value)) {
return
}
selectedVersion.value = store.releases[0]?.manifest.releaseVersion ?? ''
}
async function refreshReleases() {
await store.loadReleases()
syncSelectedRelease()
}
function formatEditComponents() {
try {
editForm.componentsText = prettyJson(editForm.componentsText)
ElMessage.success('components JSON 已格式化')
} catch {
ElMessage.error('components JSON 格式不正确')
}
}
function openEdit() {
if (!selected.value || saving.value || deleting.value) return
editForm.releaseVersion = selected.value.manifest.releaseVersion
editForm.releaseNotes = selected.value.manifest.releaseNotes
editForm.upgradeMode = selected.value.manifest.upgradeMode
editForm.parkingRequired = selected.value.manifest.parkingRequired
editForm.componentsText = formatComponents(selected.value.manifest.components)
editVisible.value = true
}
async function onSave() {
if (saving.value || !selected.value) return
let components
try {
editForm.componentsText = prettyJson(editForm.componentsText)
components = JSON.parse(editForm.componentsText)
} catch {
ElMessage.error('components JSON 格式不正确')
return
}
saving.value = true
try {
await store.saveRelease(selected.value.manifest.releaseVersion, {
releaseVersion: editForm.releaseVersion,
releaseNotes: editForm.releaseNotes,
upgradeMode: editForm.upgradeMode,
parkingRequired: editForm.parkingRequired,
components
})
selectedVersion.value = editForm.releaseVersion
editVisible.value = false
openSuccessDialog('保存成功', `版本 ${editForm.releaseVersion} 已更新。`)
} finally {
saving.value = false
}
}
async function onDelete() {
if (deleting.value || !selected.value) return
deleteVisible.value = true
}
async function confirmDelete() {
if (deleting.value || !selected.value) return
deleting.value = true
try {
const removedVersion = selected.value.manifest.releaseVersion
await store.removeRelease(removedVersion)
syncSelectedRelease()
deleteVisible.value = false
openSuccessDialog('删除成功', `版本 ${removedVersion} 已删除。`)
} finally {
deleting.value = false
}
}
onMounted(refreshReleases)
onActivated(refreshReleases)
</script>

View File

@@ -0,0 +1,66 @@
<template>
<div class="settings-grid settings-page">
<el-card class="dark-card settings-main-card span-7">
<template #header>
<div class="settings-card-header">
<div>
<span class="settings-kicker">SYSTEM CONFIG</span>
<h3>接口与令牌配置</h3>
<p>在这里维护当前前端连接的后端地址与访问令牌</p>
</div>
<el-tag type="success" effect="dark">实时生效</el-tag>
</div>
</template>
<el-form label-position="top" class="settings-form">
<el-form-item label="API Base URL">
<el-input v-model="baseUrl" placeholder="http://127.0.0.1:8080" />
</el-form-item>
<el-form-item label="OTA Token">
<el-input v-model="token" type="textarea" :rows="3" placeholder="请输入当前有效 token" />
</el-form-item>
<div class="settings-actions">
<el-button type="primary" @click="save">保存配置</el-button>
</div>
</el-form>
</el-card>
<div class="settings-side span-5">
<el-card class="dark-card settings-info-card">
<template #header>
<div class="settings-side-header">
<span>使用说明</span>
</div>
</template>
<div class="settings-note-list">
<div class="settings-note-item">
<strong>接口地址</strong>
<p>请填写当前 OTA Server 后端服务地址保存后立即用于后续请求</p>
</div>
<div class="settings-note-item">
<strong>Token 失效</strong>
<p>如果 token 缺失失效或过期系统会自动清理会话并返回登录界面</p>
</div>
<div class="settings-note-item">
<strong>调试建议</strong>
<p>建议优先通过登录获取有效 token手动填写 token 仅用于排障或联调场景</p>
</div>
</div>
</el-card>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
const baseUrl = ref(localStorage.getItem('ota-ui-api-base-url') || import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:8080')
const token = ref(localStorage.getItem('ota-ui-token') || import.meta.env.VITE_OTA_TOKEN || 'dev-token')
function save() {
localStorage.setItem('ota-ui-api-base-url', baseUrl.value)
localStorage.setItem('ota-ui-token', token.value)
ElMessage.success('配置已保存')
}
</script>

239
src/views/TasksView.vue Normal file
View File

@@ -0,0 +1,239 @@
<template>
<div class="page-grid">
<el-card class="dark-card span-7" v-loading="store.assignmentLoading || store.vehicleLoading">
<template #header>
<div class="tasks-card-header">
<span>升级任务列表</span>
<div class="task-filter-group">
<el-radio-group v-model="filterMode" size="small">
<el-radio-button label="ALL">全部任务</el-radio-button>
<el-radio-button label="ABNORMAL">只看异常任务</el-radio-button>
<el-radio-button label="FAILED">只看失败车辆</el-radio-button>
</el-radio-group>
</div>
</div>
</template>
<el-table :data="filteredAssignments" height="520" @row-click="onSelect">
<el-table-column prop="vehicleId" label="车辆 ID" min-width="180" />
<el-table-column prop="releaseVersion" label="任务目标版本" min-width="200" />
<el-table-column label="车辆当前目标版本" min-width="200">
<template #default="scope">
{{ getVehicleById(scope.row.vehicleId)?.targetRelease || '-' }}
</template>
</el-table-column>
<el-table-column label="Agent 状态" width="150">
<template #default="scope">
<el-tag :type="getAgentStatusTagType(getVehicleById(scope.row.vehicleId)?.agentStatus)" effect="dark">
{{ getVehicleById(scope.row.vehicleId)?.agentStatus || '-' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="最后结果" min-width="220" show-overflow-tooltip>
<template #default="scope">
<span :class="getResultClass(getVehicleById(scope.row.vehicleId)?.lastResult)">
{{ getVehicleById(scope.row.vehicleId)?.lastResult || '-' }}
</span>
</template>
</el-table-column>
<el-table-column prop="taskStatus" label="任务状态" width="180" />
<el-table-column prop="postponeCount" label="延后次数" width="120" />
</el-table>
</el-card>
<el-card class="dark-card span-5 detail-card" v-loading="store.assignmentLoading || store.vehicleLoading">
<template #header>
<div class="card-header-inline detail-header-inline">
<span>任务详情</span>
<div v-if="selected" class="detail-actions">
<el-button size="small" :loading="saving" :disabled="deleting" @click="openEdit">修改</el-button>
<el-button size="small" type="danger" :loading="deleting" :disabled="saving" @click="onDelete">删除</el-button>
</div>
</div>
</template>
<div v-if="selected" class="detail-stack detail-readable">
<el-descriptions :column="1" border>
<el-descriptions-item label="车辆 ID">{{ selected.vehicleId }}</el-descriptions-item>
<el-descriptions-item label="任务目标版本">{{ selected.releaseVersion }}</el-descriptions-item>
<el-descriptions-item label="车辆当前目标版本">{{ relatedVehicle?.targetRelease || '-' }}</el-descriptions-item>
<el-descriptions-item label="车辆当前版本">{{ relatedVehicle?.currentRelease || '-' }}</el-descriptions-item>
<el-descriptions-item label="任务状态">{{ selected.taskStatus }}</el-descriptions-item>
<el-descriptions-item label="车辆 Agent 状态">
<el-tag :type="getAgentStatusTagType(relatedVehicle?.agentStatus)" effect="dark">
{{ relatedVehicle?.agentStatus || '-' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="首次提示">{{ selected.promptedAt || '-' }}</el-descriptions-item>
<el-descriptions-item label="确认时间">{{ selected.confirmedAt || '-' }}</el-descriptions-item>
<el-descriptions-item label="开始时间">{{ selected.startedAt || '-' }}</el-descriptions-item>
<el-descriptions-item label="完成时间">{{ selected.finishedAt || '-' }}</el-descriptions-item>
<el-descriptions-item label="延后次数">{{ selected.postponeCount }}</el-descriptions-item>
<el-descriptions-item label="最后消息">{{ selected.lastMessage || '-' }}</el-descriptions-item>
<el-descriptions-item label="车辆最后结果">
<span :class="getResultClass(relatedVehicle?.lastResult)">
{{ relatedVehicle?.lastResult || '-' }}
</span>
</el-descriptions-item>
<el-descriptions-item label="备份文件">{{ relatedVehicle?.backupFile || '-' }}</el-descriptions-item>
<el-descriptions-item label="镜像列表">
<pre class="components-json-view vehicle-images-view">{{ formatImages(relatedVehicle?.images) }}</pre>
</el-descriptions-item>
</el-descriptions>
</div>
<el-empty v-else description="点击左侧任务查看详情" />
</el-card>
</div>
<el-dialog v-model="editVisible" title="修改任务" width="520px">
<el-form label-position="top">
<el-form-item label="车辆 ID">
<el-input :model-value="selected?.vehicleId" disabled />
</el-form-item>
<el-form-item label="目标版本">
<el-select v-model="editReleaseVersion" placeholder="请选择版本">
<el-option
v-for="release in store.releases"
:key="release.manifest.releaseVersion"
:label="release.manifest.releaseVersion"
:value="release.manifest.releaseVersion"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false" :disabled="saving">取消</el-button>
<el-button type="primary" :loading="saving" @click="onSave">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="deleteVisible" title="删除确认" width="520px">
<div class="delete-dialog-copy">
<p>确认删除车辆 {{ selected?.vehicleId }} 的任务吗</p>
<p>删除后将移除当前升级任务记录</p>
</div>
<template #footer>
<el-button @click="deleteVisible = false" :disabled="deleting">取消</el-button>
<el-button type="danger" :loading="deleting" @click="confirmDelete">确认删除</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { computed, onMounted, onActivated, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useDashboardStore } from '../stores/dashboard'
const store = useDashboardStore()
const selectedVehicleId = ref('')
const editVisible = ref(false)
const deleteVisible = ref(false)
const saving = ref(false)
const deleting = ref(false)
const editReleaseVersion = ref('')
const filterMode = ref('ALL')
const selected = computed(() => filteredAssignments.value.find((item) => item.vehicleId === selectedVehicleId.value))
const relatedVehicle = computed(() => getVehicleById(selectedVehicleId.value))
const filteredAssignments = computed(() => {
if (filterMode.value === 'ABNORMAL') {
return store.assignments.filter((item) => isAbnormalTask(item))
}
if (filterMode.value === 'FAILED') {
return store.assignments.filter((item) => isFailedVehicle(item.vehicleId))
}
return store.assignments
})
function onSelect(row) {
selectedVehicleId.value = row.vehicleId
}
function getVehicleById(vehicleId) {
return store.vehicles.find((item) => item.vehicleId === vehicleId)
}
function getAgentStatusTagType(status) {
if (status === 'SUCCESS' || status === 'IDLE') return 'success'
if (status === 'FAILED' || status === 'ROLLBACK_FAILED') return 'danger'
if (status === 'UPDATING' || status === 'DOWNLOADING' || status === 'ROLLING_BACK') return 'warning'
return 'info'
}
function getResultClass(result) {
const text = (result || '').toLowerCase()
if (!text) return 'task-result-text'
if (text.includes('success') || text.includes('ok') || text.includes('完成')) return 'task-result-text is-success'
if (text.includes('rollback') || text.includes('回滚')) return 'task-result-text is-warning'
if (text.includes('fail') || text.includes('error') || text.includes('异常') || text.includes('失败')) return 'task-result-text is-danger'
return 'task-result-text'
}
function isAbnormalTask(task) {
const vehicle = getVehicleById(task.vehicleId)
const agentStatus = vehicle?.agentStatus
const taskStatus = task.taskStatus
return [agentStatus, taskStatus].some((status) => {
return status && ['FAILED', 'ROLLBACK_FAILED', 'ROLLING_BACK', 'WAITING_CONFIRM'].includes(status)
})
}
function isFailedVehicle(vehicleId) {
const vehicle = getVehicleById(vehicleId)
return ['FAILED', 'ROLLBACK_FAILED'].includes(vehicle?.agentStatus)
}
function formatImages(images) {
return JSON.stringify(images ?? {}, null, 2)
}
function syncSelectedAssignment() {
if (selectedVehicleId.value && filteredAssignments.value.some((item) => item.vehicleId === selectedVehicleId.value)) {
return
}
selectedVehicleId.value = filteredAssignments.value[0]?.vehicleId ?? ''
}
async function refreshAssignments() {
await Promise.all([store.loadAssignments(), store.loadReleases(), store.loadVehicles()])
syncSelectedAssignment()
}
function openEdit() {
if (!selected.value || saving.value || deleting.value) return
editReleaseVersion.value = selected.value.releaseVersion
editVisible.value = true
}
async function onSave() {
if (saving.value || !selected.value) return
saving.value = true
try {
await store.saveAssignment(selected.value.vehicleId, editReleaseVersion.value)
editVisible.value = false
ElMessage.success('任务修改成功')
} finally {
saving.value = false
}
}
async function onDelete() {
if (deleting.value || !selected.value) return
deleteVisible.value = true
}
async function confirmDelete() {
if (deleting.value || !selected.value) return
deleting.value = true
try {
await store.removeAssignment(selected.value.vehicleId)
syncSelectedAssignment()
deleteVisible.value = false
ElMessage.success('任务删除成功')
} catch (error) {
deleteVisible.value = false
} finally {
deleting.value = false
}
}
onMounted(refreshAssignments)
onActivated(refreshAssignments)
</script>

196
src/views/VehiclesView.vue Normal file
View File

@@ -0,0 +1,196 @@
<template>
<div class="page-grid">
<el-card class="dark-card span-7" v-loading="store.vehicleLoading">
<template #header>车辆列表</template>
<el-table :data="store.vehicles" height="520" @row-click="onSelect">
<el-table-column prop="vehicleId" label="车辆 ID" min-width="180" />
<el-table-column prop="vin" label="VIN" min-width="220" />
<el-table-column prop="currentRelease" label="当前版本" min-width="220" />
<el-table-column prop="targetRelease" label="目标版本" min-width="220" />
<el-table-column label="在线状态" width="120">
<template #default="scope">
<el-tag :type="scope.row.online ? 'success' : 'info'" effect="dark">
{{ scope.row.online ? '在线' : '离线' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="agentStatus" label="Agent 状态" width="150" />
</el-table>
</el-card>
<el-card class="dark-card span-5 detail-card" v-loading="store.vehicleLoading">
<template #header>
<div class="card-header-inline detail-header-inline">
<span>车辆详情</span>
<div v-if="selected" class="detail-actions">
<el-button size="small" :loading="saving" :disabled="deleting" @click="openEdit">修改</el-button>
<el-button size="small" type="danger" :loading="deleting" :disabled="saving" @click="onDelete">删除</el-button>
</div>
</div>
</template>
<div v-if="selected" class="detail-stack detail-readable">
<el-descriptions :column="1" border>
<el-descriptions-item label="车辆 ID">{{ selected.vehicleId }}</el-descriptions-item>
<el-descriptions-item label="VIN">{{ selected.vin }}</el-descriptions-item>
<el-descriptions-item label="当前版本">{{ selected.currentRelease }}</el-descriptions-item>
<el-descriptions-item label="目标版本">{{ selected.targetRelease || '-' }}</el-descriptions-item>
<el-descriptions-item label="最后结果">{{ selected.lastResult || '-' }}</el-descriptions-item>
<el-descriptions-item label="备份文件">{{ selected.backupFile || '-' }}</el-descriptions-item>
<el-descriptions-item label="Backend">{{ selected.currentBackendVersion || '-' }}</el-descriptions-item>
<el-descriptions-item label="Frontend">{{ selected.currentFrontendVersion || '-' }}</el-descriptions-item>
<el-descriptions-item label="ROS2">{{ selected.currentRosVersion || '-' }}</el-descriptions-item>
<el-descriptions-item label="镜像列表">
<pre class="components-json-view vehicle-images-view">{{ formatImages(selected.images) }}</pre>
</el-descriptions-item>
<el-descriptions-item label="在线状态">
<el-tag :type="selected.online ? 'success' : 'info'" effect="dark">
{{ selected.online ? '在线' : '离线' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="Agent 状态">{{ selected.agentStatus || 'IDLE' }}</el-descriptions-item>
<el-descriptions-item label="最后心跳">{{ selected.lastSeenAt || '-' }}</el-descriptions-item>
</el-descriptions>
</div>
<el-empty v-else description="点击左侧车辆查看详情" />
</el-card>
</div>
<el-dialog v-model="editVisible" title="修改车辆" width="680px">
<el-form label-position="top">
<el-row :gutter="12">
<el-col :span="12"><el-form-item label="车辆 ID"><el-input v-model="editForm.vehicleId" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="VIN"><el-input v-model="editForm.vin" /></el-form-item></el-col>
</el-row>
<el-form-item label="当前 Release"><el-input v-model="editForm.currentRelease" /></el-form-item>
<el-form-item label="Backend"><el-input v-model="editForm.currentBackendVersion" /></el-form-item>
<el-form-item label="Frontend"><el-input v-model="editForm.currentFrontendVersion" /></el-form-item>
<el-form-item label="ROS2"><el-input v-model="editForm.currentRosVersion" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false" :disabled="saving">取消</el-button>
<el-button type="primary" :loading="saving" @click="onSave">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="deleteVisible" title="删除确认" width="520px">
<div class="delete-dialog-copy">
<p>确认删除车辆 {{ selected?.vehicleId }} </p>
<p>若该车辆存在任务记录将禁止删除</p>
</div>
<template #footer>
<el-button @click="deleteVisible = false" :disabled="deleting">取消</el-button>
<el-button type="danger" :loading="deleting" @click="confirmDelete">确认删除</el-button>
</template>
</el-dialog>
<el-dialog v-model="successDialog.visible" width="420px" class="feedback-dialog" align-center>
<div class="feedback-dialog-body feedback-dialog-success">
<div class="feedback-dialog-icon"></div>
<h3>{{ successDialog.title }}</h3>
<p>{{ successDialog.message }}</p>
</div>
<template #footer>
<el-button type="primary" @click="successDialog.visible = false">我知道了</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { computed, onMounted, onActivated, reactive, ref } from 'vue'
import { useDashboardStore } from '../stores/dashboard'
const store = useDashboardStore()
const selectedVehicleId = ref('')
const editVisible = ref(false)
const deleteVisible = ref(false)
const saving = ref(false)
const deleting = ref(false)
const successDialog = reactive({
visible: false,
title: '',
message: ''
})
const editForm = reactive({
vehicleId: '',
vin: '',
currentRelease: '',
currentBackendVersion: '',
currentFrontendVersion: '',
currentRosVersion: ''
})
const selected = computed(() => store.vehicles.find((item) => item.vehicleId === selectedVehicleId.value))
function onSelect(row) {
selectedVehicleId.value = row.vehicleId
}
function openSuccessDialog(title, message) {
successDialog.title = title
successDialog.message = message
successDialog.visible = true
}
function formatImages(images) {
return JSON.stringify(images ?? {}, null, 2)
}
function syncSelectedVehicle() {
if (selectedVehicleId.value && store.vehicles.some((item) => item.vehicleId === selectedVehicleId.value)) {
return
}
selectedVehicleId.value = store.vehicles[0]?.vehicleId ?? ''
}
async function refreshVehicles() {
await store.loadVehicles()
syncSelectedVehicle()
}
function openEdit() {
if (!selected.value || saving.value || deleting.value) return
editForm.vehicleId = selected.value.vehicleId
editForm.vin = selected.value.vin
editForm.currentRelease = selected.value.currentRelease
editForm.currentBackendVersion = selected.value.currentBackendVersion ?? ''
editForm.currentFrontendVersion = selected.value.currentFrontendVersion ?? ''
editForm.currentRosVersion = selected.value.currentRosVersion ?? ''
editVisible.value = true
}
async function onSave() {
if (saving.value || !selected.value) return
saving.value = true
try {
await store.saveVehicle(selected.value.vehicleId, { ...editForm })
selectedVehicleId.value = editForm.vehicleId
editVisible.value = false
openSuccessDialog('保存成功', `车辆 ${editForm.vehicleId} 已更新。`)
} finally {
saving.value = false
}
}
async function onDelete() {
if (deleting.value || !selected.value) return
deleteVisible.value = true
}
async function confirmDelete() {
if (deleting.value || !selected.value) return
const removedVehicleId = selected.value.vehicleId
deleting.value = true
try {
await store.removeVehicle(removedVehicleId)
syncSelectedVehicle()
deleteVisible.value = false
openSuccessDialog('删除成功', `车辆 ${removedVehicleId} 已删除。`)
} catch (error) {
deleteVisible.value = false
} finally {
deleting.value = false
}
}
onMounted(refreshVehicles)
onActivated(refreshVehicles)
</script>

57
vite.config.js Normal file
View File

@@ -0,0 +1,57 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
const vendorChunks = {
vue: ['vue', 'vue-router', 'pinia'],
elementPlus: ['element-plus', '@element-plus/icons-vue'],
axios: ['axios']
}
function manualChunks(id) {
if (!id.includes('node_modules')) {
return
}
for (const [chunkName, packages] of Object.entries(vendorChunks)) {
if (packages.some((pkg) => id.includes(`/node_modules/${pkg}/`) || id.includes(`\\node_modules\\${pkg}\\`))) {
return chunkName
}
}
return 'vendor'
}
export default defineConfig({
plugins: [
vue(),
AutoImport({
imports: ['vue', 'vue-router', 'pinia'],
resolvers: [
ElementPlusResolver({
importStyle: 'css'
})
]
}),
Components({
resolvers: [
ElementPlusResolver({
importStyle: 'css'
})
]
})
],
build: {
rollupOptions: {
output: {
manualChunks
}
}
},
server: {
host: '0.0.0.0',
port: 5173
}
})