feat(update): 版本管理页体验优化 + 新增应用下载页

- 版本列表操作列新增"下载"按钮,直接下载 APK
- 移除应用商店标签的悬浮提示,仅保留"查看详情"入口
- 新增 /download/:appKey 公开下载页:按设备 UA 识别 Android/iOS/HarmonyOS,
  展示对应最新已发布版本;Android 按品牌匹配商店跳转按钮,未匹配时列出所有已配置商店链接;
  iOS/鸿蒙仅在已发布且配置商店链接时展示,否则提示无可用安装包

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-07-04 01:51:17 +08:00
父节点 bead48eb17
当前提交 afe698bf46
共有 4 个文件被更改,包括 293 次插入13 次删除

查看文件

@ -42,6 +42,7 @@ updateClient.interceptors.request.use((config) => {
const skipAuth = ( const skipAuth = (
url.startsWith('/api/v1/updates/app/check') || url.startsWith('/api/v1/updates/app/check') ||
url.startsWith('/api/v1/updates/app/inspect') || url.startsWith('/api/v1/updates/app/inspect') ||
url.startsWith('/api/v1/updates/public/') ||
url.startsWith('/api/v1/rn/update/check') || url.startsWith('/api/v1/rn/update/check') ||
url.startsWith('/api/v1/rn/inspect') || url.startsWith('/api/v1/rn/inspect') ||
url.startsWith('/api/v1/rn/files/') url.startsWith('/api/v1/rn/files/')
@ -240,6 +241,28 @@ export interface UnifiedReleaseManifest {
rnBundles: UnifiedRnUploadItem[] rnBundles: UnifiedRnUploadItem[]
} }
export interface DownloadPageAppInfo {
versionName: string
versionCode: number
downloadUrl?: string
changeLog?: string
storeUrl?: string
storeLinks?: Record<string, string>
publishedAt: string
}
export interface DownloadPageInfo {
android: DownloadPageAppInfo | null
ios: DownloadPageAppInfo | null
harmony: DownloadPageAppInfo | null
}
export function fetchDownloadPageInfo(appKey: string) {
return updateClient.get<{ data: DownloadPageInfo }>('/api/v1/updates/public/download-info', {
params: { appKey },
})
}
export const updateAdminApi = { export const updateAdminApi = {
listAppVersions(appKey: string, platform: 'ANDROID' | 'IOS' | 'HARMONY') { listAppVersions(appKey: string, platform: 'ANDROID' | 'IOS' | 'HARMONY') {
return updateClient.get<{ data: AppVersion[] }>('/api/v1/updates/app/list', { return updateClient.get<{ data: AppVersion[] }>('/api/v1/updates/app/list', {

查看文件

@ -16,6 +16,10 @@ const router = createRouter({
path: '/forgot-password', path: '/forgot-password',
component: () => import('@/views/auth/ForgotPasswordView.vue'), component: () => import('@/views/auth/ForgotPasswordView.vue'),
}, },
{
path: '/download/:appKey',
component: () => import('@/views/download/DownloadView.vue'),
},
{ {
path: '/', path: '/',
component: () => import('@/views/layout/MainLayout.vue'), component: () => import('@/views/layout/MainLayout.vue'),

查看文件

@ -0,0 +1,261 @@
<template>
<div class="download-page">
<div v-if="loading" class="state-msg">加载中</div>
<div v-else-if="loadError" class="state-msg">{{ loadError }}</div>
<div v-else class="sections">
<div v-for="section in sections" :key="section.key" class="section-card">
<h2 class="section-title">{{ section.label }}</h2>
<template v-if="section.info">
<div class="version-info">
<span class="version-name">{{ section.info.versionName }}</span>
<span class="version-code">{{ section.info.versionCode }}</span>
</div>
<div v-if="section.info.changeLog" class="change-log">{{ section.info.changeLog }}</div>
<div class="actions">
<a
v-if="section.key === 'android' && section.info.downloadUrl"
class="btn btn-primary"
:href="section.info.downloadUrl"
>立即安装</a>
<a
v-if="section.key !== 'android' && section.info.storeUrl"
class="btn btn-primary"
:href="section.info.storeUrl"
target="_blank"
rel="noopener"
>去应用商店安装</a>
<a
v-if="section.key === 'android' && matchedStoreUrl"
class="btn btn-secondary"
:href="matchedStoreUrl"
target="_blank"
rel="noopener"
>{{ matchedStoreLabel }}安装</a>
</div>
<div
v-if="section.key === 'android' && !matchedStoreUrl && otherStoreLinks.length"
class="other-links"
>
<span class="other-links-label">其他应用商店</span>
<a
v-for="link in otherStoreLinks"
:key="link.type"
class="other-link"
:href="link.url"
target="_blank"
rel="noopener"
>{{ link.label }}</a>
</div>
</template>
<div v-else class="empty">当前应用无可用安装包</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { fetchDownloadPageInfo, type DownloadPageAppInfo, type DownloadPageInfo } from '@/api/update'
const route = useRoute()
const appKey = String(route.params.appKey ?? '')
const loading = ref(true)
const loadError = ref('')
const data = ref<DownloadPageInfo | null>(null)
const STORE_LABELS: Record<string, string> = {
HUAWEI: '华为应用市场',
HONOR: '荣耀应用市场',
MI: '小米应用商店',
OPPO: 'OPPO 软件商店',
VIVO: 'vivo 应用商店',
GOOGLE_PLAY: 'Google Play',
}
type PlatformKind = 'android' | 'ios' | 'harmony' | 'other'
function detectPlatform(): PlatformKind {
const ua = navigator.userAgent || ''
if (/harmonyos/i.test(ua)) return 'harmony'
if (/android/i.test(ua)) return 'android'
if (/iphone|ipad|ipod/i.test(ua)) return 'ios'
return 'other'
}
function detectVendor(): string | null {
const ua = navigator.userAgent || ''
if (/\bvivo\b/i.test(ua)) return 'VIVO'
if (/honor/i.test(ua)) return 'HONOR'
if (/huawei/i.test(ua)) return 'HUAWEI'
if (/xiaomi|redmi|poco/i.test(ua)) return 'MI'
if (/\boppo\b/i.test(ua)) return 'OPPO'
return null
}
const platform = ref<PlatformKind>(detectPlatform())
const vendor = ref<string | null>(detectVendor())
interface Section {
key: 'android' | 'ios' | 'harmony'
label: string
info: DownloadPageAppInfo | null
}
const sections = computed<Section[]>(() => {
if (!data.value) return []
if (platform.value === 'android') {
return [{ key: 'android', label: 'Android', info: data.value.android }]
}
if (platform.value === 'ios') {
return [{ key: 'ios', label: 'iOS', info: data.value.ios }]
}
if (platform.value === 'harmony') {
return [{ key: 'harmony', label: 'HarmonyOS', info: data.value.harmony }]
}
return [
{ key: 'android', label: 'Android', info: data.value.android },
{ key: 'ios', label: 'iOS', info: data.value.ios },
{ key: 'harmony', label: 'HarmonyOS', info: data.value.harmony },
]
})
const matchedStoreUrl = computed(() => {
const links = data.value?.android?.storeLinks
if (!links || !vendor.value) return ''
return links[vendor.value] ?? ''
})
const matchedStoreLabel = computed(() => (vendor.value ? STORE_LABELS[vendor.value] ?? vendor.value : ''))
const otherStoreLinks = computed(() => {
const links = data.value?.android?.storeLinks
if (!links) return []
return Object.entries(links).map(([type, url]) => ({
type,
url,
label: STORE_LABELS[type] ?? type,
}))
})
onMounted(async () => {
if (!appKey) {
loadError.value = '缺少应用标识'
loading.value = false
return
}
try {
const res = await fetchDownloadPageInfo(appKey)
data.value = res.data.data
} catch {
loadError.value = '加载失败,请稍后重试'
} finally {
loading.value = false
}
})
</script>
<style scoped>
.download-page {
min-height: 100vh;
padding: 24px 16px;
background: #f5f6f8;
box-sizing: border-box;
}
.state-msg {
text-align: center;
color: #888;
padding: 60px 0;
}
.sections {
max-width: 480px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.section-card {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
.section-title {
margin: 0 0 12px;
font-size: 16px;
font-weight: 600;
}
.version-info {
font-size: 15px;
margin-bottom: 8px;
}
.version-code {
color: #999;
}
.change-log {
color: #666;
font-size: 13px;
white-space: pre-wrap;
margin-bottom: 16px;
}
.actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.btn {
display: inline-block;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
text-decoration: none;
text-align: center;
}
.btn-primary {
background: #409eff;
color: #fff;
}
.btn-secondary {
background: #f0f2f5;
color: #333;
}
.other-links {
margin-top: 12px;
font-size: 13px;
color: #666;
}
.other-links-label {
margin-right: 6px;
}
.other-link {
margin-right: 10px;
color: #409eff;
text-decoration: none;
}
.empty {
color: #999;
font-size: 14px;
}
</style>

查看文件

@ -77,19 +77,7 @@
<div v-if="parseStoreReview(row.storeReviewStatus).length" class="store-review-cell"> <div v-if="parseStoreReview(row.storeReviewStatus).length" class="store-review-cell">
<div class="store-review-tags"> <div class="store-review-tags">
<template v-for="item in parseStoreReview(row.storeReviewStatus)" :key="item.store"> <template v-for="item in parseStoreReview(row.storeReviewStatus)" :key="item.store">
<el-tooltip
v-if="item.reason"
:content="item.reason"
placement="top"
>
<el-tag
:type="reviewTagType(item.state)"
size="small"
style="margin:2px"
>{{ storeLabel(item.store) }} · {{ reviewDisplayLabel(item) }}</el-tag>
</el-tooltip>
<el-tag <el-tag
v-else
:type="reviewTagType(item.state)" :type="reviewTagType(item.state)"
size="small" size="small"
style="margin:2px" style="margin:2px"
@ -127,7 +115,7 @@
<el-table-column prop="createdAt" label="上传时间" width="160"> <el-table-column prop="createdAt" label="上传时间" width="160">
<template #default="{row}">{{ formatTime(row.createdAt) }}</template> <template #default="{row}">{{ formatTime(row.createdAt) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="220" fixed="right"> <el-table-column label="操作" width="280" fixed="right">
<template #default="{row}"> <template #default="{row}">
<el-button <el-button
v-if="row.publishStatus === 'DRAFT'" v-if="row.publishStatus === 'DRAFT'"
@ -149,6 +137,10 @@
v-if="row.publishStatus === 'PUBLISHED'" v-if="row.publishStatus === 'PUBLISHED'"
link type="danger" size="small" link type="danger" size="small"
@click="promptUnpublishApp(row.id)">下架</el-button> @click="promptUnpublishApp(row.id)">下架</el-button>
<el-button
v-if="row.downloadUrl"
link type="primary" size="small"
tag="a" :href="row.downloadUrl" target="_blank">下载</el-button>
<el-button <el-button
v-if="row.downloadUrl && row.publishStatus !== 'DEPRECATED'" v-if="row.downloadUrl && row.publishStatus !== 'DEPRECATED'"
link type="primary" size="small" link type="primary" size="small"