fix(pda): display latest APK download QR codes for both apps

This commit is contained in:
2026-11-06 14:42:21 +08:00
parent d761bff871
commit c78fbd2d45
4 changed files with 490 additions and 30 deletions
@@ -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); }