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.
 
 
 
 

302 lines
9.8 KiB

<template>
<section class="operation-page">
<header class="operation-page-header">
<div>
<h1>固件管理</h1>
<span>维护机巢固件版本发布后供用户对设备升级</span>
</div>
<div class="page-actions">
<t-button shape="square" variant="outline" title="刷新" @click="load"><svg><use href="#i-refresh" /></svg></t-button>
<t-button theme="primary" @click="openAdd"><svg><use href="#i-plus" /></svg>新增固件</t-button>
</div>
</header>
<div class="business-workspace">
<div class="asset-toolbar">
<div class="asset-search">
<t-input v-model="keyword" clearable placeholder="搜索版本号 / 描述" @keyup.enter="reload" @clear="reload">
<template #prefix-icon><t-icon name="search" /></template>
</t-input>
</div>
<div class="toolbar-group">
<t-select
v-model="statusFilter"
:options="statusFilterOptions"
placeholder="全部状态"
style="width: 140px"
@change="reload"
/>
</div>
</div>
<div class="asset-table-wrap">
<t-table
row-key="id"
:data="list"
:columns="columns"
empty="暂无固件版本"
>
<template #version="{ row }">
<strong>{{ row.version }}</strong>
</template>
<template #description="{ row }">
<span :title="row.description">{{ row.description || '—' }}</span>
</template>
<template #mandatory="{ row }">
{{ row.mandatory ? '强制' : '普通' }}
</template>
<template #status="{ row }">
<span class="table-status" :class="row.statusClass"><i></i>{{ row.statusName }}</span>
</template>
<template #actions="{ row }">
<div class="row-actions">
<t-button variant="text" theme="primary" @click="openEdit(row)">编辑</t-button>
<t-button variant="text" theme="danger" shape="square" @click="remove(row)"><svg><use href="#i-trash" /></svg></t-button>
</div>
</template>
</t-table>
</div>
<div class="table-footer">
<t-pagination
v-model:current="pageNum"
:total="total"
:page-size="pageSize"
@current-change="load"
/>
</div>
</div>
<t-dialog
v-model:visible="modalVisible"
:header="editingId ? '编辑固件' : '新增固件'"
width="520px"
destroy-on-close
dialog-class-name="add-dock-dialog"
:close-on-overlay-click="false"
placement="center"
>
<form novalidate @submit.prevent="submit">
<div class="form-row">
<label><span>版本号<b>*</b></span><t-input v-model="form.version" placeholder="例如 1.3.0" :disabled="!!editingId" /></label>
<label><span>组件</span><t-input v-model="form.component" placeholder="dock-edge-agent" /></label>
</div>
<label><span>版本描述</span><t-input v-model="form.description" placeholder="本次更新内容摘要" /></label>
<label><span>文件地址<b>*</b></span><t-input v-model="form.fileUrl" placeholder="https://.../firmware.bin" /></label>
<div class="form-row">
<label><span>SHA256<b>*</b></span><t-input v-model="form.sha256" placeholder="文件校验值" /></label>
<label><span>文件大小(MB)</span><t-input-number v-model="form.fileSizeMb" theme="normal" :min="0" placeholder="0" style="width: 100%" /></label>
</div>
<label><span>签名</span><t-input v-model="form.signature" placeholder="固件签名" /></label>
<div class="form-row">
<label><span>升级方式</span>
<t-select v-model="form.mandatory" :options="mandatoryOptions" style="width: 100%" />
</label>
<label><span>状态</span>
<t-select v-model="form.status" :options="statusOptions" style="width: 100%" />
</label>
</div>
<div class="form-error">{{ formError }}</div>
</form>
<template #footer>
<t-button variant="outline" @click="modalVisible = false">取消</t-button>
<t-button theme="primary" @click="submit">{{ editingId ? '保存' : '确认新增' }}</t-button>
</template>
</t-dialog>
</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 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: undefined, mandatory: 0, status: 'draft' })
const statusFilterOptions = [
{ label: '全部状态', value: '' },
{ label: '草稿', value: 'draft' },
{ label: '已发布', value: 'released' },
{ label: '已弃用', value: 'deprecated' }
]
const mandatoryOptions = [
{ label: '普通升级', value: 0 },
{ label: '强制升级', value: 1 }
]
const statusOptions = [
{ label: '草稿', value: 'draft' },
{ label: '已发布', value: 'released' },
{ label: '已弃用', value: 'deprecated' }
]
const columns = [
{ colKey: 'version', title: '版本', width: '12%' },
{ colKey: 'component', title: '组件', width: '14%' },
{ colKey: 'description', title: '描述', width: '22%', ellipsis: true },
{ colKey: 'sizeText', title: '大小', width: '9%' },
{ colKey: 'mandatory', title: '升级方式', width: '8%' },
{ colKey: 'status', title: '状态', width: '9%' },
{ colKey: 'createdAt', title: '创建时间', width: '16%' },
{ colKey: 'actions', title: '操作', width: '10%' }
]
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
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 openAdd() {
Object.assign(form, { version: '', component: '', description: '', fileUrl: '', sha256: '', signature: '', fileSizeMb: undefined, 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 ? Number((r.fileSize / 1024 / 1024).toFixed(1)) : undefined,
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>
<style scoped>
.asset-search :deep(.t-input) {
width: 100%;
}
.asset-search {
border: 0;
padding: 0;
height: auto;
}
.toolbar-group :deep(.t-select) {
flex-shrink: 0;
}
</style>
<style>
.add-dock-dialog .t-input,
.add-dock-dialog .t-input-number,
.add-dock-dialog .t-select {
width: 100%;
}
</style>