瀏覽代碼

应用分发功能上传

liuq 1 月之前
父節點
當前提交
93f73e910a

+ 2 - 1
backend/app/api/v1/api.py

@@ -4,7 +4,7 @@ from app.api.v1.endpoints import (
     auth, users, apps, utils, simple_auth, oidc, 
     open_api, logs, system_logs, backup, login_logs, 
     user_import, system, system_config, sms_auth,
-    messages, messages_upload, ws
+    messages, messages_upload, ws, client_distributions
 )
 
 api_router = APIRouter()
@@ -12,6 +12,7 @@ api_router.include_router(auth.router, prefix="/auth", tags=["认证 (Auth)"])
 api_router.include_router(users.router, prefix="/users", tags=["用户管理 (Users)"])
 api_router.include_router(user_import.router, prefix="/users", tags=["用户导入 (User Import)"])
 api_router.include_router(apps.router, prefix="/apps", tags=["应用管理 (Applications)"])
+api_router.include_router(client_distributions.router, prefix="/client-distributions", tags=["客户端分发 (Client Distribution)"])
 api_router.include_router(logs.router, prefix="/logs", tags=["操作日志 (Logs)"])
 api_router.include_router(login_logs.router, prefix="/login-logs", tags=["登录日志 (Login Logs)"])
 api_router.include_router(system_logs.router, prefix="/system-logs", tags=["后台日志 (System Logs)"])

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

