feat(update): 版本管理页体验优化 + 新增应用下载页
- 版本列表操作列新增"下载"按钮,直接下载 APK - 移除应用商店标签的悬浮提示,仅保留"查看详情"入口 - 新增 /download/:appKey 公开下载页:按设备 UA 识别 Android/iOS/HarmonyOS, 展示对应最新已发布版本;Android 按品牌匹配商店跳转按钮,未匹配时列出所有已配置商店链接; iOS/鸿蒙仅在已发布且配置商店链接时展示,否则提示无可用安装包 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
这个提交包含在:
父节点
bead48eb17
当前提交
afe698bf46
@ -42,6 +42,7 @@ updateClient.interceptors.request.use((config) => {
|
||||
const skipAuth = (
|
||||
url.startsWith('/api/v1/updates/app/check') ||
|
||||
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/inspect') ||
|
||||
url.startsWith('/api/v1/rn/files/')
|
||||
@ -240,6 +241,28 @@ export interface UnifiedReleaseManifest {
|
||||
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 = {
|
||||
listAppVersions(appKey: string, platform: 'ANDROID' | 'IOS' | 'HARMONY') {
|
||||
return updateClient.get<{ data: AppVersion[] }>('/api/v1/updates/app/list', {
|
||||
|
||||
@ -16,6 +16,10 @@ const router = createRouter({
|
||||
path: '/forgot-password',
|
||||
component: () => import('@/views/auth/ForgotPasswordView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/download/:appKey',
|
||||
component: () => import('@/views/download/DownloadView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
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 class="store-review-tags">
|
||||
<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
|
||||
v-else
|
||||
:type="reviewTagType(item.state)"
|
||||
size="small"
|
||||
style="margin:2px"
|
||||
@ -127,7 +115,7 @@
|
||||
<el-table-column prop="createdAt" label="上传时间" width="160">
|
||||
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<el-table-column label="操作" width="280" fixed="right">
|
||||
<template #default="{row}">
|
||||
<el-button
|
||||
v-if="row.publishStatus === 'DRAFT'"
|
||||
@ -149,6 +137,10 @@
|
||||
v-if="row.publishStatus === 'PUBLISHED'"
|
||||
link type="danger" size="small"
|
||||
@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
|
||||
v-if="row.downloadUrl && row.publishStatus !== 'DEPRECATED'"
|
||||
link type="primary" size="small"
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户