fix(pda): display latest APK download QR codes for both apps
This commit is contained in:
@@ -1,8 +1,26 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, type PropType } from 'vue'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import ElementPlus, { ElMessageBox, ElRadioGroup } from 'element-plus'
|
||||
import QRCode from 'qrcode'
|
||||
import { pdaReleaseRoutes } from '../routes'
|
||||
import { adminNavigation } from '@/core/navigation/adminNavigation'
|
||||
import CopyButton from '@/shared/components/CopyButton.vue'
|
||||
import { copyText } from '@/shared/utils/copyText'
|
||||
import { listPdaReleases, publishPdaRelease, withdrawPdaRelease, type PdaRelease } from '../api/pdaReleases'
|
||||
import PdaReleasesView from './PdaReleasesView.vue'
|
||||
|
||||
const can = vi.hoisted(() => vi.fn(() => true))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
vi.mock('../api/pdaReleases', () => ({
|
||||
listPdaReleases: vi.fn(), publishPdaRelease: vi.fn(), uploadPdaRelease: vi.fn(), withdrawPdaRelease: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/shared/utils/copyText', () => ({ copyText: vi.fn() }))
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
const page = readFileSync(resolve(process.cwd(), 'src/modules/pda-releases/pages/PdaReleasesView.vue'), 'utf8')
|
||||
|
||||
@@ -18,7 +36,8 @@ describe('PDA release workbench', () => {
|
||||
it('provides separate apps, an APK download and manage-only upload/publish/withdraw controls', () => {
|
||||
expect(page).toContain('value="domestic"')
|
||||
expect(page).toContain('value="overseas"')
|
||||
expect(page).toContain('row.packageType === \'apk\' && row.downloadUrl')
|
||||
expect(page).toContain('aria-label="最新 APK 下载"')
|
||||
expect(page).not.toContain('@click="download(asRelease(row))"')
|
||||
expect(page).toContain('v-if="canManage"')
|
||||
expect(page).toContain('row.status === \'DRAFT\'')
|
||||
expect(page).toContain('row.current')
|
||||
@@ -35,10 +54,207 @@ describe('PDA release workbench', () => {
|
||||
const toolbar = page.match(/<template #toolbar-start>([\s\S]*?)<\/template>/)?.[1]
|
||||
expect(toolbar).toContain('class="pda-release-context query-toolbar workbench-list-tabs"')
|
||||
expect(toolbar).toContain('<el-radio-group v-model="app" size="small" aria-label="选择PDA应用">')
|
||||
expect(toolbar).toContain('当前 APK')
|
||||
expect(toolbar).toContain('当前 WGT')
|
||||
expect(page).not.toContain('<template #filters>')
|
||||
expect(page).not.toContain('<h1>PDA版本</h1>')
|
||||
expect(pdaReleaseRoutes[0]?.meta?.showPageHeading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
const apk: PdaRelease = {
|
||||
id: 'apk-current', app: 'domestic', appid: 'domestic-pda', packageType: 'apk',
|
||||
versionName: '0.1.0', versionCode: 10, minAppVersionCode: 10, mandatory: false,
|
||||
releaseNotes: '更新扫码', status: 'PUBLISHED', current: true, sha256: 'checksum', sizeBytes: 1024,
|
||||
downloadUrl: '/gateway-api/pda/domestic/current.apk', createdAt: '2026-11-06T14:00:00', publishedAt: '2026-11-06T14:10:00',
|
||||
}
|
||||
const apkUrl = new URL(apk.downloadUrl!, window.location.href).href
|
||||
const overseasApk: PdaRelease = { ...apk, id: 'overseas-current', app: 'overseas', appid: 'overseas-pda',
|
||||
downloadUrl: 'https://downloads.example.com/pda/overseas/current.apk' }
|
||||
|
||||
const TableStub = defineComponent({
|
||||
props: { data: { type: Array as PropType<PdaRelease[]>, default: () => [] } },
|
||||
template: `<div><slot name="toolbar-start" /><slot name="toolbar-actions" /><slot name="notices" />
|
||||
<div v-for="row in data" :key="row.id"><slot name="actions" :row="row" /></div></div>`,
|
||||
})
|
||||
|
||||
function renderPage() {
|
||||
return mount(PdaReleasesView, {
|
||||
global: { plugins: [ElementPlus], stubs: { DataTable: TableStub, AppDialog: true } },
|
||||
})
|
||||
}
|
||||
|
||||
describe('latest APK download', () => {
|
||||
beforeEach(() => {
|
||||
can.mockReturnValue(true)
|
||||
vi.mocked(listPdaReleases).mockReset().mockImplementation(async (targetApp) => targetApp === 'domestic' ? [apk] : [overseasApk])
|
||||
vi.mocked(publishPdaRelease).mockReset().mockResolvedValue(apk)
|
||||
vi.mocked(withdrawPdaRelease).mockReset().mockResolvedValue({ ...apk, current: false, status: 'WITHDRAWN' })
|
||||
vi.spyOn(QRCode, 'toString')
|
||||
})
|
||||
|
||||
it('encodes the current published APK as an absolute URL and shares it with read-only users', async () => {
|
||||
can.mockReturnValue(false)
|
||||
vi.mocked(listPdaReleases).mockResolvedValue([
|
||||
{ ...apk, id: 'apk-history', current: false, downloadUrl: '/old.apk' },
|
||||
{ ...apk, id: 'apk-draft', current: false, status: 'DRAFT', versionCode: 20, downloadUrl: '/draft.apk' },
|
||||
{ ...apk, id: 'wgt-current', packageType: 'wgt', versionCode: 30, downloadUrl: '/current.wgt' },
|
||||
overseasApk, apk,
|
||||
])
|
||||
const downloads: HTMLAnchorElement[] = []
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) { downloads.push(this) })
|
||||
const wrapper = renderPage()
|
||||
await flushPromises()
|
||||
|
||||
expect(QRCode.toString).toHaveBeenCalledTimes(2)
|
||||
expect(QRCode.toString).toHaveBeenCalledWith(apkUrl, expect.objectContaining({ type: 'svg', margin: 4, width: 160 }))
|
||||
expect(QRCode.toString).toHaveBeenCalledWith(overseasApk.downloadUrl, expect.objectContaining({ type: 'svg' }))
|
||||
const domestic = wrapper.get('[data-app="domestic"]')
|
||||
const overseas = wrapper.get('[data-app="overseas"]')
|
||||
expect(domestic.findAll('img')).toHaveLength(1)
|
||||
expect(overseas.findAll('img')).toHaveLength(1)
|
||||
expect(wrapper.get('.list-page-layout__toolbar .pda-apk-downloads').exists()).toBe(true)
|
||||
expect(wrapper.get('.workbench-table').find('.pda-apk-downloads').exists()).toBe(false)
|
||||
const image = domestic.get('.pda-apk-qr img')
|
||||
expect(image.attributes('alt')).toBe('国内 PDA最新APK下载二维码')
|
||||
expect(decodeURIComponent(image.attributes('src').split(',')[1]!)).toContain('<svg')
|
||||
expect(domestic.get('.pda-apk-download-url').attributes('href')).toBe(apkUrl)
|
||||
expect(domestic.findComponent(CopyButton).props('value')).toBe(apkUrl)
|
||||
await domestic.get('[aria-label="复制国内 PDA APK下载链接"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(copyText).toHaveBeenCalledWith(apkUrl)
|
||||
await domestic.findAll('button').find((button) => button.text() === '下载 APK')!.trigger('click')
|
||||
expect(downloads[0]?.href).toBe(apkUrl)
|
||||
expect(downloads[0]?.download).toBe('domestic-pda-0.1.0.apk')
|
||||
wrapper.findComponent(ElRadioGroup).vm.$emit('update:modelValue', 'overseas')
|
||||
await flushPromises()
|
||||
await overseas.findAll('button').find((button) => button.text() === '下载 APK')!.trigger('click')
|
||||
expect(downloads[1]?.href).toBe(overseasApk.downloadUrl)
|
||||
expect(downloads[1]?.download).toBe('overseas-pda-0.1.0.apk')
|
||||
expect(wrapper.text()).not.toContain('上传版本')
|
||||
})
|
||||
|
||||
it('offers no download for drafts, withdrawn APKs or WGT-only releases', async () => {
|
||||
vi.mocked(listPdaReleases).mockResolvedValue([
|
||||
{ ...apk, status: 'DRAFT', current: true },
|
||||
{ ...apk, id: 'withdrawn', status: 'WITHDRAWN', current: true },
|
||||
{ ...apk, id: 'wgt', packageType: 'wgt' },
|
||||
])
|
||||
const wrapper = renderPage()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('尚未发布 APK')
|
||||
expect(wrapper.find('.pda-apk-qr img').exists()).toBe(false)
|
||||
expect(wrapper.find('.pda-apk-qr').exists()).toBe(false)
|
||||
expect(wrapper.find('.pda-apk-download-url').exists()).toBe(false)
|
||||
expect(QRCode.toString).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows an unavailable state when a published APK has no download URL', async () => {
|
||||
vi.mocked(listPdaReleases).mockResolvedValue([{ ...apk, downloadUrl: null }])
|
||||
const wrapper = renderPage()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('当前 APK 下载地址暂不可用')
|
||||
expect(wrapper.find('.pda-apk-download-url').exists()).toBe(false)
|
||||
expect(QRCode.toString).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps both download cards independent of the selected list tab, including pending QR generation', async () => {
|
||||
let finishDomestic!: (svg: string) => void
|
||||
vi.mocked(QRCode.toString).mockImplementationOnce(() => new Promise<string>((resolve) => { finishDomestic = resolve }))
|
||||
const wrapper = renderPage()
|
||||
await flushPromises()
|
||||
const domestic = wrapper.get('[data-app="domestic"]')
|
||||
const overseas = wrapper.get('[data-app="overseas"]')
|
||||
const overseasImage = overseas.get('.pda-apk-qr img').attributes('src')
|
||||
wrapper.findComponent(ElRadioGroup).vm.$emit('update:modelValue', 'overseas')
|
||||
await flushPromises()
|
||||
|
||||
finishDomestic('<svg>domestic QR</svg>')
|
||||
await flushPromises()
|
||||
expect(overseas.get('.pda-apk-qr img').attributes('src')).toBe(overseasImage)
|
||||
expect(domestic.get('.pda-apk-qr img').attributes('alt')).toBe('国内 PDA最新APK下载二维码')
|
||||
expect(overseas.get('.pda-apk-qr img').attributes('alt')).toBe('海外 PDA最新APK下载二维码')
|
||||
expect(domestic.get('.pda-apk-download-url').attributes('href')).toBe(apkUrl)
|
||||
expect(overseas.get('.pda-apk-download-url').attributes('href')).toBe(overseasApk.downloadUrl)
|
||||
expect(wrapper.findComponent(TableStub).props('data')).toEqual([overseasApk])
|
||||
expect(listPdaReleases).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('ignores a previous QR generation after refreshing the latest APK', async () => {
|
||||
let finishOld!: (svg: string) => void
|
||||
vi.mocked(QRCode.toString).mockImplementationOnce(() => new Promise<string>((resolve) => { finishOld = resolve }))
|
||||
const newerApk = { ...apk, id: 'newer-apk', downloadUrl: '/newer.apk' }
|
||||
const wrapper = renderPage()
|
||||
await flushPromises()
|
||||
vi.mocked(listPdaReleases).mockImplementation(async (targetApp) => targetApp === 'domestic' ? [newerApk] : [overseasApk])
|
||||
await wrapper.findAll('button').find((button) => button.text() === '刷新')!.trigger('click')
|
||||
await flushPromises()
|
||||
const domestic = wrapper.get('[data-app="domestic"]')
|
||||
const newerImage = domestic.get('.pda-apk-qr img').attributes('src')
|
||||
finishOld('<svg>old QR</svg>')
|
||||
await flushPromises()
|
||||
|
||||
expect(domestic.get('.pda-apk-download-url').attributes('href')).toBe(new URL('/newer.apk', window.location.href).href)
|
||||
expect(domestic.get('.pda-apk-qr img').attributes('src')).toBe(newerImage)
|
||||
expect(listPdaReleases).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('shows the QR only after publishing and removes it on withdrawal without selecting history', async () => {
|
||||
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm')
|
||||
const domesticResponses: PdaRelease[][] = [
|
||||
[{ ...apk, status: 'DRAFT', current: false }], [apk],
|
||||
[{ ...apk, status: 'WITHDRAWN', current: false }, { ...apk, id: 'history', current: false }],
|
||||
]
|
||||
vi.mocked(listPdaReleases).mockImplementation(async (targetApp) => targetApp === 'domestic' ? domesticResponses.shift()! : [overseasApk])
|
||||
const wrapper = renderPage()
|
||||
await flushPromises()
|
||||
const domestic = wrapper.get('[data-app="domestic"]')
|
||||
const overseasImage = wrapper.get('[data-app="overseas"] img').attributes('src')
|
||||
expect(domestic.find('.pda-apk-qr img').exists()).toBe(false)
|
||||
|
||||
await wrapper.findAll('button').find((button) => button.text() === '发布')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(publishPdaRelease).toHaveBeenCalledWith(apk.id)
|
||||
expect(domestic.find('.pda-apk-qr img').exists()).toBe(true)
|
||||
|
||||
await wrapper.findAll('button').find((button) => button.text() === '停发')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(withdrawPdaRelease).toHaveBeenCalledWith(apk.id)
|
||||
expect(domestic.find('.pda-apk-qr').exists()).toBe(false)
|
||||
expect(domestic.find('.pda-apk-download-url').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-app="overseas"] img').attributes('src')).toBe(overseasImage)
|
||||
expect(listPdaReleases).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('clears the previous download when refresh fails and recovers on retry', async () => {
|
||||
const wrapper = renderPage()
|
||||
await flushPromises()
|
||||
const domestic = wrapper.get('[data-app="domestic"]')
|
||||
vi.mocked(listPdaReleases).mockRejectedValueOnce(new Error('network error'))
|
||||
await wrapper.findAll('button').find((button) => button.text() === '刷新')!.trigger('click')
|
||||
expect(domestic.find('.pda-apk-qr img').exists()).toBe(false)
|
||||
await flushPromises()
|
||||
expect(domestic.find('.pda-apk-download-url').exists()).toBe(false)
|
||||
expect(domestic.get('[role="alert"]').text()).toBeTruthy()
|
||||
expect(wrapper.get('[data-app="overseas"] .pda-apk-download-url').attributes('href')).toBe(overseasApk.downloadUrl)
|
||||
|
||||
await wrapper.findAll('button').find((button) => button.text() === '刷新')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(domestic.get('.pda-apk-download-url').attributes('href')).toBe(apkUrl)
|
||||
expect(domestic.find('.pda-apk-qr img').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('reports QR generation failure while preserving the APK link and allows refresh to retry', async () => {
|
||||
vi.mocked(QRCode.toString).mockImplementationOnce(() => Promise.reject(new Error('QR unavailable')))
|
||||
const wrapper = renderPage()
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[role="alert"]').text()).toBe('下载二维码生成失败,请刷新后重试')
|
||||
expect(wrapper.get('.pda-apk-download-url').attributes('href')).toBe(apkUrl)
|
||||
|
||||
await wrapper.findAll('button').find((button) => button.text() === '刷新')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.pda-apk-qr img').exists()).toBe(true)
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,8 +4,10 @@ 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 QRCode from 'qrcode'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import CopyButton from '@/shared/components/CopyButton.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -18,9 +20,14 @@ import {
|
||||
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 apps: PdaApp[] = ['domestic', 'overseas']
|
||||
const releaseLists = reactive<Record<PdaApp, { rows: PdaRelease[]; loading: boolean; error: string; request: number }>>({
|
||||
domestic: { rows: [], loading: false, error: '', request: 0 },
|
||||
overseas: { rows: [], loading: false, error: '', request: 0 },
|
||||
})
|
||||
const rows = computed(() => releaseLists[app.value].rows)
|
||||
const loading = computed(() => releaseLists[app.value].loading)
|
||||
const refreshing = computed(() => apps.some((targetApp) => releaseLists[targetApp].loading))
|
||||
const actionId = ref('')
|
||||
const uploadOpen = ref(false)
|
||||
const uploading = ref(false)
|
||||
@@ -42,27 +49,65 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'publishedAt', label: '发布时间', width: 170, slotName: 'publishedAt' },
|
||||
]
|
||||
|
||||
const currentApk = computed(() => rows.value.find((row) => row.current && row.packageType === 'apk'))
|
||||
const apkDownloads = apps.map((targetApp) => {
|
||||
const list = releaseLists[targetApp]
|
||||
const release = computed(() => list.rows.find((row) => row.app === targetApp
|
||||
&& row.current && row.status === 'PUBLISHED' && row.packageType === 'apk'))
|
||||
const url = computed(() => {
|
||||
if (list.loading || list.error || !release.value?.downloadUrl) return ''
|
||||
return new URL(release.value.downloadUrl, window.location.href).href
|
||||
})
|
||||
const qrImage = ref('')
|
||||
const qrLoading = ref(false)
|
||||
const qrError = ref('')
|
||||
|
||||
watch(url, async (downloadUrl, _previous, onCleanup) => {
|
||||
let cancelled = false
|
||||
onCleanup(() => { cancelled = true })
|
||||
qrImage.value = ''
|
||||
qrError.value = ''
|
||||
qrLoading.value = Boolean(downloadUrl)
|
||||
if (!downloadUrl) return
|
||||
try {
|
||||
const svg = await QRCode.toString(downloadUrl, { type: 'svg', margin: 4, width: 160 })
|
||||
if (!cancelled) qrImage.value = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
|
||||
} catch {
|
||||
if (!cancelled) qrError.value = '下载二维码生成失败,请刷新后重试'
|
||||
} finally {
|
||||
if (!cancelled) qrLoading.value = false
|
||||
}
|
||||
}, { immediate: true, flush: 'sync' })
|
||||
|
||||
return reactive({ app: targetApp, label: targetApp === 'domestic' ? '国内 PDA' : '海外 PDA',
|
||||
release, url, qrImage, qrLoading, qrError, loading: computed(() => list.loading), error: computed(() => list.error) })
|
||||
})
|
||||
|
||||
const currentApk = computed(() => apkDownloads.find((card) => card.app === app.value)?.release)
|
||||
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
|
||||
async function load(requestedApp: PdaApp) {
|
||||
const list = releaseLists[requestedApp]
|
||||
const request = ++list.request
|
||||
list.loading = true
|
||||
list.error = ''
|
||||
try {
|
||||
const result = await listPdaReleases(requestedApp)
|
||||
if (app.value === requestedApp) rows.value = result
|
||||
if (request === list.request) list.rows = result
|
||||
} catch (cause) {
|
||||
if (app.value === requestedApp) error.value = getApiErrorMessage(cause, '版本列表加载失败')
|
||||
if (request === list.request) list.error = getApiErrorMessage(cause, '版本列表加载失败')
|
||||
} finally {
|
||||
if (app.value === requestedApp) loading.value = false
|
||||
if (request === list.request) list.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(app, () => { rows.value = []; void load() })
|
||||
async function refreshAll() {
|
||||
await Promise.all(apps.map(load))
|
||||
}
|
||||
|
||||
watch(() => form.packageType, () => { form.file = null })
|
||||
onMounted(() => { void load() })
|
||||
onMounted(() => { void refreshAll() })
|
||||
|
||||
function openUpload() {
|
||||
Object.assign(form, { packageType: 'wgt', versionName: '', versionCode: currentApk.value?.versionCode ?? 1,
|
||||
@@ -83,11 +128,12 @@ async function upload() {
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
const uploadedApp = app.value
|
||||
try {
|
||||
await uploadPdaRelease(app.value, { ...form, file: form.file })
|
||||
await uploadPdaRelease(uploadedApp, { ...form, file: form.file })
|
||||
uploadOpen.value = false
|
||||
ElMessage.success('版本草稿已上传,请核对后发布')
|
||||
await load()
|
||||
await load(uploadedApp)
|
||||
} catch (cause) {
|
||||
ElMessage.error(getApiErrorMessage(cause, '更新包上传失败'))
|
||||
} finally { uploading.value = false }
|
||||
@@ -104,18 +150,19 @@ async function changeStatus(row: PdaRelease, action: 'publish' | 'withdraw') {
|
||||
if (action === 'publish') await publishPdaRelease(row.id)
|
||||
else await withdrawPdaRelease(row.id)
|
||||
ElMessage.success(`版本已${label}`)
|
||||
await load()
|
||||
await load(row.app)
|
||||
} catch (cause) {
|
||||
ElMessage.error(getApiErrorMessage(cause, `${label}失败,请刷新后重试`))
|
||||
} finally { actionId.value = '' }
|
||||
}
|
||||
|
||||
function download(row: PdaRelease) {
|
||||
if (!row.downloadUrl) return
|
||||
function downloadApk(targetApp: PdaApp) {
|
||||
const card = apkDownloads.find((item) => item.app === targetApp)
|
||||
if (!card?.url || !card.release) return
|
||||
const link = document.createElement('a')
|
||||
link.href = row.downloadUrl
|
||||
link.href = card.url
|
||||
link.rel = 'noopener noreferrer'
|
||||
link.download = `${row.app}-pda-${row.versionName}.${row.packageType}`
|
||||
link.download = `${targetApp}-pda-${card.release.versionName}.apk`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
@@ -125,6 +172,29 @@ function download(row: PdaRelease) {
|
||||
<template>
|
||||
<AdminPage class="admin-density pda-releases-page admin-workbench">
|
||||
<ListPageLayout aria-label="PDA版本">
|
||||
<template #toolbar>
|
||||
<div class="pda-apk-downloads" aria-label="最新 APK 下载">
|
||||
<section v-for="card in apkDownloads" :key="card.app" v-loading="card.loading || card.qrLoading" class="pda-apk-download" :data-app="card.app" :aria-label="`${card.label}下载`" :aria-busy="card.loading || card.qrLoading">
|
||||
<div v-if="card.release?.downloadUrl" class="pda-apk-qr">
|
||||
<img v-if="card.qrImage" :src="card.qrImage" :alt="`${card.label}最新APK下载二维码`" width="160" height="160" />
|
||||
</div>
|
||||
<div class="pda-apk-download-content">
|
||||
<h2>{{ card.label }} · 扫码下载 APK</h2>
|
||||
<template v-if="card.url">
|
||||
<p>{{ card.release?.versionName }} ({{ card.release?.versionCode }})</p>
|
||||
<a class="pda-apk-download-url" :href="card.url" rel="noopener noreferrer">{{ card.url }}</a>
|
||||
<div class="density-actions">
|
||||
<el-button @click="downloadApk(card.app)">下载 APK</el-button>
|
||||
<CopyButton :value="card.url" :label="`${card.label} APK下载链接`" button-text="复制链接" />
|
||||
</div>
|
||||
</template>
|
||||
<p v-else-if="card.error" role="alert">{{ card.error }}</p>
|
||||
<p v-else-if="!card.loading">{{ card.release ? '当前 APK 下载地址暂不可用' : '尚未发布 APK' }}</p>
|
||||
<p v-if="card.qrError" role="alert">{{ card.qrError }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<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">
|
||||
@@ -133,21 +203,16 @@ function download(row: PdaRelease) {
|
||||
<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 :loading="refreshing" @click="refreshAll"><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>
|
||||
@@ -190,12 +255,19 @@ function download(row: PdaRelease) {
|
||||
|
||||
<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-apk-downloads { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; gap: var(--admin-space-5); }
|
||||
.pda-apk-download { display: flex; min-width: 0; align-items: center; gap: var(--admin-space-6); padding: var(--admin-space-5); border: 1px solid var(--cc-border); border-radius: 4px; background: var(--cc-surface); }
|
||||
.pda-apk-qr { display: grid; width: 160px; height: 160px; flex-shrink: 0; place-items: center; background: #fff; }
|
||||
.pda-apk-qr img { display: block; }
|
||||
.pda-apk-download-content { display: grid; min-width: 0; gap: var(--admin-space-5); }
|
||||
.pda-apk-download-content h2 { margin: 0; color: var(--cc-text); font-size: var(--admin-font-section); }
|
||||
.pda-apk-download-content p { margin: 0; color: var(--cc-muted); font-size: var(--admin-font-body); }
|
||||
.pda-apk-download-url { color: var(--el-color-primary); font-size: var(--admin-font-body); overflow-wrap: anywhere; }
|
||||
.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); }
|
||||
|
||||
Reference in New Issue
Block a user