@@ -0,0 +1,328 @@
+import logging
+from typing import Optional
+from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
+from sqlalchemy.orm import Session
+from sqlalchemy import desc, or_
+
+from app.api.v1 import deps
+from app.models.user import User
+from app.models.client_distribution import ClientDistribution, ClientVersion
+from app.schemas.client_distribution import (
+    ClientDistributionCreate,
+    ClientDistributionUpdate,
+    ClientDistributionResponse,
+    ClientDistributionList,
+    ClientVersionCreate,
+    ClientVersionUpdate,
+    ClientVersionResponse,
+    ClientVersionList,
+)
+from app.core.minio import minio_storage
+
+router = APIRouter()
+logger = logging.getLogger(__name__)
+
+# 安装包允许类型
+ALLOWED_PACKAGE_TYPES = {
+    "application/vnd.android.package-archive",  # APK
+    "application/octet-stream",  # IPA, generic binary
+}
+MAX_PACKAGE_SIZE = 500 * 1024 * 1024  # 500MB
+
+# 图标允许类型
+ALLOWED_ICON_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
+MAX_ICON_SIZE = 2 * 1024 * 1024  # 2MB
+
+
+def _resolve_icon_url(icon_url: Optional[str]) -> Optional[str]:
+    """若 icon_url 为 MinIO object_key,转为预签名 URL"""
+    if not icon_url:
+        return icon_url
+    if str(icon_url).startswith("http"):
+        return icon_url
+    return minio_storage.get_distribution_presigned_url(icon_url) or icon_url
+
+
+def _check_distribution_access(db: Session, dist_id: int, user: User) -> ClientDistribution:
+    dist = db.query(ClientDistribution).filter(
+        ClientDistribution.id == dist_id,
+        ClientDistribution.is_deleted == 0
+    ).first()
+    if not dist:
+        raise HTTPException(status_code=404, detail="分发不存在")
+    if user.role != "SUPER_ADMIN" and dist.owner_id != user.id:
+        raise HTTPException(status_code=403, detail="无权限操作此分发")
+    return dist
+
+
+@router.get("/", response_model=ClientDistributionList)
+def list_distributions(
+    skip: int = 0,
+    limit: int = 10,
+    search: Optional[str] = None,
+    db: Session = Depends(deps.get_db),
+    current_user: User = Depends(deps.get_current_active_user),
+):
+    """获取分发列表。超级管理员看全部,开发者只看自己的。"""
+    query = db.query(ClientDistribution).filter(ClientDistribution.is_deleted == 0)
+    if current_user.role != "SUPER_ADMIN":
+        query = query.filter(ClientDistribution.owner_id == current_user.id)
+    if search:
+        query = query.filter(
+            or_(
+                ClientDistribution.name.ilike(f"%{search}%"),
+            )
+        )
+    total = query.count()
+    rows = query.order_by(desc(ClientDistribution.id)).offset(skip).limit(limit).all()
+    items = [
+        ClientDistributionResponse(
+            id=d.id,
+            name=d.name,
+            description=d.description,
+            icon_url=_resolve_icon_url(d.icon_url),
+            icon_object_key=d.icon_url if (d.icon_url and not str(d.icon_url).startswith("http")) else None,
+            owner_id=d.owner_id,
+            created_at=d.created_at,
+            updated_at=d.updated_at,
+        )
+        for d in rows
+    ]
+    return ClientDistributionList(total=total, items=items)
+
+
+@router.post("/", response_model=ClientDistributionResponse)
+def create_distribution(
+    data: ClientDistributionCreate,
+    db: Session = Depends(deps.get_db),
+    current_user: User = Depends(deps.get_current_active_user),
+):
+    """创建分发。仅开发者或超级管理员可创建。"""
+    if current_user.role not in ("SUPER_ADMIN", "DEVELOPER"):
+        raise HTTPException(status_code=403, detail="无权限创建分发")
+    dist = ClientDistribution(
+        name=data.name,
+        description=data.description,
+        icon_url=data.icon_url,
+        owner_id=current_user.id,
+    )
+    db.add(dist)
+    db.commit()
+    db.refresh(dist)
+    return ClientDistributionResponse(
+        id=dist.id,
+        name=dist.name,
+        description=dist.description,
+        icon_url=_resolve_icon_url(dist.icon_url),
+        icon_object_key=dist.icon_url if (dist.icon_url and not str(dist.icon_url).startswith("http")) else None,
+        owner_id=dist.owner_id,
+        created_at=dist.created_at,
+        updated_at=dist.updated_at,
+    )
+
+
+@router.post("/icon/upload")
+async def upload_icon(
+    file: UploadFile = File(...),
+    distribution_id: int = Form(0),
+    db: Session = Depends(deps.get_db),
+    current_user: User = Depends(deps.get_current_active_user),
+):
+    """上传分发图标。distribution_id=0 表示创建时的临时图标,>0 表示编辑已有分发。返回 object_key。"""
+    if current_user.role not in ("SUPER_ADMIN", "DEVELOPER"):
+        raise HTTPException(status_code=403, detail="无权限")
+    if distribution_id > 0:
+        _check_distribution_access(db, distribution_id, current_user)
+    content = await file.read()
+    if len(content) > MAX_ICON_SIZE:
+        raise HTTPException(status_code=400, detail="图标大小超过 2MB 限制")
+    ct = file.content_type or "image/png"
+    if ct not in ALLOWED_ICON_TYPES:
+        raise HTTPException(status_code=400, detail="仅支持 PNG、JPG、GIF、WebP 格式")
+    try:
+        object_key = minio_storage.upload_distribution_icon(
+            file_data=content,
+            filename=file.filename or "icon.png",
+            content_type=ct,
+            distribution_id=distribution_id,
+            user_id=current_user.id,
+        )
+        return {"object_key": object_key}
+    except Exception as e:
+        logger.error(f"Icon upload failed: {e}")
+        raise HTTPException(status_code=500, detail="图标上传失败")
+
+
+@router.get("/{dist_id}", response_model=ClientDistributionResponse)
+def get_distribution(
+    dist_id: int,
+    db: Session = Depends(deps.get_db),
+    current_user: User = Depends(deps.get_current_active_user),
+):
+    """获取分发详情"""
+    dist = _check_distribution_access(db, dist_id, current_user)
+    return ClientDistributionResponse(
+        id=dist.id,
+        name=dist.name,
+        description=dist.description,
+        icon_url=_resolve_icon_url(dist.icon_url),
+        icon_object_key=dist.icon_url if (dist.icon_url and not str(dist.icon_url).startswith("http")) else None,
+        owner_id=dist.owner_id,
+        created_at=dist.created_at,
+        updated_at=dist.updated_at,
+    )
+
+
+@router.put("/{dist_id}", response_model=ClientDistributionResponse)
+def update_distribution(
+    dist_id: int,
+    data: ClientDistributionUpdate,
+    db: Session = Depends(deps.get_db),
+    current_user: User = Depends(deps.get_current_active_user),
+):
+    """更新分发"""
+    dist = _check_distribution_access(db, dist_id, current_user)
+    if data.name is not None:
+        dist.name = data.name
+    if data.description is not None:
+        dist.description = data.description
+    if data.icon_url is not None:
+        dist.icon_url = data.icon_url
+    db.commit()
+    db.refresh(dist)
+    return ClientDistributionResponse(
+        id=dist.id,
+        name=dist.name,
+        description=dist.description,
+        icon_url=_resolve_icon_url(dist.icon_url),
+        icon_object_key=dist.icon_url if (dist.icon_url and not str(dist.icon_url).startswith("http")) else None,
+        owner_id=dist.owner_id,
+        created_at=dist.created_at,
+        updated_at=dist.updated_at,
+    )
+
+
+@router.delete("/{dist_id}")
+def delete_distribution(
+    dist_id: int,
+    db: Session = Depends(deps.get_db),
+    current_user: User = Depends(deps.get_current_active_user),
+):
+    """软删除分发"""
+    dist = _check_distribution_access(db, dist_id, current_user)
+    dist.is_deleted = 1
+    db.commit()
+    return {"message": "已删除"}
+
+
+# ========== Versions ==========
+
+@router.get("/{dist_id}/versions", response_model=ClientVersionList)
+def list_versions(
+    dist_id: int,
+    skip: int = 0,
+    limit: int = 20,
+    db: Session = Depends(deps.get_db),
+    current_user: User = Depends(deps.get_current_active_user),
+):
+    """获取分发下的版本列表"""
+    _check_distribution_access(db, dist_id, current_user)
+    query = db.query(ClientVersion).filter(ClientVersion.distribution_id == dist_id)
+    total = query.count()
+    items = query.order_by(desc(ClientVersion.created_at)).offset(skip).limit(limit).all()
+    return ClientVersionList(total=total, items=items)
+
+
+@router.post("/{dist_id}/versions", response_model=ClientVersionResponse)
+async def create_version(
+    dist_id: int,
+    file: UploadFile = File(...),
+    version_code: str = Form(...),
+    version_name: Optional[str] = Form(None),
+    release_notes: Optional[str] = Form(None),
+    platform: Optional[str] = Form(None),
+    db: Session = Depends(deps.get_db),
+    current_user: User = Depends(deps.get_current_active_user),
+):
+    """创建新版本(上传安装包)"""
+    dist = _check_distribution_access(db, dist_id, current_user)
+
+    # 检查版本号唯一
+    existing = db.query(ClientVersion).filter(
+        ClientVersion.distribution_id == dist_id,
+        ClientVersion.version_code == version_code
+    ).first()
+    if existing:
+        raise HTTPException(status_code=400, detail=f"版本号 {version_code} 已存在")
+
+    content = await file.read()
+    if len(content) > MAX_PACKAGE_SIZE:
+        raise HTTPException(status_code=400, detail="文件大小超过 500MB 限制")
+
+    content_type = file.content_type or "application/octet-stream"
+    if content_type not in ALLOWED_PACKAGE_TYPES and not content_type.startswith("application/"):
+        pass  # 放宽:允许 application/*
+
+    try:
+        object_key = minio_storage.upload_distribution_file(
+            file_data=content,
+            filename=file.filename or "package.bin",
+            content_type=content_type,
+            distribution_id=dist_id,
+        )
+    except Exception as e:
+        logger.error(f"Upload failed: {e}")
+        raise HTTPException(status_code=500, detail="文件上传失败")
+
+    version = ClientVersion(
+        distribution_id=dist_id,
+        version_code=version_code,
+        version_name=version_name or version_code,
+        release_notes=release_notes,
+        object_key=object_key,
+        file_size=len(content),
+        platform=platform,
+    )
+    db.add(version)
+    db.commit()
+    db.refresh(version)
+    return version
+
+
+@router.put("/versions/{version_id}", response_model=ClientVersionResponse)
+def update_version(
+    version_id: int,
+    data: ClientVersionUpdate,
+    db: Session = Depends(deps.get_db),
+    current_user: User = Depends(deps.get_current_active_user),
+):
+    """更新版本信息(不含文件)"""
+    version = db.query(ClientVersion).filter(ClientVersion.id == version_id).first()
+    if not version:
+        raise HTTPException(status_code=404, detail="版本不存在")
+    _check_distribution_access(db, version.distribution_id, current_user)
+    if data.version_name is not None:
+        version.version_name = data.version_name
+    if data.release_notes is not None:
+        version.release_notes = data.release_notes
+    if data.platform is not None:
+        version.platform = data.platform
+    db.commit()
+    db.refresh(version)
+    return version
+
+
+@router.delete("/versions/{version_id}")
+def delete_version(
+    version_id: int,
+    db: Session = Depends(deps.get_db),
+    current_user: User = Depends(deps.get_current_active_user),
+):
+    """删除版本"""
+    version = db.query(ClientVersion).filter(ClientVersion.id == version_id).first()
+    if not version:
+        raise HTTPException(status_code=404, detail="版本不存在")
+    _check_distribution_access(db, version.distribution_id, current_user)
+    db.delete(version)
+    db.commit()
+    return {"message": "已删除"}

+ 56 - 0
backend/app/api/v1/endpoints/open_api.py

