liuq 4 giorni fa
parent
commit
4d6f5f8231

+ 21 - 1
backend/app/api/v1/endpoints/open_api.py

@@ -1,9 +1,10 @@
-from typing import Any
+from typing import Any, Optional
 from fastapi import APIRouter, Depends, HTTPException, Body
 from sqlalchemy.orm import Session
 from pydantic import BaseModel
 
 from app.api.v1 import deps
+from app.core.config import settings
 from app.core import security
 from app.core.utils import generate_english_name
 from app.schemas.user import UserRegister, User as UserSchema
@@ -22,6 +23,25 @@ from app.services.captcha_service import CaptchaService
 
 router = APIRouter()
 
+
+class DownloadLinksPublic(BaseModel):
+    mobile: Optional[str] = None
+    pc: Optional[str] = None
+
+
+@router.get(
+    "/download-links",
+    response_model=DownloadLinksPublic,
+    summary="客户端下载页地址(登录页等公开使用)",
+)
+def get_download_links():
+    """从环境变量读取;值为下载落地页 URL,前端直接跳转即可。"""
+    return DownloadLinksPublic(
+        mobile=(settings.CLIENT_DOWNLOAD_URL_MOBILE or None),
+        pc=(settings.CLIENT_DOWNLOAD_URL_PC or None),
+    )
+
+
 @router.post("/register", response_model=UserSchema, summary="开发者注册")
 def register_developer(
     req: UserRegister,

+ 5 - 1
backend/app/core/config.py

@@ -70,7 +70,11 @@ class Settings(BaseSettings):
     MINIO_DISTRIBUTION_BUCKET_NAME: str = "unified-application-distribution"
     MINIO_UPDATE_BUCKET_NAME: str = "app-updates"  # 公开读桶,用于自建更新文件 update/
     MINIO_SECURE: bool = False
-    
+
+    # 登录页「下载客户端」跳转地址(通常为下载落地页);不配则隐藏入口,不写数据库
+    CLIENT_DOWNLOAD_URL_MOBILE: Optional[str] = "https://api.hnyunzhu.com/d/gAAAAABp3aYH7i4_dgAmlQLb-ruUiqbPyc5vH23EEB7pvIWEZHbBwekS1hfaWPqyew2QuqGfcPMtxGd7Zvei0_Ijv-LyPst1NAYv1skXP4LklYTAVjuCvRM%3D?platform=android"
+    CLIENT_DOWNLOAD_URL_PC: Optional[str] = "https://api.hnyunzhu.com/d/gAAAAABp3aYH7i4_dgAmlQLb-ruUiqbPyc5vH23EEB7pvIWEZHbBwekS1hfaWPqyew2QuqGfcPMtxGd7Zvei0_Ijv-LyPst1NAYv1skXP4LklYTAVjuCvRM%3D?platform=windows"
+
     class Config:
         case_sensitive = True
         env_file = ".env"

+ 9 - 0
frontend/src/api/public.ts

@@ -43,4 +43,13 @@ export const ssoLogin = (data: { app_id: string, username: string, password: str
 
 export const registerDeveloper = (data: { mobile: string, password: string, sms_code: string, name: string }) => {
   return api.post('/open/register', data)
+}
+
+export interface DownloadLinksPublic {
+  mobile: string | null
+  pc: string | null
+}
+
+export const getDownloadLinks = () => {
+  return api.get<DownloadLinksPublic>('/open/download-links')
 }

+ 40 - 2
frontend/src/views/Login.vue

@@ -95,15 +95,19 @@
         </div>
       </el-form>
     </el-card>
+
+    <div v-if="clientDownloadHref" class="client-download-footer">
+      <a :href="clientDownloadHref" target="_blank" rel="noopener noreferrer">下载客户端</a>
+    </div>
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, onMounted } from 'vue'
+import { ref, reactive, onMounted, computed } from 'vue'
 import { useRouter, useRoute } from 'vue-router'
 import { useAuthStore } from '../store/auth'
 import { getLoginRequest, acceptLogin } from '../api/oidc'
-import { getSystemStatus, getAppPublicInfo, ssoLogin } from '../api/public'
+import { getSystemStatus, getAppPublicInfo, ssoLogin, getDownloadLinks } from '../api/public'
 import { getPublicConfigs } from '../api/systemConfig'
 import { sendSmsCode, loginWithSms } from '../api/smsAuth'
 import { ElMessage } from 'element-plus'
@@ -136,6 +140,20 @@ const loginChallenge = ref('')
 const ssoAppId = ref('')
 const ssoAppName = ref('')
 
+const downloadLinks = ref<{ mobile: string | null; pc: string | null } | null>(null)
+
+const isMobileUa = () =>
+  /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
+
+const clientDownloadHref = computed(() => {
+  const d = downloadLinks.value
+  if (!d) return ''
+  if (isMobileUa()) {
+    return d.mobile || d.pc || ''
+  }
+  return d.pc || d.mobile || ''
+})
+
 const handleSendCode = async () => {
     if (!smsForm.mobile) {
         ElMessage.warning('请输入手机号')
@@ -262,6 +280,13 @@ onMounted(async () => {
       console.error("Failed to fetch public config", e)
   }
 
+  try {
+    const dl = await getDownloadLinks()
+    downloadLinks.value = dl.data
+  } catch {
+    downloadLinks.value = null
+  }
+
   console.log('Login page mounted. Params:', { challenge, appid, fullQuery: route.query })
 
   // 1. Check System Initialization Status
@@ -348,6 +373,7 @@ onMounted(async () => {
 <style scoped>
 .login-container {
   display: flex;
+  flex-direction: column;
   justify-content: center;
   align-items: center;
   height: 100vh;
@@ -367,4 +393,16 @@ onMounted(async () => {
   height: 60px;
   object-fit: contain;
 }
+.client-download-footer {
+  margin-top: 16px;
+  text-align: center;
+  font-size: 14px;
+}
+.client-download-footer a {
+  color: #409eff;
+  text-decoration: none;
+}
+.client-download-footer a:hover {
+  text-decoration: underline;
+}
 </style>

+ 45 - 7
frontend/src/views/distribution/DownloadPage.vue

@@ -52,7 +52,7 @@
           type="button"
           class="platform-icon-btn"
           :class="{ active: activeTab === tab.key }"
-          @click="activeTab = tab.key"
+          @click="selectPlatformTab(tab.key)"
         >
           <span class="platform-icon-circle">
             <el-icon :size="28"><component :is="platformIcon(tab.key)" /></el-icon>
@@ -85,12 +85,13 @@
 
 <script setup lang="ts">
 import { ref, onMounted, computed, watch } from 'vue'
-import { useRoute } from 'vue-router'
+import { useRoute, useRouter } from 'vue-router'
 import { Download, Iphone, Monitor, Cellphone } from '@element-plus/icons-vue'
 import type { Component } from 'vue'
 import { getDistributionPublic, DistributionPublicResponse, type LatestVersionInfoPublic } from '../../api/clientDistributions'
 
 const route = useRoute()
+const router = useRouter()
 const shareId = computed(() => String(route.params.id ?? ''))
 const isWeChat = /MicroMessenger/i.test(navigator.userAgent)
 const data = ref<DistributionPublicResponse | null>(null)
@@ -107,6 +108,12 @@ function pickDefaultPlatformTab(tabs: { key: string }[]): string {
   return keys[0] ?? ''
 }
 
+/** URL ?platform= 与接口 key 对齐 */
+function normalizePlatformQuery(raw: unknown): string {
+  const v = Array.isArray(raw) ? raw[0] : raw
+  return typeof v === 'string' ? v.trim() : ''
+}
+
 const PLATFORM_LABELS: Record<string, string> = {
   android: 'Android',
   android_phone: '安卓',
@@ -152,6 +159,17 @@ const platformTabs = computed<{ key: string; label: string }[]>(() => {
 
 const activeTab = ref('')
 
+/** 点击底部平台 tab:同步地址栏 ?platform=,保留其它查询参数 */
+function selectPlatformTab(key: string) {
+  const q = normalizePlatformQuery(route.query.platform)
+  if (activeTab.value === key && q === key) return
+  activeTab.value = key
+  router.replace({
+    path: route.path,
+    query: { ...route.query, platform: key }
+  })
+}
+
 /** 当前选中的版本:有 Tab 时用选中平台,否则用全局 latest_version */
 const selectedVersion = computed<LatestVersionInfoPublic | undefined>(() => {
   const d = data.value
@@ -164,10 +182,30 @@ const selectedVersion = computed<LatestVersionInfoPublic | undefined>(() => {
 })
 
 watch(
-  () => [data.value, platformTabs.value] as const,
-  () => {
-    const tabs = platformTabs.value
-    if (tabs.length > 0 && !activeTab.value) {
+  () => [data.value, platformTabs.value, route.query.platform] as const,
+  (newVal, oldVal) => {
+    const d = newVal[0]
+    const tabs = newVal[1]
+    const qPlatform = newVal[2]
+    if (tabs.length === 0) return
+
+    const byPlatform = d?.latest_versions_by_platform
+    const requested = normalizePlatformQuery(qPlatform)
+    const valid = requested && byPlatform?.[requested] ? requested : ''
+
+    if (!activeTab.value) {
+      activeTab.value = valid || pickDefaultPlatformTab(tabs)
+      return
+    }
+
+    if (oldVal === undefined) return
+
+    const oldQ = normalizePlatformQuery(oldVal[2])
+    if (oldQ === requested) return
+
+    if (valid) {
+      activeTab.value = valid
+    } else if (!requested && oldQ !== '') {
       activeTab.value = pickDefaultPlatformTab(tabs)
     }
   },
@@ -198,7 +236,7 @@ onMounted(async () => {
   }
   try {
     const res = await getDistributionPublic(shareId.value)
-    data.value = res.data
+    data.value = res.data as DistributionPublicResponse
   } catch {
     data.value = null
     error.value = true