Files
Academic-Affairs-System/web/src/components/AppUpdateManagementPanel.vue
T
2026-07-29 10:34:33 +08:00

459 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import type { UploadFile, UploadFiles } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh, UploadFilled } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
type UpdatePlatform = 'Android' | 'Ios'
type UpdateChannel = 'Production' | 'Staging'
type UpdateStatus = 'Draft' | 'Published' | 'Archived'
interface AppUpdateRelease {
id: string
platform: UpdatePlatform
channel: UpdateChannel
version: string
nativeVersion: string
status: UpdateStatus
releaseNotes?: string
fileName: string
fileSize: number
sha256: string
createdByUserName: string
createdAt: string
publishedByUserName?: string
publishedAt?: string
}
interface PageResult<T> {
items: T[]
total: number
page: number
pageSize: number
}
const loading = ref(false)
const uploadBusy = ref(false)
const rows = ref<AppUpdateRelease[]>([])
const total = ref(0)
const uploadVisible = ref(false)
const selectedBundle = ref<File>()
const uploadFiles = ref<UploadFiles>([])
const filter = reactive({
page: 1,
pageSize: 20,
platform: '',
channel: '',
})
const form = reactive({
platform: 'Android' as UpdatePlatform,
channel: 'Production' as UpdateChannel,
version: '',
nativeVersion: '1.0',
releaseNotes: '',
})
function formatTime(value?: string) {
if (!value) return '—'
return new Date(value).toLocaleString('zh-CN', { hour12: false })
}
function formatBytes(value: number) {
if (value < 1024) return `${value} B`
if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KB`
return `${(value / 1024 ** 2).toFixed(1)} MB`
}
function platformLabel(value: UpdatePlatform) {
return value === 'Android' ? 'Android' : 'iOS'
}
function channelLabel(value: UpdateChannel) {
return value === 'Production' ? '正式' : '测试'
}
function statusLabel(value: UpdateStatus) {
return value === 'Published'
? '已发布'
: value === 'Archived'
? '已归档'
: '草稿'
}
function statusType(value: UpdateStatus) {
return value === 'Published'
? 'success'
: value === 'Archived'
? 'info'
: 'warning'
}
async function loadReleases() {
loading.value = true
try {
const params: Record<string, string | number> = {
page: filter.page,
pageSize: filter.pageSize,
}
if (filter.platform) params.platform = filter.platform
if (filter.channel) params.channel = filter.channel
const { data } = await http.get<PageResult<AppUpdateRelease>>(
'/app-updates/releases',
{ params },
)
rows.value = data.items
total.value = data.total
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
function search() {
filter.page = 1
loadReleases()
}
function openUpload() {
Object.assign(form, {
platform: 'Android',
channel: 'Production',
version: '',
nativeVersion: '1.0',
releaseNotes: '',
})
selectedBundle.value = undefined
uploadFiles.value = []
uploadVisible.value = true
}
function handleBundleChange(file: UploadFile, files: UploadFiles) {
uploadFiles.value = files.slice(-1)
selectedBundle.value = file.raw
}
function handleBundleRemove() {
selectedBundle.value = undefined
uploadFiles.value = []
}
async function uploadRelease() {
if (!selectedBundle.value) {
ElMessage.warning('请选择更新 ZIP。')
return
}
if (!form.version.trim() || !form.nativeVersion.trim()) {
ElMessage.warning('请填写热更新版本和兼容的原生版本。')
return
}
const body = new FormData()
body.append('bundle', selectedBundle.value)
body.append('platform', form.platform)
body.append('channel', form.channel)
body.append('version', form.version.trim())
body.append('nativeVersion', form.nativeVersion.trim())
body.append('releaseNotes', form.releaseNotes.trim())
uploadBusy.value = true
try {
await http.post('/app-updates/releases', body, {
timeout: 10 * 60 * 1000,
})
ElMessage.success('更新包已上传为草稿,确认无误后再发布。')
uploadVisible.value = false
await loadReleases()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
uploadBusy.value = false
}
}
async function publishRelease(tableRow: Record<string, unknown>) {
const row = tableRow as unknown as AppUpdateRelease
const isRollback = row.status === 'Archived'
try {
await ElMessageBox.confirm(
isRollback
? `将把 ${platformLabel(row.platform)} ${row.nativeVersion} 的正式通道回滚到 ${row.version}。新启动的 App 将下载此版本。`
: `确认发布 ${row.version}?同平台、通道和原生版本下当前生效的更新将自动归档。`,
isRollback ? '确认版本回滚' : '确认发布更新',
{
type: isRollback ? 'warning' : 'info',
confirmButtonText: isRollback ? '确认回滚' : '发布',
cancelButtonText: '取消',
},
)
await http.post(`/app-updates/releases/${row.id}/publish`)
ElMessage.success(isRollback ? '已回滚到指定版本。' : '更新已发布。')
await loadReleases()
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(apiErrorMessage(error))
}
}
async function deleteRelease(tableRow: Record<string, unknown>) {
const row = tableRow as unknown as AppUpdateRelease
try {
await ElMessageBox.confirm(
`将永久删除更新包 ${row.fileName}。此操作不会影响已安装到设备的版本。`,
'删除更新包',
{
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
},
)
await http.delete(`/app-updates/releases/${row.id}`)
ElMessage.success('更新包已删除。')
await loadReleases()
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(apiErrorMessage(error))
}
}
onMounted(loadReleases)
</script>
<template>
<section class="update-panel">
<header class="update-heading">
<div>
<span>SELF-HOSTED OTA</span>
<h3>App 前端热更新</h3>
<p>上传构建后的 ZIP,按原生版本和通道发布;保留历史包用于快速回滚。</p>
</div>
<div class="heading-actions">
<el-button :icon="Refresh" :loading="loading" @click="loadReleases">
刷新
</el-button>
<el-button type="primary" :icon="UploadFilled" @click="openUpload">
上传更新包
</el-button>
</div>
</header>
<div class="update-rules">
<strong>发布边界</strong>
<span>仅限 HTML、JavaScript、CSS 和静态资源</span>
<span>新增原生插件、权限或 Android/iOS 代码必须重新发版</span>
<span>更新失败时 App 自动回退到上一个可用版本</span>
</div>
<div class="update-filter">
<el-select v-model="filter.platform" clearable placeholder="全部平台">
<el-option label="Android" value="Android" />
<el-option label="iOS" value="Ios" />
</el-select>
<el-select v-model="filter.channel" clearable placeholder="全部通道">
<el-option label="正式通道" value="Production" />
<el-option label="测试通道" value="Staging" />
</el-select>
<el-button type="primary" @click="search">查询</el-button>
</div>
<el-table v-loading="loading" :data="rows" stripe>
<el-table-column label="版本" min-width="142">
<template #default="{ row }">
<strong class="version">{{ row.version }}</strong>
<small>原生 {{ row.nativeVersion }}</small>
</template>
</el-table-column>
<el-table-column label="目标" width="132">
<template #default="{ row }">
{{ platformLabel(row.platform) }} · {{ channelLabel(row.channel) }}
</template>
</el-table-column>
<el-table-column label="状态" width="94">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" effect="plain">
{{ statusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="更新说明" min-width="210" show-overflow-tooltip>
<template #default="{ row }">{{ row.releaseNotes || '—' }}</template>
</el-table-column>
<el-table-column label="更新包" min-width="205" show-overflow-tooltip>
<template #default="{ row }">
<span class="file-name">{{ row.fileName }}</span>
<small>{{ formatBytes(row.fileSize) }} · {{ row.sha256.slice(0, 12) }}…</small>
</template>
</el-table-column>
<el-table-column label="时间" width="168">
<template #default="{ row }">
{{ formatTime(row.publishedAt || row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="152">
<template #default="{ row }">
<el-button
v-if="row.status !== 'Published'"
link
type="primary"
@click="publishRelease(row)"
>
{{ row.status === 'Archived' ? '回滚至此版本' : '发布' }}
</el-button>
<el-button
v-if="row.status !== 'Published'"
link
type="danger"
@click="deleteRelease(row)"
>
删除
</el-button>
<span v-else class="active-label">当前生效</span>
</template>
</el-table-column>
<template #empty>
<span>尚未上传 App 热更新包。</span>
</template>
</el-table>
<el-pagination
v-model:current-page="filter.page"
v-model:page-size="filter.pageSize"
background
layout="total, prev, pager, next"
:total="total"
@current-change="loadReleases"
/>
<el-dialog
v-model="uploadVisible"
title="上传 App 热更新包"
width="min(620px, 92vw)"
destroy-on-close
>
<el-form label-position="top">
<div class="form-grid">
<el-form-item label="平台">
<el-select v-model="form.platform">
<el-option label="Android" value="Android" />
<el-option label="iOS" value="Ios" />
</el-select>
</el-form-item>
<el-form-item label="通道">
<el-select v-model="form.channel">
<el-option label="正式通道" value="Production" />
<el-option label="测试通道" value="Staging" />
</el-select>
</el-form-item>
<el-form-item label="热更新版本">
<el-input v-model="form.version" placeholder="例如 1.0.1" />
</el-form-item>
<el-form-item label="兼容的原生版本">
<el-input v-model="form.nativeVersion" placeholder="当前 Android 为 1.0" />
</el-form-item>
</div>
<el-form-item label="更新说明">
<el-input
v-model="form.releaseNotes"
type="textarea"
:rows="3"
maxlength="1000"
show-word-limit
placeholder="说明本次修复内容,不要包含敏感信息。"
/>
</el-form-item>
<el-form-item label="更新 ZIP">
<el-upload
v-model:file-list="uploadFiles"
accept=".zip,application/zip"
:auto-upload="false"
:limit="1"
:on-change="handleBundleChange"
:on-remove="handleBundleRemove"
>
<el-button :icon="UploadFilled">选择 ZIP</el-button>
<template #tip>
<div class="el-upload__tip">
最大 30 MB,ZIP 根目录必须直接包含 index.html。
</div>
</template>
</el-upload>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="uploadVisible = false">取消</el-button>
<el-button type="primary" :loading="uploadBusy" @click="uploadRelease">
上传为草稿
</el-button>
</template>
</el-dialog>
</section>
</template>
<style scoped>
.update-panel {
background: #fff;
border: 1px solid #d8dee1;
min-width: 0;
}
.update-heading {
align-items: center;
border-bottom: 1px solid #d8dee1;
display: flex;
justify-content: space-between;
padding: 16px 18px;
}
.update-heading span {
color: #74818a;
font-family: "Cascadia Mono", Consolas, monospace;
font-size: 10px;
letter-spacing: .09em;
}
.update-heading h3 { font-size: 17px; margin: 3px 0 4px; }
.update-heading p { color: #66747e; font-size: 12px; margin: 0; }
.heading-actions { display: flex; gap: 8px; }
.update-rules {
align-items: center;
background: #f5f8f7;
border-bottom: 1px solid #e0e6e5;
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
padding: 10px 18px;
}
.update-rules strong { color: #1f7468; font-size: 11px; }
.update-rules span { color: #66747e; font-size: 11px; }
.update-rules span::before { content: "·"; margin-right: 8px; }
.update-filter {
display: flex;
gap: 9px;
padding: 14px 18px 0;
}
.update-filter .el-select { width: 160px; }
.version,
.file-name { display: block; font-family: "Cascadia Mono", Consolas, monospace; }
.version { color: #1f7468; font-size: 13px; }
.file-name { font-size: 11px; }
small { color: #74818a; display: block; font-size: 10px; margin-top: 4px; }
.active-label { color: #1f7468; font-size: 11px; font-weight: 700; }
.el-table { margin-top: 12px; }
.el-pagination { justify-content: flex-end; padding: 16px 18px; }
.form-grid { display: grid; gap: 0 14px; grid-template-columns: 1fr 1fr; }
.form-grid .el-select { width: 100%; }
@media (max-width: 720px) {
.update-heading { align-items: flex-start; display: block; }
.heading-actions { margin-top: 12px; }
.update-filter { flex-wrap: wrap; }
.update-filter .el-select { flex: 1 1 140px; }
.form-grid { display: block; }
}
</style>