459 lines
14 KiB
Vue
459 lines
14 KiB
Vue
<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>
|