@@ -9,6 +9,12 @@ from app.core.utils import generate_english_name
 from app.schemas.user import UserRegister, User as UserSchema
 from app.models.user import User, UserStatus, UserRole
 from app.models.application import Application
+from app.models.client_distribution import ClientDistribution, ClientVersion
+from app.schemas.client_distribution import (
+    DistributionPublicResponse,
+    LatestVersionInfo,
+)
+from app.core.minio import minio_storage
 from app.services.sms_service import SmsService
 from app.services.captcha_service import CaptchaService
 
@@ -137,3 +143,53 @@ def reset_password(
     db.commit()
     
     return {"message": "密码重置成功"}
+
+
+@router.get("/distribution/{dist_id}", response_model=DistributionPublicResponse, summary="获取分发公开信息及最新版本")
+def get_distribution_public(
+    dist_id: int,
+    db: Session = Depends(deps.get_db),
+):
+    """
+    公开接口,无需登录。根据分发 ID 获取分发信息及最新版本(含预签名下载链接)。
+    用于公开下载页 /d/:id
+    """
+    dist = db.query(ClientDistribution).filter(
+        ClientDistribution.id == dist_id,
+        ClientDistribution.is_deleted == 0
+    ).first()
+    if not dist:
+        raise HTTPException(status_code=404, detail="分发不存在")
+
+    latest = (
+        db.query(ClientVersion)
+        .filter(ClientVersion.distribution_id == dist_id)
+        .order_by(ClientVersion.created_at.desc())
+        .first()
+    )
+
+    latest_info = None
+    if latest:
+        download_url = minio_storage.get_distribution_presigned_url(latest.object_key)
+        latest_info = LatestVersionInfo(
+            id=latest.id,
+            version_code=latest.version_code,
+            version_name=latest.version_name,
+            release_notes=latest.release_notes,
+            file_size=latest.file_size,
+            platform=latest.platform,
+            created_at=latest.created_at,
+            download_url=download_url or "",
+        )
+
+    icon_url = dist.icon_url
+    if icon_url and not str(icon_url).startswith("http"):
+        icon_url = minio_storage.get_distribution_presigned_url(icon_url) or icon_url
+
+    return DistributionPublicResponse(
+        id=dist.id,
+        name=dist.name,
+        description=dist.description,
+        icon_url=icon_url,
+        latest_version=latest_info,
+    )

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

@@ -60,6 +60,7 @@ class Settings(BaseSettings):
     MINIO_SECRET_KEY: str = "6FHE9lzeQ6rZ3wIXSA9jGu8pGf49vgwT18NJ9XpO"
     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_SECURE: bool = False
     
     class Config:

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

@@ -136,5 +136,96 @@ class MessageStorage:
             logger.error(f"Database backup upload failed: {e}")
             raise Exception("Database backup upload failed")
 
+    def _ensure_distribution_bucket(self):
+        """确保客户端分发桶存在"""
+        if not self.client:
+            return
+        bucket = settings.MINIO_DISTRIBUTION_BUCKET_NAME
+        try:
+            if not self.client.bucket_exists(bucket):
+                self.client.make_bucket(bucket)
+                logger.info(f"Created distribution bucket: {bucket}")
+        except S3Error as e:
+            logger.error(f"MinIO distribution bucket error: {e}")
+
+    def upload_distribution_file(
+        self, file_data: bytes, filename: str, content_type: str,
+        distribution_id: int
+    ) -> str:
+        """
+        上传客户端分发安装包到 MinIO
+        路径格式: versions/{distribution_id}/{uuid}.{ext}
+        返回: object_name
+        """
+        if not self.client:
+            raise Exception("Storage service unavailable")
+        self._ensure_distribution_bucket()
+        bucket = settings.MINIO_DISTRIBUTION_BUCKET_NAME
+        ext = filename.split('.')[-1] if '.' in filename else 'bin'
+        object_name = f"versions/{distribution_id}/{uuid.uuid4()}.{ext}"
+        try:
+            self.client.put_object(
+                bucket_name=bucket,
+                object_name=object_name,
+                data=io.BytesIO(file_data),
+                length=len(file_data),
+                content_type=content_type
+            )
+            return object_name
+        except S3Error as e:
+            logger.error(f"Distribution file upload failed: {e}")
+            raise Exception("File upload failed")
+
+    def get_distribution_presigned_url(self, object_name: str, expires=None) -> str | None:
+        """生成分发桶内对象的预签名下载链接"""
+        if not self.client:
+            return None
+        try:
+            from datetime import timedelta
+            if expires is None:
+                expires = timedelta(hours=1)
+            bucket = settings.MINIO_DISTRIBUTION_BUCKET_NAME
+            return self.client.get_presigned_url(
+                "GET", bucket, object_name, expires=expires
+            )
+        except Exception as e:
+            logger.error(f"Generate distribution presigned url failed: {e}")
+            return None
+
+    def upload_distribution_icon(
+        self, file_data: bytes, filename: str, content_type: str,
+        distribution_id: int, user_id: int
+    ) -> str:
+        """
+        上传分发图标到 MinIO
+        - distribution_id > 0: icons/{distribution_id}/{uuid}.{ext}
+        - distribution_id == 0 (创建时): icons/temp/{user_id}/{uuid}.{ext}
+        返回: object_name
+        """
+        if not self.client:
+            raise Exception("Storage service unavailable")
+        self._ensure_distribution_bucket()
+        bucket = settings.MINIO_DISTRIBUTION_BUCKET_NAME
+        ext = filename.split('.')[-1].lower() if '.' in filename else 'png'
+        if ext not in ('png', 'jpg', 'jpeg', 'gif', 'webp'):
+            ext = 'png'
+        if distribution_id > 0:
+            object_name = f"icons/{distribution_id}/{uuid.uuid4()}.{ext}"
+        else:
+            object_name = f"icons/temp/{user_id}/{uuid.uuid4()}.{ext}"
+        try:
+            self.client.put_object(
+                bucket_name=bucket,
+                object_name=object_name,
+                data=io.BytesIO(file_data),
+                length=len(file_data),
+                content_type=content_type
+            )
+            return object_name
+        except S3Error as e:
+            logger.error(f"Distribution icon upload failed: {e}")
+            raise Exception("Icon upload failed")
+
+
 # 单例实例
 minio_storage = MessageStorage()

+ 2 - 1
backend/app/models/__init__.py

@@ -8,4 +8,5 @@ from app.models.system_config import SystemConfig
 from app.models.backup import BackupRecord, BackupSettings
 from app.models.message import Message
 from app.models.device import UserDevice
-from app.models.app_category import AppCategory
+from app.models.app_category import AppCategory
+from app.models.client_distribution import ClientDistribution, ClientVersion

+ 36 - 0
backend/app/models/client_distribution.py

@@ -0,0 +1,36 @@
+from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, BigInteger
+from sqlalchemy.sql import func
+from sqlalchemy.orm import relationship
+from app.core.database import Base
+
+
+class ClientDistribution(Base):
+    __tablename__ = "client_distributions"
+
+    id = Column(Integer, primary_key=True, index=True)
+    name = Column(String(100), nullable=False)
+    description = Column(Text, nullable=True)
+    icon_url = Column(String(255), nullable=True)
+    owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
+    is_deleted = Column(Integer, default=0, nullable=False)
+    created_at = Column(DateTime(timezone=True), server_default=func.now())
+    updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
+
+    owner = relationship("User")
+    versions = relationship("ClientVersion", back_populates="distribution", cascade="all, delete-orphan")
+
+
+class ClientVersion(Base):
+    __tablename__ = "client_versions"
+
+    id = Column(Integer, primary_key=True, index=True)
+    distribution_id = Column(Integer, ForeignKey("client_distributions.id", ondelete="CASCADE"), nullable=False)
+    version_code = Column(String(32), nullable=False)
+    version_name = Column(String(100), nullable=True)
+    release_notes = Column(Text, nullable=True)
+    object_key = Column(String(512), nullable=False)
+    file_size = Column(BigInteger, nullable=True)
+    platform = Column(String(32), nullable=True)
+    created_at = Column(DateTime(timezone=True), server_default=func.now())
+
+    distribution = relationship("ClientDistribution", back_populates="versions")

+ 91 - 0
backend/app/schemas/client_distribution.py

@@ -0,0 +1,91 @@
+from typing import Optional, List
+from pydantic import BaseModel
+from datetime import datetime
+
+
+# Distribution Schemas
+class ClientDistributionBase(BaseModel):
+    name: str
+    description: Optional[str] = None
+    icon_url: Optional[str] = None
+
+
+class ClientDistributionCreate(ClientDistributionBase):
+    pass
+
+
+class ClientDistributionUpdate(BaseModel):
+    name: Optional[str] = None
+    description: Optional[str] = None
+    icon_url: Optional[str] = None
+
+
+class ClientDistributionResponse(ClientDistributionBase):
+    id: int
+    owner_id: int
+    created_at: datetime
+    updated_at: Optional[datetime] = None
+    icon_object_key: Optional[str] = None  # 原始 object_key,编辑时回传用
+
+    class Config:
+        from_attributes = True
+
+
+class ClientDistributionList(BaseModel):
+    total: int
+    items: List[ClientDistributionResponse]
+
+
+# Version Schemas
+class ClientVersionBase(BaseModel):
+    version_code: str
+    version_name: Optional[str] = None
+    release_notes: Optional[str] = None
+    platform: Optional[str] = None
+
+
+class ClientVersionCreate(ClientVersionBase):
+    object_key: str
+    file_size: Optional[int] = None
+
+
+class ClientVersionUpdate(BaseModel):
+    version_name: Optional[str] = None
+    release_notes: Optional[str] = None
+    platform: Optional[str] = None
+
+
+class ClientVersionResponse(ClientVersionBase):
+    id: int
+    distribution_id: int
+    object_key: str
+    file_size: Optional[int] = None
+    created_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+class ClientVersionList(BaseModel):
+    total: int
+    items: List[ClientVersionResponse]
+
+
+# Open API - Public download page
+class LatestVersionInfo(BaseModel):
+    id: int
+    version_code: str
+    version_name: Optional[str] = None
+    release_notes: Optional[str] = None
+    file_size: Optional[int] = None
+    platform: Optional[str] = None
+    created_at: datetime
+    download_url: str  # Pre-signed URL
+
+
+class DistributionPublicResponse(BaseModel):
+    id: int
+    name: str
+    description: Optional[str] = None
+    icon_url: Optional[str] = None
+    latest_version: Optional[LatestVersionInfo] = None

+ 104 - 0
frontend/src/api/clientDistributions.ts

@@ -0,0 +1,104 @@
+import api from '../utils/request'
+
+export interface ClientDistribution {
+  id: number
+  name: string
+  description?: string
+  icon_url?: string
+  icon_object_key?: string
+  owner_id: number
+  created_at: string
+  updated_at?: string
+}
+
+export interface ClientVersion {
+  id: number
+  distribution_id: number
+  version_code: string
+  version_name?: string
+  release_notes?: string
+  object_key: string
+  file_size?: number
+  platform?: string
+  created_at: string
+}
+
+export interface DistributionListResponse {
+  total: number
+  items: ClientDistribution[]
+}
+
+export interface VersionListResponse {
+  total: number
+  items: ClientVersion[]
+}
+
+export interface DistributionPublicResponse {
+  id: number
+  name: string
+  description?: string
+  icon_url?: string
+  latest_version?: {
+    id: number
+    version_code: string
+    version_name?: string
+    release_notes?: string
+    file_size?: number
+    platform?: string
+    created_at: string
+    download_url: string
+  }
+}
+
+export const listDistributions = (params?: { skip?: number; limit?: number; search?: string }) => {
+  return api.get<DistributionListResponse>('/client-distributions/', { params })
+}
+
+export const createDistribution = (data: { name: string; description?: string; icon_url?: string }) => {
+  return api.post<ClientDistribution>('/client-distributions/', data)
+}
+
+export const getDistribution = (id: number) => {
+  return api.get<ClientDistribution>(`/client-distributions/${id}`)
+}
+
+export const updateDistribution = (id: number, data: Partial<{ name: string; description: string; icon_url: string }>) => {
+  return api.put<ClientDistribution>(`/client-distributions/${id}`, data)
+}
+
+export const deleteDistribution = (id: number) => {
+  return api.delete(`/client-distributions/${id}`)
+}
+
+/** 上传分发图标,distributionId=0 表示创建时,>0 表示编辑时 */
+export const uploadDistributionIcon = (file: File, distributionId: number = 0) => {
+  const formData = new FormData()
+  formData.append('file', file)
+  formData.append('distribution_id', String(distributionId))
+  return api.post<{ object_key: string }>('/client-distributions/icon/upload', formData)
+}
+
+export const listVersions = (distId: number, params?: { skip?: number; limit?: number }) => {
+  return api.get<VersionListResponse>(`/client-distributions/${distId}/versions`, { params })
+}
+
+export const createVersion = (distId: number, formData: FormData) => {
+  return api.post<ClientVersion>(`/client-distributions/${distId}/versions`, formData, {
+    timeout: 300000  // 5 min for large file upload
+  })
+}
+
+export const updateVersion = (versionId: number, data: Partial<{ version_name: string; release_notes: string; platform: string }>) => {
+  return api.put<ClientVersion>(`/client-distributions/versions/${versionId}`, data)
+}
+
+export const deleteVersion = (versionId: number) => {
+  return api.delete(`/client-distributions/versions/${versionId}`)
+}
+
+// 公开接口,无需 token;404 时由页面自行处理,不触发全局错误提示
+export const getDistributionPublic = (id: number) => {
+  return api.get<DistributionPublicResponse>(`/open/distribution/${id}`, {
+    skipGlobalErrorHandler: true
+  })
+}

+ 17 - 2
frontend/src/router/index.ts

@@ -171,8 +171,23 @@ const routes: Array<RouteRecordRaw> = [
         path: 'messages',
         name: 'Messages',
         component: () => import('../views/message/index.vue')
+      },
+      {
+        path: 'client-distributions',
+        name: 'ClientDistributions',
+        component: () => import('../views/distribution/DistributionList.vue')
+      },
+      {
+        path: 'client-distributions/:id',
+        name: 'DistributionDetail',
+        component: () => import('../views/distribution/DistributionDetail.vue')
       }
     ]
