liuq 1 месяц назад
Родитель
Сommit
2af0536077

+ 70 - 0
backend/app/api/v1/endpoints/client_distributions.py

@@ -387,3 +387,73 @@ def delete_version(
     db.delete(version)
     db.commit()
     return {"message": "已删除"}
+
+
+# ========== 自建更新文件(update 桶,公开读)==========
+
+@router.get("/{dist_id}/update-files")
+def list_update_files(
+    dist_id: int,
+    platform: str,
+    db: Session = Depends(deps.get_db),
+    current_user: User = Depends(deps.get_current_active_user),
+):
+    """列出该分发、该平台下 update/ 目录中的文件(MinIO 路径:创建时间戳/平台/update/...)"""
+    dist = _check_distribution_access(db, dist_id, current_user)
+    if not platform or platform.strip().lower() not in {p.value for p in DistributionPlatform}:
+        raise HTTPException(status_code=400, detail="请选择有效平台")
+    platform = platform.strip().lower()
+    ts = int(dist.created_at.timestamp())
+    files = minio_storage.list_update_files(ts, platform)
+    base_url = minio_storage._update_bucket_public_base_url()
+    for f in files:
+        f["url"] = minio_storage.get_update_file_public_url(f["key"])
+    return {"base_url": f"{base_url}{ts}/{platform}/update/", "files": files}
+
+
+@router.post("/{dist_id}/update-files")
+async def upload_update_file(
+    dist_id: int,
+    platform: str = Form(...),
+    file: UploadFile = File(...),
+    db: Session = Depends(deps.get_db),
+    current_user: User = Depends(deps.get_current_active_user),
+):
+    """上传文件到该分发、该平台的 update/ 目录,透传不改名"""
+    dist = _check_distribution_access(db, dist_id, current_user)
+    if platform.strip().lower() not in {p.value for p in DistributionPlatform}:
+        raise HTTPException(status_code=400, detail="请选择有效平台")
+    platform = platform.strip().lower()
+    filename = file.filename or "file.bin"
+    content = await file.read()
+    ts = int(dist.created_at.timestamp())
+    object_key = minio_storage.upload_update_file(
+        created_at_timestamp=ts,
+        platform=platform,
+        file_data=content,
+        filename=filename,
+        content_type=file.content_type or "application/octet-stream",
+    )
+    url = minio_storage.get_update_file_public_url(object_key)
+    return {"object_key": object_key, "url": url}
+
+
+@router.delete("/{dist_id}/update-files")
+def delete_update_file(
+    dist_id: int,
+    platform: str,
+    key: str,
+    db: Session = Depends(deps.get_db),
+    current_user: User = Depends(deps.get_current_active_user),
+):
+    """删除 update/ 目录下的文件,key 为 list 接口返回的完整 object_key"""
+    dist = _check_distribution_access(db, dist_id, current_user)
+    if not platform or not key:
+        raise HTTPException(status_code=400, detail="缺少 platform 或 key")
+    platform = platform.strip().lower()
+    ts = int(dist.created_at.timestamp())
+    prefix = f"{ts}/{platform}/update/"
+    if not key.startswith(prefix):
+        raise HTTPException(status_code=400, detail="key 不属当前分发/平台")
+    minio_storage.delete_update_file(key)
+    return {"message": "已删除"}

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

@@ -61,6 +61,7 @@ class Settings(BaseSettings):
     MINIO_BUCKET_NAME: str = "unified-message-files"
     MINIO_DB_BACKUP_BUCKET_NAME: str = "unified-db-backups"
     MINIO_DISTRIBUTION_BUCKET_NAME: str = "unified-application-distribution"
+    MINIO_UPDATE_BUCKET_NAME: str = "app-updates"  # 公开读桶,用于自建更新文件 update/
     MINIO_SECURE: bool = False
     
     class Config:

+ 90 - 0
backend/app/core/minio.py

@@ -226,6 +226,96 @@ class MessageStorage:
             logger.error(f"Distribution icon upload failed: {e}")
             raise Exception("Icon upload failed")
 
+    # ---------- 自建更新桶(公开读):路径 {时间戳}/{platform}/update/{filename} ----------
+    def _ensure_update_bucket(self):
+        """确保更新桶存在"""
+        if not self.client:
+            return
+        bucket = settings.MINIO_UPDATE_BUCKET_NAME
+        try:
+            if not self.client.bucket_exists(bucket):
+                self.client.make_bucket(bucket)
+                logger.info(f"Created update bucket: {bucket}")
+        except S3Error as e:
+            logger.error(f"MinIO update bucket error: {e}")
+            raise Exception("Update bucket unavailable")
+
+    def _update_bucket_public_base_url(self) -> str:
+        """更新桶公开访问的 base URL(末尾带 /)"""
+        endpoint = (settings.MINIO_ENDPOINT or "").rstrip("/")
+        bucket = settings.MINIO_UPDATE_BUCKET_NAME
+        return f"{endpoint}/{bucket}/"
+
+    def list_update_files(self, created_at_timestamp: int, platform: str) -> list:
+        """
+        列出某分发、某平台下 update/ 目录中的文件。
+        返回: [{"key": str, "name": str, "size": int}, ...]
+        """
+        if not self.client:
+            return []
+        self._ensure_update_bucket()
+        bucket = settings.MINIO_UPDATE_BUCKET_NAME
+        prefix = f"{created_at_timestamp}/{platform}/update/"
+        try:
+            objects = self.client.list_objects(bucket, prefix=prefix, recursive=True)
+            result = []
+            for obj in objects:
+                name = obj.object_name.split("/")[-1] if "/" in obj.object_name else obj.object_name
+                size = 0
+                if hasattr(obj, "size") and obj.size is not None:
+                    size = getattr(obj.size, "size", obj.size) if hasattr(obj.size, "size") else int(obj.size)
+                result.append({"key": obj.object_name, "name": name, "size": size})
+            return result
+        except S3Error as e:
+            logger.error(f"List update files failed: {e}")
+            return []
+
+    def upload_update_file(
+        self,
+        created_at_timestamp: int,
+        platform: str,
+        file_data: bytes,
+        filename: str,
+        content_type: str = "application/octet-stream",
+    ) -> str:
+        """
+        上传文件到更新桶,路径 {timestamp}/{platform}/update/{filename},透传不改名。
+        返回 object_key。
+        """
+        if not self.client:
+            raise Exception("Storage service unavailable")
+        self._ensure_update_bucket()
+        bucket = settings.MINIO_UPDATE_BUCKET_NAME
+        object_key = f"{created_at_timestamp}/{platform}/update/{filename}"
+        try:
+            self.client.put_object(
+                bucket_name=bucket,
+                object_name=object_key,
+                data=io.BytesIO(file_data),
+                length=len(file_data),
+                content_type=content_type,
+            )
+            return object_key
+        except S3Error as e:
+            logger.error(f"Update file upload failed: {e}")
+            raise Exception("Update file upload failed")
+
+    def delete_update_file(self, object_key: str) -> None:
+        """删除更新桶中的对象"""
+        if not self.client:
+            raise Exception("Storage service unavailable")
+        bucket = settings.MINIO_UPDATE_BUCKET_NAME
+        try:
+            self.client.remove_object(bucket, object_key)
+        except S3Error as e:
+            logger.error(f"Update file delete failed: {e}")
+            raise Exception("Update file delete failed")
+
+    def get_update_file_public_url(self, object_key: str) -> str:
+        """更新桶公开读,返回无需签名的固定 URL"""
+        base = self._update_bucket_public_base_url()
+        return f"{base}{object_key}"
+
 
 # 单例实例
 minio_storage = MessageStorage()

+ 12 - 2
frontend/nginx.conf

@@ -18,7 +18,12 @@ server {
         proxy_set_header Host $host;
         proxy_set_header X-Real-IP $remote_addr;
         proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
-        
+
+        # 大文件上传:读/写/连接超时 10 分钟
+        proxy_connect_timeout 600s;
+        proxy_send_timeout 600s;
+        proxy_read_timeout 600s;
+
         # WebSocket Support
         proxy_http_version 1.1;
         proxy_set_header Upgrade $http_upgrade;
@@ -71,7 +76,12 @@ server {
         proxy_set_header Host $host;
         proxy_set_header X-Real-IP $remote_addr;
         proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
-        
+
+        # 大文件上传:读/写/连接超时 10 分钟
+        proxy_connect_timeout 600s;
+        proxy_send_timeout 600s;
+        proxy_read_timeout 600s;
+
         # WebSocket Support
         proxy_http_version 1.1;
         proxy_set_header Upgrade $http_upgrade;

+ 61 - 2
frontend/src/api/clientDistributions.ts

@@ -91,9 +91,19 @@ export const listVersions = (
   return api.get<VersionListResponse>(`/client-distributions/${distId}/versions`, { params })
 }
 
-export const createVersion = (distId: number, formData: FormData) => {
+export const createVersion = (
+  distId: number,
+  formData: FormData,
+  options?: { onUploadProgress?: (percent: number) => void }
+) => {
   return api.post<ClientVersion>(`/client-distributions/${distId}/versions`, formData, {
-    timeout: 300000  // 5 min for large file upload
+    timeout: 300000,
+    onUploadProgress: options?.onUploadProgress
+      ? (e) => {
+          const percent = e.total ? Math.round((e.loaded / e.total) * 100) : 0
+          options.onUploadProgress!(percent)
+        }
+      : undefined
   })
 }
 
@@ -111,3 +121,52 @@ export const getDistributionPublic = (shareId: string) => {
     skipGlobalErrorHandler: true
   })
 }
+
+// 自建更新文件(update 桶,路径:创建时间戳/平台/update/...)
+export interface UpdateFileItem {
+  key: string
+  name: string
+  size: number
+  url: string
+}
+
+export interface UpdateFilesResponse {
+  base_url: string
+  files: UpdateFileItem[]
+}
+
+export const listUpdateFiles = (distId: number, platform: string) => {
+  return api.get<UpdateFilesResponse>(`/client-distributions/${distId}/update-files`, {
+    params: { platform }
+  })
+}
+
+export const uploadUpdateFile = (
+  distId: number,
+  platform: string,
+  file: File,
+  options?: { onUploadProgress?: (percent: number) => void }
+) => {
+  const formData = new FormData()
+  formData.append('file', file)
+  formData.append('platform', platform)
+  return api.post<{ object_key: string; url: string }>(
+    `/client-distributions/${distId}/update-files`,
+    formData,
+    {
+      timeout: 300000,
+      onUploadProgress: options?.onUploadProgress
+        ? (e) => {
+            const percent = e.total ? Math.round((e.loaded / e.total) * 100) : 0
+            options.onUploadProgress!(percent)
+          }
+        : undefined
+    }
+  )
+}
+
+export const deleteUpdateFile = (distId: number, platform: string, key: string) => {
+  return api.delete(`/client-distributions/${distId}/update-files`, {
+    params: { platform, key }
+  })
+}

+ 174 - 2
frontend/src/views/distribution/DistributionDetail.vue

@@ -12,6 +12,7 @@
         <div class="versions-section">
           <div class="toolbar">
             <el-button type="primary" :icon="Plus" @click="openCreateVersion">新建版本</el-button>
+            <el-button type="default" @click="openUpdateFilesDialog">管理自动更新软件</el-button>
             <el-select
               v-model="versionPlatformFilter"
               placeholder="平台"
@@ -128,6 +129,12 @@
               </template>
             </el-upload>
             <span v-if="versionForm.fileName" class="file-name">{{ versionForm.fileName }}</span>
+            <el-progress
+              v-if="versionUploadPercent >= 0"
+              :percentage="versionUploadPercent"
+              style="margin-top: 8px;"
+              :status="versionUploadPercent === 100 ? 'success' : undefined"
+            />
           </el-form-item>
           <el-form-item label="显示名称">
             <el-input v-model="versionForm.version_name" placeholder="选填,默认同版本号" />
@@ -179,6 +186,75 @@
         </template>
       </template>
     </el-dialog>
+
+    <!-- 管理自动更新软件(update 桶) -->
+    <el-dialog v-model="updateFilesDialogVisible" title="管理自动更新软件" width="680px" @open="onUpdateFilesDialogOpen">
+      <div class="update-files-dialog">
+        <el-form-item label="操作系统" required>
+          <el-select
+            v-model="updateFilesPlatform"
+            placeholder="请先选择操作系统"
+            style="width: 100%"
+            @change="fetchUpdateFilesList"
+          >
+            <el-option label="Android" value="android" />
+            <el-option label="安卓手机" value="android_phone" />
+            <el-option label="安卓平板" value="android_tablet" />
+            <el-option label="iOS" value="ios" />
+            <el-option label="iPadOS" value="ipados" />
+            <el-option label="Windows" value="windows" />
+            <el-option label="macOS" value="macos" />
+            <el-option label="鸿蒙电脑" value="harmonyos_pc" />
+            <el-option label="鸿蒙平板" value="harmonyos_pad" />
+            <el-option label="鸿蒙手机" value="harmonyos_phone" />
+          </el-select>
+        </el-form-item>
+        <template v-if="updateFilesPlatform">
+          <div v-if="updateFilesBaseUrl" class="update-base-url-wrap">
+            <span class="label">更新目录 URL(Electron generic 可填):</span>
+            <el-input v-model="updateFilesBaseUrl" readonly>
+              <template #append>
+                <el-button @click="copyUpdateUrl(updateFilesBaseUrl)">复制</el-button>
+              </template>
+            </el-input>
+          </div>
+          <div class="update-files-upload">
+            <el-upload
+              :auto-upload="false"
+              :show-file-list="false"
+              :on-change="onUpdateFileSelect"
+              :disabled="updateFileUploadPercent >= 0"
+              accept="*"
+            >
+              <el-button type="primary" :loading="updateFileUploadPercent >= 0">上传文件</el-button>
+            </el-upload>
+            <el-progress
+              v-if="updateFileUploadPercent >= 0"
+              :percentage="updateFileUploadPercent"
+              style="margin-top: 8px;"
+              :status="updateFileUploadPercent === 100 ? 'success' : undefined"
+            />
+          </div>
+          <el-table v-loading="updateFilesLoading" :data="updateFilesList" border stripe style="width: 100%; margin-top: 12px;">
+            <el-table-column prop="name" label="文件名" min-width="180" show-overflow-tooltip />
+            <el-table-column prop="size" label="大小" width="100">
+              <template #default="scope">{{ formatSize(scope.row.size) }}</template>
+            </el-table-column>
+            <el-table-column label="URL" width="100">
+              <template #default="scope">
+                <el-button link type="primary" @click="copyUpdateUrl(scope.row.url)">复制</el-button>
+              </template>
+            </el-table-column>
+            <el-table-column label="操作" width="80">
+              <template #default="scope">
+                <el-button link type="danger" @click="handleDeleteUpdateFile(scope.row)">删除</el-button>
+              </template>
+            </el-table-column>
+          </el-table>
+          <el-empty v-if="!updateFilesLoading && updateFilesList.length === 0" description="暂无文件,请上传" style="margin-top: 16px;" />
+        </template>
+      </div>
+    </el-dialog>
   </div>
 </template>
 
@@ -194,8 +270,12 @@ import {
   createVersion,
   updateVersion,
   deleteVersion,
+  listUpdateFiles,
+  uploadUpdateFile,
+  deleteUpdateFile,
   ClientDistribution,
-  ClientVersion
+  ClientVersion,
+  type UpdateFileItem
 } from '../../api/clientDistributions'
 
 const route = useRoute()
@@ -218,6 +298,18 @@ const createStep = ref(1)
 /** 用于提示“当前该平台最新版本”,打开新建弹窗时拉取 */
 const allVersionsForHint = ref<ClientVersion[]>([])
 
+/** 新建版本上传进度:-1 不显示,0-100 显示百分比 */
+const versionUploadPercent = ref(-1)
+/** 管理自动更新文件上传进度:-1 不显示,0-100 显示百分比 */
+const updateFileUploadPercent = ref(-1)
+
+/** 管理自动更新软件弹窗 */
+const updateFilesDialogVisible = ref(false)
+const updateFilesPlatform = ref('')
+const updateFilesList = ref<UpdateFileItem[]>([])
+const updateFilesBaseUrl = ref('')
+const updateFilesLoading = ref(false)
+
 const versionForm = ref({
   version_code: '',
   version_name: '',
@@ -296,6 +388,7 @@ const onVersionSearch = () => {
 const openCreateVersion = async () => {
   versionEditId.value = null
   createStep.value = 1
+  versionUploadPercent.value = -1
   versionForm.value = { version_code: '', version_name: '', release_notes: '', platform: '', fileName: '' }
   selectedFile.value = null
   uploadRef.value?.clearFiles()
@@ -376,8 +469,11 @@ const handleVersionSubmit = async () => {
     if (versionForm.value.version_name) formData.append('version_name', versionForm.value.version_name)
     if (versionForm.value.release_notes) formData.append('release_notes', versionForm.value.release_notes)
     versionSubmitting.value = true
+    versionUploadPercent.value = 0
     try {
-      await createVersion(distId.value, formData)
+      await createVersion(distId.value, formData, {
+        onUploadProgress: (p) => { versionUploadPercent.value = p }
+      })
       ElMessage.success('创建成功')
       versionDialogVisible.value = false
       fetchVersions()
@@ -386,6 +482,7 @@ const handleVersionSubmit = async () => {
       ElMessage.error(typeof msg === 'string' ? msg : JSON.stringify(msg))
     } finally {
       versionSubmitting.value = false
+      versionUploadPercent.value = -1
     }
   }
 }
@@ -399,6 +496,66 @@ const handleDeleteVersion = async (row: ClientVersion) => {
   } catch {}
 }
 
+const openUpdateFilesDialog = () => {
+  updateFilesPlatform.value = ''
+  updateFilesList.value = []
+  updateFilesBaseUrl.value = ''
+  updateFilesDialogVisible.value = true
+}
+
+const onUpdateFilesDialogOpen = () => {
+  updateFilesPlatform.value = ''
+  updateFilesList.value = []
+  updateFilesBaseUrl.value = ''
+}
+
+const fetchUpdateFilesList = async () => {
+  if (!distId.value || !updateFilesPlatform.value) return
+  updateFilesLoading.value = true
+  try {
+    const res = await listUpdateFiles(distId.value, updateFilesPlatform.value)
+    updateFilesList.value = res.data.files || []
+    updateFilesBaseUrl.value = res.data.base_url || ''
+  } catch {
+    updateFilesList.value = []
+    updateFilesBaseUrl.value = ''
+  } finally {
+    updateFilesLoading.value = false
+  }
+}
+
+const onUpdateFileSelect = async (file: { raw?: File }) => {
+  if (!file.raw || !distId.value || !updateFilesPlatform.value) return
+  updateFileUploadPercent.value = 0
+  try {
+    await uploadUpdateFile(distId.value, updateFilesPlatform.value, file.raw, {
+      onUploadProgress: (p) => { updateFileUploadPercent.value = p }
+    })
+    ElMessage.success('上传成功')
+    await fetchUpdateFilesList()
+  } catch (err: any) {
+    const msg = err?.response?.data?.detail || err?.message || '上传失败'
+    ElMessage.error(typeof msg === 'string' ? msg : JSON.stringify(msg))
+  } finally {
+    updateFileUploadPercent.value = -1
+  }
+}
+
+const handleDeleteUpdateFile = async (row: UpdateFileItem) => {
+  await ElMessageBox.confirm(`确定删除 ${row.name}?`, '确认删除', { type: 'warning' })
+  if (!distId.value || !updateFilesPlatform.value) return
+  try {
+    await deleteUpdateFile(distId.value, updateFilesPlatform.value, row.key)
+    ElMessage.success('已删除')
+    await fetchUpdateFilesList()
+  } catch {}
+}
+
+const copyUpdateUrl = (url: string) => {
+  if (!url) return
+  navigator.clipboard.writeText(url).then(() => ElMessage.success('已复制到剪贴板')).catch(() => ElMessage.error('复制失败'))
+}
+
 onMounted(() => {
   fetchDetail()
   fetchVersions()
@@ -458,4 +615,19 @@ onMounted(() => {
   margin-top: 4px;
   color: #909399;
 }
+
+.update-files-dialog .label {
+  font-size: 13px;
+  color: #606266;
+}
+.update-base-url-wrap {
+  margin-bottom: 16px;
+}
+.update-base-url-wrap .label {
+  display: block;
+  margin-bottom: 6px;
+}
+.update-files-upload {
+  margin-bottom: 8px;
+}
 </style>

+ 139 - 62
frontend/src/views/distribution/DownloadPage.vue

@@ -11,22 +11,6 @@
           <p v-if="data.description" class="desc">{{ data.description }}</p>
         </div>
 
-        <!-- 多平台 Tab:有按平台数据且多于一个时显示 -->
-        <div v-if="platformTabs.length > 1" class="platform-tabs-wrap">
-          <div class="platform-tabs">
-            <button
-              v-for="tab in platformTabs"
-              :key="tab.key"
-              type="button"
-              class="platform-tab"
-              :class="{ active: activeTab === tab.key }"
-              @click="activeTab = tab.key"
-            >
-              <span class="tab-label">{{ tab.label }}</span>
-            </button>
-          </div>
-        </div>
-
         <div v-if="selectedVersion" class="version-info">
           <h3>最新版本 {{ selectedVersion.version_code }}</h3>
           <p v-if="selectedVersion.release_notes" class="release-notes">
@@ -59,6 +43,25 @@
       </template>
     </div>
 
+    <!-- 底部平台切换栏(微信风格) -->
+    <footer v-if="data && platformTabs.length > 0" class="platform-footer">
+      <div class="platform-icons">
+        <button
+          v-for="tab in platformTabs"
+          :key="tab.key"
+          type="button"
+          class="platform-icon-btn"
+          :class="{ active: activeTab === tab.key }"
+          @click="activeTab = tab.key"
+        >
+          <span class="platform-icon-circle">
+            <el-icon :size="28"><component :is="platformIcon(tab.key)" /></el-icon>
+          </span>
+          <span class="platform-icon-label">{{ tab.label }}</span>
+        </button>
+      </div>
+    </footer>
+
     <el-dialog
       v-model="wechatTipVisible"
       title="请在外部浏览器中打开"
@@ -83,7 +86,8 @@
 <script setup lang="ts">
 import { ref, onMounted, computed, watch } from 'vue'
 import { useRoute } from 'vue-router'
-import { Download } from '@element-plus/icons-vue'
+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()
@@ -121,6 +125,23 @@ const PLATFORM_LABELS: Record<string, string> = {
 }
 const platformLabel = (value: string) => (value ? PLATFORM_LABELS[value] || value : '')
 
+/** 平台对应底部圆形图标(微信风格) */
+function platformIcon(platformKey: string): Component {
+  const iconMap: Record<string, Component> = {
+    android: Cellphone,
+    android_phone: Cellphone,
+    android_tablet: Cellphone,
+    ios: Iphone,
+    ipados: Iphone,
+    windows: Monitor,
+    macos: Monitor,
+    harmonyos_pc: Monitor,
+    harmonyos_pad: Cellphone,
+    harmonyos_phone: Cellphone
+  }
+  return iconMap[platformKey] || Download
+}
+
 /** Tab 显示顺序 */
 const PLATFORM_ORDER = ['windows', 'macos', 'android', 'android_phone', 'android_tablet', 'ios', 'ipados', 'harmonyos_pc', 'harmonyos_pad', 'harmonyos_phone']
 
@@ -197,10 +218,12 @@ onMounted(async () => {
 .download-page {
   min-height: 100vh;
   display: flex;
+  flex-direction: column;
   align-items: center;
-  justify-content: center;
-  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-  padding: 20px;
+  justify-content: flex-start;
+  background: #f8f9fa;
+  padding: 24px 20px 140px;
+  color: #1a1a1a;
 }
 .wechat-tip-content {
   padding: 8px 0;
@@ -229,48 +252,16 @@ onMounted(async () => {
 }
 .card {
   background: #fff;
-  border-radius: 16px;
-  padding: 48px;
+  border-radius: 12px;
+  padding: 40px 32px;
   max-width: 520px;
   width: 100%;
-  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
+  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
+  color: #1a1a1a;
 }
 .app-info {
   text-align: center;
-  margin-bottom: 24px;
-}
-.platform-tabs-wrap {
-  margin-bottom: 24px;
-}
-.platform-tabs {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 8px;
-  justify-content: center;
-}
-.platform-tab {
-  padding: 10px 18px;
-  border-radius: 12px;
-  border: 2px solid #e8e8e8;
-  background: #fafafa;
-  color: #606266;
-  font-size: 14px;
-  font-weight: 500;
-  cursor: pointer;
-  transition: all 0.2s ease;
-}
-.platform-tab:hover {
-  border-color: #667eea;
-  color: #667eea;
-  background: #f5f3ff;
-}
-.platform-tab.active {
-  border-color: #667eea;
-  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-  color: #fff;
-}
-.tab-label {
-  white-space: nowrap;
+  margin-bottom: 28px;
 }
 .icon {
   width: 80px;
@@ -287,14 +278,16 @@ onMounted(async () => {
   display: flex;
   align-items: center;
   justify-content: center;
-  color: #999;
+  color: #909399;
 }
 .app-info h1 {
   margin: 0 0 8px 0;
-  font-size: 24px;
+  font-size: 26px;
+  font-weight: 600;
+  color: #1a1a1a;
 }
 .desc {
-  color: #666;
+  color: #606266;
   font-size: 14px;
   margin: 0;
 }
@@ -304,9 +297,10 @@ onMounted(async () => {
 .version-info h3 {
   margin: 0 0 12px 0;
   font-size: 18px;
+  color: #1a1a1a;
 }
 .release-notes {
-  color: #666;
+  color: #606266;
   font-size: 14px;
   line-height: 1.6;
   margin: 0 0 16px 0;
@@ -316,7 +310,7 @@ onMounted(async () => {
 .meta {
   margin-bottom: 24px;
   font-size: 13px;
-  color: #999;
+  color: #909399;
 }
 .platform {
   margin-right: 12px;
@@ -325,8 +319,91 @@ onMounted(async () => {
   width: 100%;
   height: 48px;
   font-size: 16px;
+  background: #07c160;
+  color: #fff;
+  border: none;
+}
+.download-btn:hover {
+  background: #06ad56;
+  color: #fff;
 }
 .no-version {
   padding: 40px 0;
 }
+.no-version :deep(.el-empty__description) {
+  color: #909399;
+}
+.card :deep(.el-result__title) {
+  color: #1a1a1a;
+}
+.card :deep(.el-result__subtitle) {
+  color: #606266;
+}
+.card :deep(.el-loading-mask) {
+  background-color: rgba(255, 255, 255, 0.8);
+}
+.card :deep(.el-loading-spinner .path) {
+  stroke: #07c160;
+}
+
+/* 底部平台切换栏(白底协调) */
+.platform-footer {
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  padding: 20px 16px 24px;
+  background: #fff;
+  border-top: 1px solid #eee;
+  box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.04);
+}
+.platform-icons {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: center;
+  gap: 20px 24px;
+  max-width: 560px;
+  margin: 0 auto;
+}
+.platform-icon-btn {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  background: none;
+  border: none;
+  color: #606266;
+  cursor: pointer;
+  padding: 0;
+  transition: color 0.2s ease;
+}
+.platform-icon-btn:hover {
+  color: #07c160;
+}
+.platform-icon-circle {
+  width: 56px;
+  height: 56px;
+  border-radius: 50%;
+  background: #f0f0f0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-bottom: 8px;
+  transition: background 0.2s ease, color 0.2s ease;
+}
+.platform-icon-btn:hover .platform-icon-circle {
+  background: #e8e8e8;
+}
+.platform-icon-btn.active .platform-icon-circle {
+  background: #e8f5e9;
+  color: #07c160;
+}
+.platform-icon-btn.active {
+  color: #07c160;
+}
+.platform-icon-btn.active .platform-icon-circle :deep(.el-icon) {
+  color: #07c160;
+}
+.platform-icon-label {
+  font-size: 12px;
+}
 </style>