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 = (
|
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"
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户