+  },
+  {
+    path: '/d/:id',
+    name: 'DownloadPage',
+    component: () => import('../views/distribution/DownloadPage.vue')
   }
 ]
 
@@ -233,9 +248,9 @@ router.beforeEach((to, from, next) => {
     }
   }
 
-  // Public routes
+  // Public routes (including /d/:id for download page)
   const publicRoutes = ['/login', '/register', '/consent', '/reset-password', '/setup', '/mobile/login', '/mobile/reset-password', '/auto-login', '/auto-logout']
-  if (publicRoutes.includes(to.path)) {
+  if (publicRoutes.includes(to.path) || to.path.startsWith('/d/')) {
     next()
     return
   }

+ 8 - 1
frontend/src/views/Dashboard.vue

@@ -28,6 +28,13 @@
             <el-icon><Grid /></el-icon>
             <span>应用管理</span>
           </el-menu-item>
+          <el-menu-item 
+            v-if="user && (user.role === 'SUPER_ADMIN' || user.role === 'DEVELOPER')" 
+            index="/dashboard/client-distributions"
+          >
+            <el-icon><Upload /></el-icon>
+            <span>客户端分发</span>
+          </el-menu-item>
           
           <el-menu-item index="/dashboard/mappings">
             <el-icon><Connection /></el-icon>
@@ -142,7 +149,7 @@
 import { computed, onMounted, ref, reactive } from 'vue'
 import { useRouter } from 'vue-router'
 import { useAuthStore } from '../store/auth'
-import { Grid, List, QuestionFilled, User, ArrowDown, Connection, Monitor, Document, Download, RefreshRight, Lock, Setting, ChatDotRound, Folder, Menu } from '@element-plus/icons-vue'
+import { Grid, List, QuestionFilled, User, ArrowDown, Connection, Monitor, Document, Download, RefreshRight, Lock, Setting, ChatDotRound, Folder, Menu, Upload } from '@element-plus/icons-vue'
 import { ElMessage, FormInstance, FormRules } from 'element-plus'
 import api from '../utils/request'
 

+ 304 - 0
frontend/src/views/distribution/DistributionDetail.vue

@@ -0,0 +1,304 @@
+<template>
+  <div class="mapping-container">
+    <div class="header">
+      <div class="title-group">
+        <el-button @click="$router.push('/dashboard/client-distributions')" :icon="ArrowLeft" circle />
+        <h2 v-if="distribution">{{ distribution.name }}</h2>
+      </div>
+    </div>
+
+    <div v-loading="loading" class="content">
+      <template v-if="distribution">
+        <div class="versions-section">
+          <div class="toolbar">
+            <el-button type="primary" :icon="Plus" @click="openCreateVersion">新建版本</el-button>
+          </div>
+          <el-table :data="versions" style="width: 100%; margin-top: 15px;" stripe border>
+            <el-table-column prop="version_code" label="版本号" width="120" />
+            <el-table-column prop="version_name" label="显示名称" width="140" />
+            <el-table-column prop="release_notes" label="更新内容" min-width="200" show-overflow-tooltip />
+            <el-table-column prop="platform" label="平台" width="100" />
+            <el-table-column prop="file_size" label="大小" width="100">
+              <template #default="scope">
+                {{ formatSize(scope.row.file_size) }}
+              </template>
+            </el-table-column>
+            <el-table-column prop="created_at" label="创建时间" width="170">
+              <template #default="scope">
+                {{ new Date(scope.row.created_at).toLocaleString() }}
+              </template>
+            </el-table-column>
+            <el-table-column label="操作" width="150" fixed="right">
+              <template #default="scope">
+                <div class="action-buttons">
+                  <el-button type="primary" link @click="openEditVersion(scope.row)">编辑</el-button>
+                  <el-divider direction="vertical" />
+                  <el-button type="danger" link @click="handleDeleteVersion(scope.row)">删除</el-button>
+                </div>
+              </template>
+            </el-table-column>
+          </el-table>
+          <div class="pagination">
+            <el-pagination
+              v-model:current-page="versionPage"
+              v-model:page-size="versionPageSize"
+              :page-sizes="[10, 20, 50, 100]"
+              layout="total, sizes, prev, pager, next, jumper"
+              :total="versionTotal"
+              @size-change="fetchVersions"
+              @current-change="fetchVersions"
+            />
+          </div>
+        </div>
+      </template>
+    </div>
+
+    <!-- 新建/编辑版本 -->
+    <el-dialog v-model="versionDialogVisible" :title="versionEditId ? '编辑版本' : '新建版本'" width="520px">
+      <el-form :model="versionForm" label-width="100px">
+        <el-form-item label="版本号" required v-if="!versionEditId">
+          <el-input v-model="versionForm.version_code" placeholder="如 1.2.3" />
+        </el-form-item>
+        <el-form-item label="安装包" required v-if="!versionEditId">
+          <el-upload
+            ref="uploadRef"
+            :auto-upload="false"
+            :limit="1"
+            :on-change="onFileChange"
+            :on-exceed="() => ElMessage.warning('仅支持上传一个文件')"
+            accept=".apk,.ipa,.exe,.dmg"
+          >
+            <el-button type="primary">选择文件</el-button>
+            <template #tip>
+              <span class="tip">支持 APK、IPA、EXE 等,最大 500MB</span>
+            </template>
+          </el-upload>
+          <span v-if="versionForm.fileName" class="file-name">{{ versionForm.fileName }}</span>
+        </el-form-item>
+        <el-form-item label="显示名称">
+          <el-input v-model="versionForm.version_name" placeholder="选填,默认同版本号" />
+        </el-form-item>
+        <el-form-item label="更新内容">
+          <el-input v-model="versionForm.release_notes" type="textarea" :rows="4" placeholder="选填" />
+        </el-form-item>
+        <el-form-item label="平台">
+          <el-select v-model="versionForm.platform" placeholder="选填" clearable>
+            <el-option label="Android" value="android" />
+            <el-option label="iOS" value="ios" />
+            <el-option label="Windows" value="windows" />
+            <el-option label="macOS" value="macos" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="versionDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleVersionSubmit" :loading="versionSubmitting">确定</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, computed } from 'vue'
+import { useRoute } from 'vue-router'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { ArrowLeft, Plus } from '@element-plus/icons-vue'
+import type { UploadInstance, UploadFile, UploadFiles } from 'element-plus'
+import {
+  getDistribution,
+  listVersions,
+  createVersion,
+  updateVersion,
+  deleteVersion,
+  ClientDistribution,
+  ClientVersion
+} from '../../api/clientDistributions'
+
+const route = useRoute()
+const distId = computed(() => Number(route.params.id))
+const distribution = ref<ClientDistribution | null>(null)
+const versions = ref<ClientVersion[]>([])
+const loading = ref(false)
+const versionPage = ref(1)
+const versionPageSize = ref(10)
+const versionTotal = ref(0)
+const versionDialogVisible = ref(false)
+const versionEditId = ref<number | null>(null)
+const versionSubmitting = ref(false)
+const uploadRef = ref<UploadInstance>()
+const selectedFile = ref<File | null>(null)
+
+const versionForm = ref({
+  version_code: '',
+  version_name: '',
+  release_notes: '',
+  platform: '',
+  fileName: ''
+})
+
+const formatSize = (bytes?: number) => {
+  if (!bytes) return '-'
+  if (bytes < 1024) return bytes + ' B'
+  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
+  return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
+}
+
+const fetchDetail = async () => {
+  loading.value = true
+  try {
+    const res = await getDistribution(distId.value)
+    distribution.value = res.data
+  } catch {
+    distribution.value = null
+  } finally {
+    loading.value = false
+  }
+}
+
+const fetchVersions = async () => {
+  if (!distId.value) return
+  try {
+    const res = await listVersions(distId.value, {
+      skip: (versionPage.value - 1) * versionPageSize.value,
+      limit: versionPageSize.value
+    })
+    versions.value = res.data.items
+    versionTotal.value = res.data.total
+  } catch {
+    versions.value = []
+  }
+}
+
+const openCreateVersion = () => {
+  versionEditId.value = null
+  versionForm.value = { version_code: '', version_name: '', release_notes: '', platform: '', fileName: '' }
+  selectedFile.value = null
+  uploadRef.value?.clearFiles()
+  versionDialogVisible.value = true
+}
+
+const openEditVersion = (row: ClientVersion) => {
+  versionEditId.value = row.id
+  versionForm.value = {
+    version_code: row.version_code,
+    version_name: row.version_name || '',
+    release_notes: row.release_notes || '',
+    platform: row.platform || '',
+    fileName: ''
+  }
+  versionDialogVisible.value = true
+}
+
+const onFileChange = (_file: UploadFile, files: UploadFiles) => {
+  if (files.length > 0 && files[0].raw) {
+    selectedFile.value = files[0].raw
+    versionForm.value.fileName = files[0].name
+  } else {
+    selectedFile.value = null
+    versionForm.value.fileName = ''
+  }
+}
+
+const handleVersionSubmit = async () => {
+  if (versionEditId.value) {
+    versionSubmitting.value = true
+    try {
+      await updateVersion(versionEditId.value, {
+        version_name: versionForm.value.version_name || undefined,
+        release_notes: versionForm.value.release_notes || undefined,
+        platform: versionForm.value.platform || undefined
+      })
+      ElMessage.success('更新成功')
+      versionDialogVisible.value = false
+      fetchVersions()
+    } finally {
+      versionSubmitting.value = false
+    }
+  } else {
+    if (!versionForm.value.version_code.trim()) {
+      ElMessage.warning('请输入版本号')
+      return
+    }
+    if (!selectedFile.value) {
+      ElMessage.warning('请选择安装包文件')
+      return
+    }
+    const formData = new FormData()
+    formData.append('file', selectedFile.value)
+    formData.append('version_code', versionForm.value.version_code.trim())
+    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)
+    if (versionForm.value.platform) formData.append('platform', versionForm.value.platform)
+    versionSubmitting.value = true
+    try {
+      await createVersion(distId.value, formData)
+      ElMessage.success('创建成功')
+      versionDialogVisible.value = false
+      fetchVersions()
+    } finally {
+      versionSubmitting.value = false
+    }
+  }
+}
+
+const handleDeleteVersion = async (row: ClientVersion) => {
+  await ElMessageBox.confirm(`确定删除版本 ${row.version_code}?`, '确认删除', { type: 'warning' })
+  try {
+    await deleteVersion(row.id)
+    ElMessage.success('已删除')
+    fetchVersions()
+  } catch {}
+}
+
+onMounted(() => {
+  fetchDetail()
+  fetchVersions()
+})
+</script>
+
+<style scoped>
+.mapping-container {
+  padding: 20px;
+  background-color: #fff;
+  border-radius: 4px;
+}
+.header {
+  margin-bottom: 20px;
+  border-bottom: 1px solid #eee;
+  padding-bottom: 15px;
+}
+.title-group {
+  display: flex;
+  align-items: center;
+  gap: 15px;
+}
+.title-group h2 {
+  margin: 0;
+}
+.versions-section {
+  margin-top: 0;
+}
+.toolbar {
+  margin-bottom: 15px;
+  display: flex;
+  gap: 10px;
+}
+.pagination {
+  margin-top: 20px;
+  display: flex;
+  justify-content: flex-end;
+}
+.action-buttons {
+  display: flex;
+  align-items: center;
+  justify-content: flex-start;
+}
+.tip {
+  font-size: 12px;
+  color: #666;
+}
+.file-name {
+  margin-left: 8px;
+  font-size: 13px;
+}
+</style>

