Changes Initial commit
This commit is contained in:
90
auto-imports.d.ts
vendored
Normal file
90
auto-imports.d.ts
vendored
Normal 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
44
components.d.ts
vendored
Normal 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
2
env.example
Normal 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
15
index.html
Normal 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
2556
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal 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
3
src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
123
src/layouts/MainLayout.vue
Normal file
123
src/layouts/MainLayout.vue
Normal 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
11
src/main.js
Normal 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
76
src/router/index.js
Normal 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
201
src/services/api.js
Normal 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
133
src/stores/dashboard.js
Normal 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
1189
src/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
86
src/views/AgentDebugView.vue
Normal file
86
src/views/AgentDebugView.vue
Normal 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
313
src/views/DashboardView.vue
Normal 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
62
src/views/LoginView.vue
Normal 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
229
src/views/ReleasesView.vue
Normal 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>
|
||||
66
src/views/SettingsView.vue
Normal file
66
src/views/SettingsView.vue
Normal 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
239
src/views/TasksView.vue
Normal 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
196
src/views/VehiclesView.vue
Normal 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
57
vite.config.js
Normal 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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user