You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

267 lines
9.7 KiB

<template>
<section class="operation-page">
<header class="operation-page-header">
<div>
<h1>固件管理</h1>
<span>维护机巢固件版本发布后供用户对设备升级</span>
</div>
<div class="page-actions">
<button class="icon-command" title="刷新" @click="load"><svg><use href="#i-refresh" /></svg></button>
<button class="primary-command" @click="openAdd"><svg><use href="#i-plus" /></svg>新增固件</button>
</div>
</header>
<div class="business-workspace">
<div class="asset-toolbar">
<div class="asset-search"><svg><use href="#i-search" /></svg><input v-model="keyword" type="text" placeholder="搜索版本号 / 描述" @keyup.enter="reload" /></div>
<div class="toolbar-group">
<select v-model="statusFilter" @change="reload">
<option value="">全部状态</option>
<option value="draft">草稿</option>
<option value="released">已发布</option>
<option value="deprecated">已弃用</option>
</select>
</div>
</div>
<div class="asset-table-wrap">
<table class="asset-table">
<thead>
<tr>
<th style="width:12%">版本</th>
<th style="width:14%">组件</th>
<th style="width:22%">描述</th>
<th style="width:9%">大小</th>
<th style="width:8%">升级方式</th>
<th style="width:9%">状态</th>
<th style="width:16%">创建时间</th>
<th style="width:10%">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="f in list" :key="f.id">
<td><strong>{{ f.version }}</strong></td>
<td>{{ f.component }}</td>
<td><span :title="f.description">{{ f.description || '—' }}</span></td>
<td>{{ f.sizeText }}</td>
<td>{{ f.mandatory ? '强制' : '普通' }}</td>
<td><span class="table-status" :class="f.statusClass"><i></i>{{ f.statusName }}</span></td>
<td>{{ f.createdAt }}</td>
<td>
<div class="row-actions">
<button @click="openEdit(f)">编辑</button>
<button class="more-action" @click="remove(f)"><svg><use href="#i-trash" /></svg></button>
</div>
</td>
</tr>
</tbody>
</table>
<div v-if="!list.length" class="table-empty">暂无固件版本</div>
</div>
<div class="table-footer">
<span>共 {{ total }} 个固件版本 · 第 {{ pageNum }} / {{ pages }} 页</span>
<div>
<button @click="goPage(pageNum - 1)"><svg><use href="#i-chevron" /></svg></button>
<b>{{ pageNum }}</b>
<button @click="goPage(pageNum + 1)"><svg><use href="#i-chevron" /></svg></button>
</div>
</div>
</div>
<!-- Add / edit dialog -->
<div class="modal-backdrop" :class="{ open: modalVisible }">
<div class="modal add-dock-dialog" role="dialog" aria-modal="true">
<header>
<div><span>固件版本</span><h3>{{ editingId ? '编辑固件' : '新增固件' }}</h3></div>
<button class="icon-button" @click="modalVisible = false"><svg><use href="#i-x" /></svg></button>
</header>
<form novalidate @submit.prevent="submit">
<div class="form-row">
<label><span>版本号<b>*</b></span><input v-model="form.version" type="text" placeholder="例如 1.3.0" :disabled="!!editingId" /></label>
<label><span>组件</span><input v-model="form.component" type="text" placeholder="dock-edge-agent" /></label>
</div>
<label><span>版本描述</span><input v-model="form.description" type="text" placeholder="本次更新内容摘要" /></label>
<label><span>文件地址<b>*</b></span><input v-model="form.fileUrl" type="text" placeholder="https://.../firmware.bin" /></label>
<div class="form-row">
<label><span>SHA256<b>*</b></span><input v-model="form.sha256" type="text" placeholder="文件校验值" /></label>
<label><span>文件大小(MB)</span><input v-model="form.fileSizeMb" type="number" min="0" placeholder="0" /></label>
</div>
<label><span>签名</span><input v-model="form.signature" type="text" placeholder="固件签名" /></label>
<div class="form-row">
<label><span>升级方式</span>
<select v-model="form.mandatory">
<option :value="0">普通升级</option>
<option :value="1">强制升级</option>
</select>
</label>
<label><span>状态</span>
<select v-model="form.status">
<option value="draft">草稿</option>
<option value="released">已发布</option>
<option value="deprecated">已弃用</option>
</select>
</label>
</div>
<div class="form-error">{{ formError }}</div>
<footer>
<button class="plain-command" type="button" @click="modalVisible = false">取消</button>
<button class="primary-command" type="submit">{{ editingId ? '保存' : '确认新增' }}</button>
</footer>
</form>
</div>
</div>
</section>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { useUiStore } from '@/stores/modules/uiStore'
import request from '@/utils/http'
import * as urls from '@/config/urls'
const ui = useUiStore()
const list = ref([])
const total = ref(0)
const pageNum = ref(1)
const pageSize = ref(10)
const pages = ref(1)
const keyword = ref('')
const statusFilter = ref('')
const modalVisible = ref(false)
const editingId = ref(null)
const formError = ref('')
const form = reactive({ version: '', component: '', description: '', fileUrl: '', sha256: '', signature: '', fileSizeMb: '', mandatory: 0, status: 'draft' })
const STATUS = {
draft: { name: '草稿', cls: 'pending' },
released: { name: '已发布', cls: 'online' },
deprecated: { name: '已弃用', cls: 'offline' }
}
function fmtSize(b) {
if (!b) return '--'
const n = Number(b)
if (n >= 1024 * 1024 * 1024) return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB'
if (n >= 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB'
if (n >= 1024) return (n / 1024).toFixed(0) + ' KB'
return n + ' B'
}
function fmtTime(t) {
if (!t) return '--'
const d = new Date(t)
if (Number.isNaN(d.getTime())) return '--'
return d.toLocaleString('zh-CN', { hour12: false })
}
async function load() {
try {
const params = { pageNum: pageNum.value, pageSize: pageSize.value }
if (keyword.value) params.keyword = keyword.value
if (statusFilter.value) params.status = statusFilter.value
const page = await request.get(urls.FIRMWARES, { params })
total.value = page?.total || 0
pages.value = page?.pages || 1
list.value = (page?.records || []).map((f) => ({
id: String(f.id),
version: f.version,
component: f.component,
description: f.description || '',
sizeText: fmtSize(f.fileSize),
mandatory: Number(f.mandatory) === 1,
status: f.status,
statusName: STATUS[f.status]?.name || f.status,
statusClass: STATUS[f.status]?.cls || 'offline',
createdAt: fmtTime(f.createdAt),
raw: f
}))
} catch (e) {
ui.toast(e.message || '加载固件列表失败')
}
}
function reload() {
pageNum.value = 1
load()
}
function goPage(p) {
if (p < 1 || p > pages.value) return
pageNum.value = p
load()
}
function openAdd() {
Object.assign(form, { version: '', component: '', description: '', fileUrl: '', sha256: '', signature: '', fileSizeMb: '', mandatory: 0, status: 'draft' })
editingId.value = null
formError.value = ''
modalVisible.value = true
}
function openEdit(f) {
const r = f.raw
Object.assign(form, {
version: r.version,
component: r.component || '',
description: r.description || '',
fileUrl: r.fileUrl || '',
sha256: r.sha256 || '',
signature: r.signature || '',
fileSizeMb: r.fileSize ? (r.fileSize / 1024 / 1024).toFixed(1) : '',
mandatory: Number(r.mandatory) === 1 ? 1 : 0,
status: r.status || 'draft'
})
editingId.value = f.id
formError.value = ''
modalVisible.value = true
}
async function submit() {
formError.value = ''
if (!form.version || !form.fileUrl || !form.sha256) {
formError.value = '请填写版本号、文件地址和 SHA256。'
return
}
const payload = {
version: form.version,
component: form.component || 'dock-edge-agent',
description: form.description,
fileUrl: form.fileUrl,
sha256: form.sha256,
signature: form.signature || '',
fileSize: Math.round(Number(form.fileSizeMb || 0) * 1024 * 1024),
mandatory: Number(form.mandatory) === 1 ? 1 : 0,
status: form.status || 'draft'
}
try {
if (editingId.value) {
payload.status = form.status
await request.put(urls.FIRMWARE(editingId.value), payload)
ui.toast('固件已更新')
} else {
await request.post(urls.FIRMWARES, payload)
ui.toast(form.status === 'released' ? '固件已新增并发布' : '固件已新增(草稿状态)')
}
modalVisible.value = false
await load()
} catch (e) {
formError.value = e.message || '保存失败'
}
}
async function remove(f) {
const ok = await ui.confirm(`确认删除固件版本 ${f.version}`, '删除后不可恢复,已绑定该版本的设备不受影响。')
if (!ok) return
try {
await request.delete(urls.FIRMWARE(f.id))
ui.toast('固件已删除')
await load()
} catch (e) {
ui.toast(e.message || '删除失败')
}
}
onMounted(load)
</script>