+ 285 - 0
frontend/src/views/distribution/DistributionList.vue

@@ -0,0 +1,285 @@
+<template>
+  <div class="app-list-container">
+    <div class="header">
+      <h2>客户端分发</h2>
+      <div class="header-actions">
+        <el-input
+          v-model="searchQuery"
+          placeholder="搜索分发名称"
+          style="width: 250px; margin-right: 10px;"
+          clearable
+          @clear="fetchList"
+          @keyup.enter="handleSearch"
+        >
+          <template #append>
+            <el-button icon="Search" @click="handleSearch" />
+          </template>
+        </el-input>
+        <el-button type="primary" @click="openCreateDialog">创建分发</el-button>
+      </div>
+    </div>
+
+    <el-table :data="items" v-loading="loading" style="width: 100%" stripe border>
+      <el-table-column prop="id" label="ID" width="80" />
+      <el-table-column prop="name" label="分发名称" min-width="150" />
+      <el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
+      <el-table-column label="操作" width="260" fixed="right">
+        <template #default="scope">
+          <div class="action-buttons">
+            <el-button type="primary" link @click="goDetail(scope.row)">进入</el-button>
+            <el-divider direction="vertical" />
+            <el-button type="primary" link @click="copyShareUrl(scope.row.id)">分享</el-button>
+            <el-divider direction="vertical" />
+            <el-button type="primary" link @click="openEditDialog(scope.row)">编辑</el-button>
+            <el-divider direction="vertical" />
+            <el-button type="danger" link @click="handleDelete(scope.row)">删除</el-button>
+          </div>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <div class="pagination">
+      <el-pagination
+        v-model:current-page="currentPage"
+        v-model:page-size="pageSize"
+        :page-sizes="[10, 20, 50, 100]"
+        layout="total, sizes, prev, pager, next, jumper"
+        :total="total"
+        @size-change="fetchList"
+        @current-change="fetchList"
+      />
+    </div>
+
+    <el-dialog v-model="dialogVisible" :title="isEdit ? '编辑分发' : '创建分发'" width="500px">
+      <el-form :model="form" label-width="100px">
+        <el-form-item label="分发名称" required>
+          <el-input v-model="form.name" placeholder="如:iOS App / Android App" />
+        </el-form-item>
+        <el-form-item label="描述">
+          <el-input v-model="form.description" type="textarea" :rows="3" placeholder="选填" />
+        </el-form-item>
+        <el-form-item label="图标">
+          <el-upload
+            :auto-upload="false"
+            :limit="1"
+            :on-change="onIconChange"
+            :on-remove="onIconRemove"
+            :file-list="iconFileList"
+            accept="image/png,image/jpeg,image/gif,image/webp"
+          >
+            <el-button type="primary">选择图标</el-button>
+            <template #tip>
+              <span class="tip">支持 PNG、JPG、GIF、WebP,最大 2MB</span>
+            </template>
+          </el-upload>
+          <img v-if="form.iconPreview" :src="form.iconPreview" class="icon-preview" alt="" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, computed } from 'vue'
+import { useRouter } from 'vue-router'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import {
+  listDistributions,
+  createDistribution,
+  updateDistribution,
+  deleteDistribution,
+  uploadDistributionIcon,
+  ClientDistribution
+} from '../../api/clientDistributions'
+
+const router = useRouter()
+const items = ref<ClientDistribution[]>([])
+const loading = ref(false)
+const total = ref(0)
+const currentPage = ref(1)
+const pageSize = ref(10)
+const searchQuery = ref('')
+const dialogVisible = ref(false)
+const isEdit = ref(false)
+const submitting = ref(false)
+const editId = ref<number | null>(null)
+
+const form = ref({
+  name: '',
+  description: '',
+  icon_url: '',
+  iconPreview: '' as string
+})
+const iconFileList = ref<any[]>([])
+const iconUploading = ref(false)
+
+const shareUrl = (id: number) => `${window.location.origin}/d/${id}`
+
+const copyShareUrl = async (id: number) => {
+  const url = shareUrl(id)
+  try {
+    await navigator.clipboard.writeText(url)
+    ElMessage.success('已复制下载页面链接')
+  } catch {
+    ElMessage.error('复制失败')
+  }
+}
+
+const fetchList = async () => {
+  loading.value = true
+  try {
+    const res = await listDistributions({
+      skip: (currentPage.value - 1) * pageSize.value,
+      limit: pageSize.value,
+      search: searchQuery.value || undefined
+    })
+    items.value = res.data.items
+    total.value = res.data.total
+  } catch {
+    items.value = []
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleSearch = () => fetchList()
+
+const openCreateDialog = () => {
+  isEdit.value = false
+  editId.value = null
+  form.value = { name: '', description: '', icon_url: '', iconPreview: '' }
+  iconFileList.value = []
+  dialogVisible.value = true
+}
+
+const openEditDialog = (row: ClientDistribution) => {
+  isEdit.value = true
+  editId.value = row.id
+  form.value = {
+    name: row.name,
+    description: row.description || '',
+    icon_url: row.icon_object_key || '',
+    iconPreview: row.icon_url || ''
+  }
+  iconFileList.value = row.icon_url ? [{ name: '当前图标', uid: 'current' }] : []
+  dialogVisible.value = true
+}
+
+const onIconChange = async (file: any) => {
+  const raw = file.raw
+  if (!raw) return
+  if (raw.size > 2 * 1024 * 1024) {
+    ElMessage.warning('图标大小不能超过 2MB')
+    return
+  }
+  iconUploading.value = true
+  try {
+    const res = await uploadDistributionIcon(raw, editId.value || 0)
+    form.value.icon_url = res.data.object_key
+    form.value.iconPreview = URL.createObjectURL(raw)
+    iconFileList.value = [{ name: raw.name, uid: file.uid }]
+  } catch {
+    iconFileList.value = []
+  } finally {
+    iconUploading.value = false
+  }
+}
+
+const onIconRemove = () => {
+  form.value.icon_url = ''
+  form.value.iconPreview = ''
+  iconFileList.value = []
+}
+
+const handleSubmit = async () => {
+  if (!form.value.name.trim()) {
+    ElMessage.warning('请输入分发名称')
+    return
+  }
+  submitting.value = true
+  try {
+    const payload: Record<string, unknown> = {
+      name: form.value.name,
+      description: form.value.description || undefined
+    }
+    if (form.value.icon_url !== undefined) {
+      payload.icon_url = form.value.icon_url
+    }
+    if (isEdit.value && editId.value) {
+      await updateDistribution(editId.value, payload)
+      ElMessage.success('更新成功')
+    } else {
+      await createDistribution(payload)
+      ElMessage.success('创建成功')
+    }
+    dialogVisible.value = false
+    fetchList()
+  } finally {
+    submitting.value = false
+  }
+}
+
+const handleDelete = async (row: ClientDistribution) => {
+  await ElMessageBox.confirm(`确定删除「${row.name}」?删除后分享链接将失效。`, '确认删除', {
+    type: 'warning'
+  })
+  try {
+    await deleteDistribution(row.id)
+    ElMessage.success('已删除')
+    fetchList()
+  } catch {}
+}
+
+const goDetail = (row: ClientDistribution) => {
+  router.push(`/dashboard/client-distributions/${row.id}`)
+}
+
+onMounted(fetchList)
+</script>
+
+<style scoped>
+.app-list-container {
+  padding: 20px;
+  background-color: #fff;
+  border-radius: 4px;
+}
+.header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20px;
+}
+.header h2 {
+  margin: 0;
+}
+.header-actions {
+  display: flex;
+  align-items: center;
+}
+.pagination {
+  margin-top: 20px;
+  display: flex;
+  justify-content: flex-end;
+}
+.action-buttons {
+  display: flex;
+  align-items: center;
+  justify-content: flex-start;
+}
+.tip {
+  font-size: 12px;
+  color: #999;
+}
+.icon-preview {
+  margin-top: 8px;
+  width: 64px;
+  height: 64px;
+  object-fit: cover;
+  border-radius: 8px;
+  border: 1px solid #eee;
+}
+</style>

+ 190 - 0
frontend/src/views/distribution/DownloadPage.vue

@@ -0,0 +1,190 @@
+<template>
+  <div class="download-page" :class="{ 'has-wechat-tip': isWeChat }">
+    <div v-if="isWeChat" class="wechat-tip">
+      <p>如果使用手机微信浏览器打开,请很清晰的点右上角三个点,用外部浏览器打开本网页</p>
+    </div>
+    <div v-loading="loading" class="card">
+      <template v-if="data">
+        <div class="app-info">
+          <img v-if="data.icon_url" :src="data.icon_url" class="icon" alt="" />
+          <div v-else class="icon-placeholder">
+            <el-icon :size="48"><Download /></el-icon>
+          </div>
+          <h1>{{ data.name }}</h1>
+          <p v-if="data.description" class="desc">{{ data.description }}</p>
+        </div>
+        <div v-if="data.latest_version" class="version-info">
+          <h3>最新版本 {{ data.latest_version.version_code }}</h3>
+          <p v-if="data.latest_version.release_notes" class="release-notes">
+            {{ data.latest_version.release_notes }}
+          </p>
+          <div class="meta">
+            <span v-if="data.latest_version.platform" class="platform">
+              {{ data.latest_version.platform }}
+            </span>
+            <span v-if="data.latest_version.file_size" class="size">
+              {{ formatSize(data.latest_version.file_size) }}
+            </span>
+          </div>
+          <el-button
+            type="primary"
+            size="large"
+            :icon="Download"
+            @click="handleDownload"
+            class="download-btn"
+          >
+            立即下载
+          </el-button>
+        </div>
+        <div v-else class="no-version">
+          <el-empty description="暂无可用版本" />
+        </div>
+      </template>
+      <template v-else-if="!loading && error">
+        <el-result icon="error" title="未找到" sub-title="该分发不存在或已被删除" />
+      </template>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, computed } from 'vue'
+import { useRoute } from 'vue-router'
+import { Download } from '@element-plus/icons-vue'
+import { getDistributionPublic, DistributionPublicResponse } from '../../api/clientDistributions'
+
+const route = useRoute()
+const distId = computed(() => Number(route.params.id))
+const isWeChat = /MicroMessenger/i.test(navigator.userAgent)
+const data = ref<DistributionPublicResponse | null>(null)
+const loading = ref(true)
+const error = ref(false)
+
+const formatSize = (bytes: number) => {
+  if (bytes < 1024) return bytes + ' B'
+  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
+  return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
+}
+
+const handleDownload = () => {
+  const v = data.value?.latest_version
+  if (!v?.download_url) return
+  window.open(v.download_url, '_blank')
+}
+
+onMounted(async () => {
+  if (!distId.value) {
+    loading.value = false
+    error.value = true
+    return
+  }
+  try {
+    const res = await getDistributionPublic(distId.value)
+    data.value = res.data
+  } catch {
+    data.value = null
+    error.value = true
+  } finally {
+    loading.value = false
+  }
+})
+</script>
+
+<style scoped>
+.download-page {
+  min-height: 100vh;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  padding: 20px;
+}
+.wechat-tip {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  background: #ff9800;
+  color: #fff;
+  padding: 16px 20px;
+  text-align: center;
+  z-index: 1000;
+  font-size: 15px;
+  line-height: 1.6;
+}
+.wechat-tip p {
+  margin: 0;
+}
+.download-page.has-wechat-tip {
+  padding-top: 70px;
+}
+.card {
+  background: #fff;
+  border-radius: 16px;
+  padding: 48px;
+  max-width: 480px;
+  width: 100%;
+  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
+}
+.app-info {
+  text-align: center;
+  margin-bottom: 32px;
+}
+.icon {
+  width: 80px;
+  height: 80px;
+  border-radius: 16px;
+  object-fit: cover;
+}
+.icon-placeholder {
+  width: 80px;
+  height: 80px;
+  margin: 0 auto 16px;
+  background: #f0f0f0;
+  border-radius: 16px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #999;
+}
+.app-info h1 {
+  margin: 0 0 8px 0;
+  font-size: 24px;
+}
+.desc {
+  color: #666;
+  font-size: 14px;
+  margin: 0;
+}
+.version-info {
+  text-align: center;
+}
+.version-info h3 {
+  margin: 0 0 12px 0;
+  font-size: 18px;
+}
+.release-notes {
+  color: #666;
+  font-size: 14px;
+  line-height: 1.6;
+  margin: 0 0 16px 0;
+  white-space: pre-wrap;
+  text-align: left;
+}
+.meta {
+  margin-bottom: 24px;
+  font-size: 13px;
+  color: #999;
+}
+.platform {
+  margin-right: 12px;
+}
+.download-btn {
+  width: 100%;
+  height: 48px;
+  font-size: 16px;
+}
+.no-version {
+  padding: 40px 0;
+}
+</style>

