Files
admin-web/src/modules/pda-releases/pages/PdaReleasesView.vue
T
2026-11-03 17:15:03 +08:00

212 lines
13 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 '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { FileUp, 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 fileInput = ref<HTMLInputElement | null>(null)
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: 'status', label: '状态', width: 115, slotName: 'status', afterColumn: 'versionName' },
{ key: 'versionCode', prop: 'versionCode', label: 'APK版本码', width: 105 },
{ key: 'minAppVersionCode', prop: 'minAppVersionCode', label: '最低APK版本码', width: 135 },
{ 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() })
watch(() => form.packageType, () => { form.file = null })
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 })
if (fileInput.value) fileInput.value.value = ''
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 admin-workbench' })
} 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 admin-workbench">
<ListPageLayout aria-label="PDA版本">
<div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" preference-key="pda-releases-table" :data="rows" :columns="columns" :loading="loading" :aria-busy="loading" row-key="id" border empty-text="暂无版本记录">
<template #toolbar-start>
<div class="pda-release-context query-toolbar workbench-list-tabs">
<el-radio-group v-model="app" size="small" aria-label="选择PDA应用">
<el-radio-button value="domestic">国内 PDA</el-radio-button>
<el-radio-button value="overseas">海外 PDA</el-radio-button>
</el-radio-group>
<dl class="pda-release-current">
<div><dt>当前 APK</dt><dd>{{ currentApk ? `${currentApk.versionName} (${currentApk.versionCode})` : '尚未发布' }}</dd></div>
<div><dt>当前 WGT</dt><dd>{{ currentWgt ? `${currentWgt.versionName} · 最低 APK ${currentWgt.minAppVersionCode}` : '尚未发布' }}</dd></div>
</dl>
</div>
</template>
<template #notices><el-alert v-if="error" class="pda-release-error" type="error" :title="error" show-icon :closable="false" /></template>
<template #toolbar-actions>
<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>
</template>
<template #actions="{ row }">
<div class="pda-release-actions density-actions row-actions admin-text-actions">
<el-button v-if="row.packageType === 'apk' && row.downloadUrl" class="density-action" data-tone="neutral" @click="download(asRelease(row))">下载 APK</el-button>
<el-button v-if="canManage && row.status === 'DRAFT'" class="density-action" data-tone="primary" :loading="actionId === row.id" @click="changeStatus(asRelease(row), 'publish')">发布</el-button>
<el-button v-if="canManage && row.current" class="density-action" data-tone="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></div>
</ListPageLayout>
<AppDialog v-model="uploadOpen" class="admin-density-dialog admin-workbench is-dialog" :title="`上传${app === 'overseas' ? '海外' : '国内'}PDA版本`" width="min(640px, calc(100vw - 28px))" confirm-text="保存草稿" :confirm-loading="uploading" @confirm="upload">
<el-form label-position="top" class="pda-release-form density-form density-form--outlined density-form--flush-end" :disabled="uploading">
<div class="pda-release-options">
<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>
<el-form-item label="强制更新"><el-switch v-model="form.mandatory" /></el-form-item>
</div>
<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" align="left" placeholder="版本码" /></el-form-item>
<el-form-item label="最低兼容APK版本码"><el-input-number v-model="form.minAppVersionCode" :min="1" :controls="false" align="left" placeholder="最低版本码" /></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="更新包(最大200MB)">
<input :key="form.packageType" ref="fileInput" class="pda-release-file-input" type="file" :accept="`.${form.packageType}`" :aria-label="`选择 ${form.packageType.toUpperCase()} 更新包`" tabindex="-1" :disabled="uploading" @change="selectFile" />
<div class="pda-release-file-control">
<el-button :icon="FileUp" :disabled="uploading" @click="fileInput?.click()">选择 {{ form.packageType.toUpperCase() }} 文件</el-button>
<div class="pda-release-file-meta"><span class="pda-release-file-name">{{ form.file?.name || '未选择文件' }}</span><span v-if="form.file" class="pda-release-file-size">{{ (form.file.size / 1024 / 1024).toFixed(2) }} MB</span></div>
</div>
</el-form-item>
</el-form>
</AppDialog>
</AdminPage>
</template>
<style scoped>
.pda-release-panel { min-width: 0; overflow: hidden; }
.pda-release-error { border: 0; border-bottom: 1px solid var(--el-color-danger-light-7); border-radius: 0; }
.pda-release-context { display: flex; min-width: 0; align-items: center; gap: var(--admin-space-6); }
.pda-release-current { display: flex; min-width: 0; flex-wrap: wrap; gap: var(--admin-space-3) var(--admin-space-6); margin: 0; }
.pda-release-current > div { display: flex; min-width: 0; align-items: baseline; gap: var(--admin-space-2); }
.pda-release-current dt { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
.pda-release-current dd { margin: 0; color: var(--workbench-text); font-size: var(--admin-font-body); font-variant-numeric: tabular-nums; }
.pda-release-actions { display: flex; align-items: center; gap: var(--admin-space-2); }
.pda-release-actions :deep(.el-button) { display: inline-flex; align-items: center; gap: 5px; }
.pda-release-options { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); gap: 0 var(--admin-space-6); }
.pda-release-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 var(--admin-space-6); }
.pda-release-options > *, .pda-release-fields > * { min-width: 0; }
.pda-release-file-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.pda-release-file-control { display: flex; width: 100%; min-width: 0; align-items: flex-start; gap: var(--admin-space-5); }
.pda-release-file-control > :deep(.el-button) { flex-shrink: 0; }
.pda-release-file-meta { flex: 1; min-width: 0; line-height: 1.6; }
.pda-release-file-name { display: block; color: var(--cc-text); overflow-wrap: anywhere; }
.pda-release-file-size { display: block; color: var(--cc-muted); font-size: var(--admin-font-caption); }
@media (max-width: 900px) { .pda-release-context { align-items: flex-start; flex-direction: column; gap: var(--admin-space-3); } }
</style>