This commit is contained in:
2026-09-20 14:22:12 +08:00
parent 1305753b71
commit 60a41af3ac
10 changed files with 414 additions and 3 deletions
@@ -0,0 +1,66 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export type PdaApp = 'domestic' | 'overseas'
export type PdaPackageType = 'apk' | 'wgt'
export interface PdaRelease {
id: string
app: PdaApp
appid: string
packageType: PdaPackageType
versionName: string
versionCode: number
minAppVersionCode: number
mandatory: boolean
releaseNotes: string
status: 'DRAFT' | 'PUBLISHED' | 'WITHDRAWN'
current: boolean
sha256: string
sizeBytes: number
downloadUrl: string | null
createdAt: string
publishedAt: string | null
}
const basePath = '/platform/v1/files/pda-releases'
export async function listPdaReleases(app: PdaApp) {
const response = await http.get<ApiEnvelope<PdaRelease[]>, ApiEnvelope<PdaRelease[]>>(`${basePath}/${app}`)
return unwrapApiResponse(response)
}
export async function uploadPdaRelease(app: PdaApp, data: {
packageType: PdaPackageType
versionName: string
versionCode: number
minAppVersionCode: number
mandatory: boolean
releaseNotes: string
file: File
}) {
const form = new FormData()
form.append('packageType', data.packageType)
form.append('versionName', data.versionName)
form.append('versionCode', String(data.versionCode))
form.append('minAppVersionCode', String(data.minAppVersionCode))
form.append('mandatory', String(data.mandatory))
form.append('releaseNotes', data.releaseNotes)
form.append('file', data.file)
const response = await http.post<ApiEnvelope<PdaRelease>, ApiEnvelope<PdaRelease>>(`${basePath}/${app}`, form, {
timeout: 10 * 60 * 1000,
})
return unwrapApiResponse(response)
}
export async function publishPdaRelease(id: string) {
const response = await http.post<ApiEnvelope<PdaRelease>, ApiEnvelope<PdaRelease>>(
`${basePath}/${encodeURIComponent(id)}/publish`, {},
)
return unwrapApiResponse(response)
}
export async function withdrawPdaRelease(id: string) {
const response = await http.post<ApiEnvelope<PdaRelease>, ApiEnvelope<PdaRelease>>(
`${basePath}/${encodeURIComponent(id)}/withdraw`, {},
)
return unwrapApiResponse(response)
}
@@ -0,0 +1,192 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Download, Plus, RefreshCw } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorMessage } from '@/shared/api/http'
import {
listPdaReleases, publishPdaRelease, uploadPdaRelease, withdrawPdaRelease,
type PdaApp, type PdaPackageType, type PdaRelease,
} from '../api/pdaReleases'
const access = useAccessPreviewStore()
const canManage = computed(() => access.can('platform:pda-release:manage'))
const app = ref<PdaApp>('domestic')
const rows = ref<PdaRelease[]>([])
const loading = ref(false)
const error = ref('')
const actionId = ref('')
const uploadOpen = ref(false)
const uploading = ref(false)
const form = reactive({ packageType: 'wgt' as PdaPackageType, versionName: '', versionCode: 1,
minAppVersionCode: 1, mandatory: false, releaseNotes: '', file: null as File | null })
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 215, fixed: 'left', slotName: 'actions' },
{ key: 'app', label: '应用', width: 110, slotName: 'app' },
{ key: 'packageType', label: '包类型', width: 95, slotName: 'packageType' },
{ key: 'versionName', prop: 'versionName', label: '版本', width: 115 },
{ key: 'versionCode', prop: 'versionCode', label: 'APK版本码', width: 105 },
{ key: 'minAppVersionCode', prop: 'minAppVersionCode', label: '最低APK版本码', width: 135 },
{ key: 'status', label: '状态', width: 115, slotName: 'status' },
{ key: 'mandatory', label: '强制更新', width: 100, slotName: 'mandatory' },
{ key: 'releaseNotes', prop: 'releaseNotes', label: '更新说明', minWidth: 190, showOverflowTooltip: true },
{ key: 'sizeBytes', label: '大小', width: 100, slotName: 'sizeBytes' },
{ key: 'createdAt', label: '上传时间', width: 170, slotName: 'createdAt' },
{ key: 'publishedAt', label: '发布时间', width: 170, slotName: 'publishedAt' },
]
const currentApk = computed(() => rows.value.find((row) => row.current && row.packageType === 'apk'))
const currentWgt = computed(() => rows.value.find((row) => row.current && row.packageType === 'wgt'))
function asRelease(row: unknown) { return row as PdaRelease }
async function load() {
loading.value = true
error.value = ''
const requestedApp = app.value
try {
const result = await listPdaReleases(requestedApp)
if (app.value === requestedApp) rows.value = result
} catch (cause) {
if (app.value === requestedApp) error.value = getApiErrorMessage(cause, '版本列表加载失败')
} finally {
if (app.value === requestedApp) loading.value = false
}
}
watch(app, () => { rows.value = []; void load() })
onMounted(() => { void load() })
function openUpload() {
Object.assign(form, { packageType: 'wgt', versionName: '', versionCode: currentApk.value?.versionCode ?? 1,
minAppVersionCode: currentApk.value?.versionCode ?? 1, mandatory: false, releaseNotes: '', file: null })
uploadOpen.value = true
}
function selectFile(event: Event) {
form.file = (event.target as HTMLInputElement).files?.[0] ?? null
}
async function upload() {
if (!form.file || !/^\d+(\.\d+)+$/.test(form.versionName)
|| form.versionCode < 1 || form.minAppVersionCode < 1
|| (form.packageType === 'apk' && form.versionCode !== form.minAppVersionCode)) {
ElMessage.warning('请填写有效版本、最低APK版本码并选择对应安装包')
return
}
uploading.value = true
try {
await uploadPdaRelease(app.value, { ...form, file: form.file })
uploadOpen.value = false
ElMessage.success('版本草稿已上传,请核对后发布')
await load()
} catch (cause) {
ElMessage.error(getApiErrorMessage(cause, '更新包上传失败'))
} finally { uploading.value = false }
}
async function changeStatus(row: PdaRelease, action: 'publish' | 'withdraw') {
const label = action === 'publish' ? '发布' : '停发'
try {
await ElMessageBox.confirm(`确认${label}${row.app === 'domestic' ? '国内' : '海外'}PDA ${row.packageType.toUpperCase()} ${row.versionName}?${action === 'publish' ? '发布后设备将收到更新。' : '停发后设备不再获取这个版本。'}`,
`${label}PDA版本`, { type: 'warning', confirmButtonText: `确认${label}`, cancelButtonText: '取消', customClass: 'admin-density-confirm' })
} catch { return }
actionId.value = row.id
try {
if (action === 'publish') await publishPdaRelease(row.id)
else await withdrawPdaRelease(row.id)
ElMessage.success(`版本已${label}`)
await load()
} catch (cause) {
ElMessage.error(getApiErrorMessage(cause, `${label}失败,请刷新后重试`))
} finally { actionId.value = '' }
}
function download(row: PdaRelease) {
if (!row.downloadUrl) return
const link = document.createElement('a')
link.href = row.downloadUrl
link.rel = 'noopener noreferrer'
link.download = `${row.app}-pda-${row.versionName}.${row.packageType}`
document.body.appendChild(link)
link.click()
link.remove()
}
</script>
<template>
<AdminPage class="admin-density pda-releases-page" spacing="compact">
<ListPageLayout aria-label="PDA版本">
<template #toolbar>
<div class="pda-release-toolbar">
<div>
<h1>PDA版本</h1>
<el-radio-group v-model="app" aria-label="选择PDA应用">
<el-radio-button value="domestic">国内 PDA</el-radio-button>
<el-radio-button value="overseas">海外 PDA</el-radio-button>
</el-radio-group>
</div>
<div class="pda-release-commands">
<el-button :loading="loading" @click="load"><RefreshCw :size="16" />刷新</el-button>
<el-button v-if="canManage" type="primary" @click="openUpload"><Plus :size="16" />上传版本</el-button>
</div>
</div>
</template>
<template #metrics>
<div class="pda-release-current">
<span>当前 APK:{{ currentApk ? `${currentApk.versionName} (${currentApk.versionCode})` : '尚未发布' }}</span>
<span>当前 WGT:{{ currentWgt ? `${currentWgt.versionName} · 最低 APK ${currentWgt.minAppVersionCode}` : '尚未发布' }}</span>
</div>
</template>
<template #state>
<el-alert v-if="error" type="error" :title="error" show-icon :closable="false" />
</template>
<DataTable :data="rows" :columns="columns" :loading="loading" row-key="id" border stripe empty-text="暂无版本记录">
<template #actions="{ row }">
<div class="pda-release-actions">
<el-button v-if="row.packageType === 'apk' && row.downloadUrl" link @click="download(asRelease(row))"><Download :size="15" />下载 APK</el-button>
<el-button v-if="canManage && row.status === 'DRAFT'" link :loading="actionId === row.id" @click="changeStatus(asRelease(row), 'publish')">发布</el-button>
<el-button v-if="canManage && row.current" link type="danger" :loading="actionId === row.id" @click="changeStatus(asRelease(row), 'withdraw')">停发</el-button>
</div>
</template>
<template #app="{ row }">{{ row.app === 'domestic' ? '国内' : '海外' }}</template>
<template #packageType="{ row }">{{ row.packageType.toUpperCase() }}</template>
<template #status="{ row }"><el-tag :type="row.current ? 'success' : row.status === 'DRAFT' ? 'info' : 'warning'" size="small">{{ row.current ? '当前发布' : row.status === 'DRAFT' ? '草稿' : row.status === 'WITHDRAWN' ? '已停发' : '历史发布' }}</el-tag></template>
<template #mandatory="{ row }">{{ row.mandatory ? '是' : '否' }}</template>
<template #sizeBytes="{ row }">{{ (row.sizeBytes / 1024 / 1024).toFixed(1) }} MB</template>
<template #createdAt="{ row }">{{ row.createdAt?.replace('T', ' ').slice(0, 19) || '—' }}</template>
<template #publishedAt="{ row }">{{ row.publishedAt?.replace('T', ' ').slice(0, 19) || '—' }}</template>
</DataTable>
</ListPageLayout>
<AppDialog v-model="uploadOpen" class="admin-density-dialog" title="上传PDA版本" width="min(640px, calc(100vw - 28px))" confirm-text="保存草稿" :confirm-loading="uploading" @confirm="upload">
<el-form label-position="top" class="pda-release-form">
<el-form-item label="包类型">
<el-radio-group v-model="form.packageType"><el-radio-button value="apk">APK 整包</el-radio-button><el-radio-button value="wgt">WGT 热更新</el-radio-button></el-radio-group>
</el-form-item>
<div class="pda-release-fields">
<el-form-item label="版本号"><el-input v-model.trim="form.versionName" placeholder="例如 0.0.4" /></el-form-item>
<el-form-item label="APK版本码"><el-input-number v-model="form.versionCode" :min="1" :controls="false" /></el-form-item>
<el-form-item label="最低兼容APK版本码"><el-input-number v-model="form.minAppVersionCode" :min="1" :controls="false" /></el-form-item>
<el-form-item label="强制更新"><el-switch v-model="form.mandatory" /></el-form-item>
</div>
<el-form-item label="更新说明"><el-input v-model="form.releaseNotes" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
<el-form-item :label="`选择 ${form.packageType.toUpperCase()} 文件(最大200MB)`"><input :key="form.packageType" type="file" :accept="`.${form.packageType}`" @change="selectFile" /></el-form-item>
</el-form>
</AppDialog>
</AdminPage>
</template>
<style scoped>
.pda-release-toolbar { display: flex; align-items: end; justify-content: space-between; gap: 16px; }
.pda-release-toolbar h1 { margin: 0 0 10px; font-size: var(--admin-font-title, 16px); }
.pda-release-commands, .pda-release-actions { display: flex; align-items: center; gap: 8px; }
.pda-release-commands :deep(.el-button), .pda-release-actions :deep(.el-button) { display: inline-flex; align-items: center; gap: 5px; }
.pda-release-current { display: flex; gap: 24px; padding: 10px 0; border-bottom: 1px solid var(--cc-border); }
.pda-release-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; }
.pda-release-form :deep(.el-input-number) { width: 100%; }
</style>
+10
View File
@@ -0,0 +1,10 @@
import type { RouteRecordRaw } from 'vue-router'
export const pdaReleaseRoutes = [
{
path: 'system/pda-releases',
name: 'pda-releases',
component: () => import('@/modules/pda-releases/pages/PdaReleasesView.vue'),
meta: { title: 'PDA版本', description: '国内与海外PDA安装包下载和热更新发布', permission: 'platform:pda-release:list' },
},
] satisfies RouteRecordRaw[]