+ 37 - 0
sql/V5__add_client_distribution.sql

@@ -0,0 +1,37 @@
+-- V5__add_client_distribution.sql
+-- 客户端 App 版本分发平台:分发表 + 版本表
+
+-- ==========================================
+-- Step 1: 创建客户端分发表
+-- ==========================================
+CREATE TABLE IF NOT EXISTS client_distributions (
+    id INT AUTO_INCREMENT PRIMARY KEY,
+    name VARCHAR(100) NOT NULL COMMENT '分发名称',
+    description TEXT NULL COMMENT '分发描述',
+    icon_url VARCHAR(255) NULL COMMENT '图标URL',
+    owner_id INT NOT NULL COMMENT '创建者 user_id',
+    is_deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '软删除 0:否 1:是',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_owner_id (owner_id),
+    INDEX idx_is_deleted (is_deleted),
+    CONSTRAINT fk_client_distributions_owner FOREIGN KEY (owner_id) REFERENCES users(id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户端分发表';
+
+-- ==========================================
+-- Step 2: 创建客户端版本表
+-- ==========================================
+CREATE TABLE IF NOT EXISTS client_versions (
+    id INT AUTO_INCREMENT PRIMARY KEY,
+    distribution_id INT NOT NULL COMMENT '分发ID',
+    version_code VARCHAR(32) NOT NULL COMMENT '版本号如 1.2.3',
+    version_name VARCHAR(100) NULL COMMENT '显示名称',
+    release_notes TEXT NULL COMMENT '更新内容',
+    object_key VARCHAR(512) NOT NULL COMMENT 'MinIO 对象键',
+    file_size BIGINT NULL COMMENT '文件大小(字节)',
+    platform VARCHAR(32) NULL COMMENT '平台 ios/android/windows',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_distribution_id (distribution_id),
+    UNIQUE KEY uq_dist_version (distribution_id, version_code),
+    CONSTRAINT fk_client_versions_distribution FOREIGN KEY (distribution_id) REFERENCES client_distributions(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户端版本表';