212 lines
13 KiB
Vue
212 lines
13 KiB
Vue
<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>
|