Browse Source

Merge branch 'refs/heads/dev' into jiapu_aijiuyi

jiapu 2 months ago
parent
commit
9af1639fdc
25 changed files with 1431 additions and 13 deletions
  1. 66 2
      code/ajyApp/pages/device/detail/detail.nvue
  2. 7 2
      code/ajyApp/pages/device/deviceInfo/deviceInfo.nvue
  3. 1 1
      code/ajyApp/pages/device/list/list.nvue
  4. 5 2
      code/ajyApp/pages/device/search/search.nvue
  5. 210 6
      code/ajyApp/pages/my/version/version.nvue
  6. 9 0
      code/ajyApp/utils/api/device.js
  7. 19 0
      code/ajyApp/utils/api/version.js
  8. 22 0
      code/backend/src/main/java/com/aijiuyi/admin/common/util/OssUtil.java
  9. 39 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/AppUpdateController.java
  10. 100 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/AppVersionController.java
  11. 13 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/FileController.java
  12. 31 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AppVersionCheckVO.java
  13. 25 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AppVersionQueryDTO.java
  14. 37 0
      code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AppVersionSaveDTO.java
  15. 58 0
      code/backend/src/main/java/com/aijiuyi/admin/entity/AppVersion.java
  16. 12 0
      code/backend/src/main/java/com/aijiuyi/admin/mapper/AppVersionMapper.java
  17. 59 0
      code/backend/src/main/java/com/aijiuyi/admin/service/AppVersionService.java
  18. 204 0
      code/backend/src/main/java/com/aijiuyi/admin/service/impl/AppVersionServiceImpl.java
  19. 1 0
      code/backend/src/main/resources/application.yml
  20. 5 0
      code/backend/src/main/resources/mapper/AppVersionMapper.xml
  21. 19 0
      code/backend/src/main/resources/sql/migration_app_version.sql
  22. 3 0
      code/frontend/src/api/index.js
  23. 68 0
      code/frontend/src/api/version.js
  24. 6 0
      code/frontend/src/router/index.js
  25. 412 0
      code/frontend/src/views/system/version/index.vue

+ 66 - 2
code/ajyApp/pages/device/detail/detail.nvue

@@ -23,9 +23,17 @@
 				<text class="gray">{{name}}</text>
 			</view>
 			<view class="menuitem">
-				<text>设备ID</text>
+				<text>设备编号</text>
 				<text class="gray device-id-text">{{deviceId}}</text>
 			</view>
+			<view class="menuitem">
+				<text>设备型号</text>
+				<text class="gray">{{deviceModel || '未知'}}</text>
+			</view>
+			<view class="menuitem">
+				<text>序列号</text>
+				<text class="gray">{{serialNo || '未知'}}</text>
+			</view>
 			<view class="menuitem">
 				<text>信号强度</text>
 				<view class="signal-value-wrap">
@@ -42,10 +50,20 @@
 				<text>固件版本</text>
 				<text class="gray">{{firmwareVersion || '未获取'}}</text>
 			</view>
-			<view class="menuitem lastItem">
+			<view class="menuitem">
 				<text>连接状态</text>
 				<text class="gray">{{connected ? '已连接' : '未连接'}}</text>
 			</view>
+			<view class="menuitem">
+				<text>在线状态</text>
+				<text class="gray">{{onlineStatus === 1 ? '在线' : '离线'}}</text>
+			</view>
+			<view class="menuitem lastItem menuitem-address">
+				<text>设备地址</text>
+				<view class="address-wrap">
+					<text class="gray address-text">{{address || '未设置'}}</text>
+				</view>
+			</view>
 
 			<text class="txt">设备操作</text>
 			<view class="menuitem" @click="disconnectDevice" v-if="connected">
@@ -68,6 +86,7 @@
 <script>
 import { useBleStore } from '@/stores/ble'
 import { BLE_STATE } from '@/utils/ble/constants.js'
+import { getDeviceDetail } from '@/utils/api/device.js'
 import ayToast from '@/components/ay-toast/ay-toast.nvue'
 
 export default {
@@ -79,7 +98,13 @@ export default {
 			statusBarHeight: 44,
 			name: '',
 			deviceId: '',
+			deviceCode: '',
+			deviceModel: '',
+			serialNo: '',
+			onlineStatus: 0,
+			address: '',
 			rssi: '',
+			id:'',
 			connected: false,
 			connecting: false,
 			firmwareVersion: '',
@@ -113,6 +138,11 @@ export default {
 		this.name = decodeURIComponent(options.name || '')
 		this.deviceId = decodeURIComponent(options.deviceId || '')
 		this.rssi = decodeURIComponent(options.rssi || '')
+		this.id = decodeURIComponent(options.id || '')
+		// 获取设备详情
+		if (this.id) {
+			this.fetchDeviceDetail()
+		}
 	},
 	async onShow() {
 		const bleStore = useBleStore()
@@ -158,6 +188,21 @@ export default {
 		this._cleanListeners()
 	},
 	methods: {
+		async fetchDeviceDetail() {
+			try {
+				const res = await getDeviceDetail(this.id)
+				if (res) {
+					this.name = res.deviceName || this.name
+					this.deviceModel = res.deviceModel || ''
+					this.serialNo = res.serialNo || ''
+					this.firmwareVersion = res.firmwareVersion || this.firmwareVersion
+					this.onlineStatus = res.onlineStatus || 0
+					this.address = res.address || ''
+				}
+			} catch (e) {
+				console.log('获取设备详情失败', e)
+			}
+		},
 		goBack() {
 			uni.navigateBack()
 		},
@@ -395,6 +440,25 @@ export default {
 	height: 40rpx;
 }
 
+.menuitem-address {
+	height: auto;
+	min-height: 104rpx;
+	padding-top: 24rpx;
+	padding-bottom: 24rpx;
+}
+
+.address-wrap {
+	flex: 1;
+	margin-left: 20rpx;
+}
+
+.address-text {
+	font-size: 28rpx;
+	color: #9BA5B7;
+	text-align: right;
+	lines: 0;
+}
+
 .delbtn {
 	width: 654rpx;
 	height: 96rpx;

+ 7 - 2
code/ajyApp/pages/device/deviceInfo/deviceInfo.nvue

@@ -7,12 +7,12 @@
 				<view class="back-wrap" @click="goBack">
 					<image src="/static/public/back.png" mode="aspectFill" class="backimg"></image>
 				</view>
-				<text class="customTitle">{{picMode===1?'我的艾灸椅':'穴位编辑'}}</text>
+				<text class="customTitle">调试{{deviceType}}{{picMode===1?'我的艾灸椅':'穴位编辑'}}</text>
 				<view class="headRight">
 					<view class="userSwitchBtn" @click="goUserManage">
 						<text class="userSwitchTxt">{{currentUserName || '选择用户'}}</text>
 					</view>
-					<navigator :url="'/pages/device/detail/detail?name='+deviceName+'&deviceId='+deviceId+'&rssi='+rssi"
+					<navigator :url="'/pages/device/detail/detail?name='+deviceName+'&deviceId='+deviceId+'&rssi='+rssi+'&id='+id"
 						class="plus">
 						<image v-if="picMode===1" src="/static/public/view-list.png" mode="aspectFill" class="menuimg"></image>
 					</navigator>
@@ -337,6 +337,8 @@ export default {
 			statusBarHeight: 44,
 			deviceId: '',
 			deviceName: '',
+			deviceType:'',
+			id:'',
 			rssi: '',
 			isShowDrawer2: false,
 			modelist: ['无艾灸模式', '专业模式', '自定义模式', '延年圣手模式'],
@@ -369,6 +371,8 @@ export default {
 		this.deviceId = decodeURIComponent(options.deviceId || '')
 		this.deviceName = decodeURIComponent(options.name || '')
 		this.rssi = decodeURIComponent(options.rssi || '')
+		this.id= decodeURIComponent(options.id || '')
+		this.deviceType=decodeURIComponent(options.deviceType || '')
 
 		// TODO: 后续接口确定后实现 - 下载穴位列表
 		// TODO: 后续接口确定后实现 - 下载专业模式推荐方案
@@ -398,6 +402,7 @@ export default {
 		},
 		/** 加载当前用户信息 */
 		loadCurrentUser() {
+			uni.removeStorageSync('current_manage_user')
 			const user = uni.getStorageSync('current_manage_user')
 			if (user && user.name) {
 				this.currentUserName = user.name

+ 1 - 1
code/ajyApp/pages/device/list/list.nvue

@@ -80,7 +80,7 @@ export default {
 		},
 		goDeviceInfo(item) {
 			uni.navigateTo({
-				url: '/pages/device/deviceInfo/deviceInfo?name=' + encodeURIComponent(item.deviceName || item.name || '') + '&deviceId=' + encodeURIComponent(item.deviceId || '') + '&deviceCode=' + encodeURIComponent(item.deviceCode || '') + '&rssi=' + encodeURIComponent(item.RSSI || '')
+				url: '/pages/device/deviceInfo/deviceInfo?name=' + encodeURIComponent(item.deviceName || item.name || '') + '&deviceId=' + encodeURIComponent(item.deviceId || '') + '&deviceCode=' + encodeURIComponent(item.deviceCode || '') + '&rssi=' + encodeURIComponent(item.RSSI || '')+'&id=' + encodeURIComponent(item.id || '')+'&deviceType='+encodeURIComponent(item.deviceType || '')
 			})
 		},
 		/** 如果设备已连接,实时获取RSSI并更新缓存 */

+ 5 - 2
code/ajyApp/pages/device/search/search.nvue

@@ -155,6 +155,7 @@ export default {
 			console.log("要操作的设备",device)
 			let deviceList = []
 			try {
+				// uni.removeStorageSync('deviceList')
 				const data = uni.getStorageSync('deviceList')
 				console.log("deviceList",data)
 				if (data && data.length > 0) {
@@ -177,14 +178,16 @@ export default {
 				deviceList.push({
 					deviceId: device.deviceId,
 					name: device.name || '艾灸椅',
-					RSSI: device.RSSI
+					RSSI: device.RSSI,
+					id:res.id,
+					deviceType:res.deviceType
 				})
 				uni.setStorageSync('deviceList', deviceList)
 				this.$refs.ayToast.success('设备添加成功')
 
 				setTimeout(() => {
 					uni.redirectTo({
-						url: `/pages/device/deviceInfo/deviceInfo?deviceId=${encodeURIComponent(device.deviceId)}&name=${encodeURIComponent(device.name || '艾灸椅')}&rssi=${device.RSSI || ''}`
+						url: `/pages/device/deviceInfo/deviceInfo?deviceId=${encodeURIComponent(device.deviceId)}&name=${encodeURIComponent(device.name || '艾灸椅')}&rssi=${device.RSSI || ''}&deviceType=${res.deviceType}`
 					})
 				}, 1000)
 			} catch (e) {

+ 210 - 6
code/ajyApp/pages/my/version/version.nvue

@@ -16,16 +16,45 @@
 			<text class="version-code">V{{localVersion}}</text>
 			<text class="check-btn" @click="checkUpdate">检查更新</text>
 		</view>
+
+		<!-- 更新弹窗 -->
+		<view class="update-modal" v-if="showUpdateDialog">
+			<view class="update-mask" @click="closeUpdateDialog"></view>
+			<view class="update-dialog">
+				<text class="update-title">发现新版本</text>
+				<text class="update-version">V{{updateInfo.versionCode}}</text>
+				<text class="update-size" v-if="updateInfo.fileSize">{{updateInfo.fileSize}}</text>
+				<view class="update-content-box">
+					<text class="update-content">{{updateInfo.updateContent || '更新了一些功能和体验优化'}}</text>
+				</view>
+				<!-- 下载进度条 -->
+				<view class="progress-box" v-if="downloading">
+					<view class="progress-bar">
+						<view class="progress-fill" :style="{width: downloadProgress + '%'}"></view>
+					</view>
+					<text class="progress-text">{{downloadProgress}}%</text>
+				</view>
+				<text class="update-btn" @click="doUpdate" v-if="!downloading">立即更新</text>
+				<text class="update-btn update-btn-disabled" v-if="downloading">下载中...</text>
+				<text class="update-later" v-if="updateInfo.updateType !== 1 && !downloading" @click="closeUpdateDialog">稍后再说</text>
+			</view>
+		</view>
 	</view>
 </template>
 
 <script>
+import { checkUpdate } from '@/utils/api/version.js'
+
 export default {
 	data() {
 		return {
 			statusBarHeight: 44,
 			localVersion: '1.0.0',
-			platform: 'android'
+			platform: 'android',
+			showUpdateDialog: false,
+			updateInfo: {},
+			downloading: false,
+			downloadProgress: 0
 		}
 	},
 	onLoad() {
@@ -42,12 +71,95 @@ export default {
 		goBack() {
 			uni.navigateBack()
 		},
-		checkUpdate() {
-			// TODO: 后续对接后台接口检查更新
-			uni.showToast({
-				title: '已是最新版本',
-				icon: 'none'
+		async checkUpdate() {
+			const platformCode = this.platform === 'ios' ? 2 : 1
+			try {
+				const res = await checkUpdate(platformCode, this.localVersion)
+				console.log(res)
+				if (res && res.hasUpdate) {
+					this.updateInfo = res
+					this.showUpdateDialog = true
+					this.downloading = false
+					this.downloadProgress = 0
+				} else {
+					uni.showToast({
+						title: '已是最新版本',
+						icon: 'none'
+					})
+				}
+			} catch (e) {
+				uni.showToast({
+					title: '检查更新失败',
+					icon: 'none'
+				})
+			}
+		},
+		closeUpdateDialog() {
+			// 强制更新时不允许关闭
+			if (this.updateInfo.updateType === 1) {
+				return
+			}
+			this.showUpdateDialog = false
+		},
+		doUpdate() {
+			if (this.platform === 'ios') {
+				this.openAppStore()
+			} else {
+				this.downloadAndInstall()
+			}
+		},
+		// iOS: 跳转App Store
+		openAppStore() {
+			const url = this.updateInfo.downloadUrl
+			if (!url) {
+				uni.showToast({ title: '下载地址无效', icon: 'none' })
+				return
+			}
+			// #ifdef APP-PLUS
+			plus.runtime.openURL(url, (err) => {
+				uni.showToast({ title: '打开App Store失败', icon: 'none' })
+			})
+			// #endif
+		},
+		// Android: 下载APK并安装
+		downloadAndInstall() {
+			console.log("下载地址",this.updateInfo.downloadUrl)
+			const url = this.updateInfo.downloadUrl
+			if (!url) {
+				uni.showToast({ title: '下载地址无效', icon: 'none' })
+				return
+			}
+			this.downloading = true
+			this.downloadProgress = 0
+
+			// #ifdef APP-PLUS
+			const dtask = plus.downloader.createDownload(url, {
+				filename: '_downloads/update.apk'
+			}, (d, status) => {
+				if (status === 200) {
+					// 下载完成,安装APK
+					plus.runtime.install(d.filename, {
+						force: true
+					}, () => {
+						// 安装成功
+						plus.runtime.restart()
+					}, (err) => {
+						this.downloading = false
+						uni.showToast({ title: '安装失败,请重试', icon: 'none' })
+					})
+				} else {
+					this.downloading = false
+					uni.showToast({ title: '下载失败,请重试', icon: 'none' })
+				}
 			})
+			dtask.addEventListener('statechanged', (task, status) => {
+				// state=3 表示正在接收数据
+				if (task.state === 3 && task.totalSize > 0) {
+					this.downloadProgress = Math.round((task.downloadedSize / task.totalSize) * 100)
+				}
+			}, false)
+			dtask.start()
+			// #endif
 		}
 	}
 }
@@ -117,4 +229,96 @@ export default {
 	line-height: 88rpx;
 	text-align: center;
 }
+/* 更新弹窗 */
+.update-modal {
+	position: fixed;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	justify-content: center;
+	align-items: center;
+}
+.update-mask {
+	position: absolute;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	background-color: rgba(0, 0, 0, 0.5);
+}
+.update-dialog {
+	width: 600rpx;
+	background-color: #FFFFFF;
+	border-radius: 24rpx;
+	padding: 48rpx 40rpx;
+	align-items: center;
+}
+.update-title {
+	font-size: 36rpx;
+	font-weight: bold;
+	color: #333333;
+	margin-bottom: 12rpx;
+}
+.update-version {
+	font-size: 28rpx;
+	color: #999999;
+	margin-bottom: 8rpx;
+}
+.update-size {
+	font-size: 24rpx;
+	color: #BBBBBB;
+	margin-bottom: 24rpx;
+}
+.update-content-box {
+	width: 520rpx;
+	max-height: 300rpx;
+	margin-bottom: 36rpx;
+}
+.update-content {
+	font-size: 28rpx;
+	color: #666666;
+	line-height: 44rpx;
+	lines: 0;
+}
+.progress-box {
+	width: 520rpx;
+	align-items: center;
+	margin-bottom: 24rpx;
+}
+.progress-bar {
+	width: 520rpx;
+	height: 12rpx;
+	background-color: #EEEEEE;
+	border-radius: 6rpx;
+	overflow: hidden;
+}
+.progress-fill {
+	height: 12rpx;
+	background-color: #389588;
+	border-radius: 6rpx;
+}
+.progress-text {
+	font-size: 24rpx;
+	color: #389588;
+	margin-top: 8rpx;
+}
+.update-btn {
+	width: 520rpx;
+	height: 80rpx;
+	background-color: #389588;
+	border-radius: 16rpx;
+	color: #FFFFFF;
+	font-size: 30rpx;
+	line-height: 80rpx;
+	text-align: center;
+}
+.update-btn-disabled {
+	background-color: #AAAAAA;
+}
+.update-later {
+	margin-top: 20rpx;
+	font-size: 26rpx;
+	color: #999999;
+}
 </style>

+ 9 - 0
code/ajyApp/utils/api/device.js

@@ -21,3 +21,12 @@ export function bindDeviceByCode(deviceCode) {
 	console.log("请求参数",deviceCode)
 	return http.post('/app/user/device/bind-by-code', {deviceCode },{showError:true})
 }
+
+/**
+ * 获取设备详情
+ * @param {string|number} id - 设备ID
+ * @returns {Promise<{id, deviceCode, deviceName, deviceModel, deviceType, serialNo, firmwareVersion, onlineStatus, lastOnlineTime, provinceCode, cityCode, districtCode, address, remark, createTime, updateTime}>}
+ */
+export function getDeviceDetail(id) {
+	return http.get(`/app/device/${id}`, {showError: true})
+}

+ 19 - 0
code/ajyApp/utils/api/version.js

@@ -0,0 +1,19 @@
+/**
+ * 版本更新模块 API
+ */
+import http from '@/utils/http'
+
+/**
+ * 检查APP版本更新
+ * @param {number} platform - 平台:1=Android,2=iOS
+ * @param {string} versionCode - 当前版本号(如:1.0.0)
+ * @returns {Promise<{hasUpdate, versionCode, versionNumber, updateType, downloadUrl, updateContent, fileSize}>}
+ */
+export function checkUpdate(platform, versionCode) {
+	return http.get('/app/version/check', {
+		data: { platform, versionCode },
+		showLoading: false,
+		showError: false,
+		withToken: false
+	})
+}

+ 22 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/util/OssUtil.java

@@ -32,6 +32,9 @@ public class OssUtil {
     /** 允许的视频扩展名 */
     private static final List<String> VIDEO_EXTENSIONS = Arrays.asList("mp4", "mov", "avi", "wmv", "flv", "mkv");
 
+    /** 允许的安装包扩展名 */
+    private static final List<String> APK_EXTENSIONS = Arrays.asList("apk", "wgt");
+
     @Autowired
     private OssProperties ossProperties;
 
@@ -53,6 +56,25 @@ public class OssUtil {
         return doUpload(file, "image", ext);
     }
 
+    /**
+     * 上传APK/wgt安装包到 OSS
+     *
+     * @param file 上传文件
+     * @return 公网访问 URL
+     */
+    public String uploadApk(MultipartFile file) {
+        checkFile(file);
+        String ext = getExtension(file.getOriginalFilename());
+        if (!APK_EXTENSIONS.contains(ext.toLowerCase())) {
+            throw new BusinessException(ResultCode.FILE_TYPE_NOT_SUPPORTED);
+        }
+        // APK文件大小限制200MB
+        if (file.getSize() > 200 * 1024 * 1024L) {
+            throw new BusinessException(ResultCode.FILE_SIZE_EXCEEDED);
+        }
+        return doUpload(file, "apk", ext);
+    }
+
     /**
      * 上传视频到 OSS
      *

+ 39 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/AppUpdateController.java

@@ -0,0 +1,39 @@
+package com.aijiuyi.admin.controller;
+
+import com.aijiuyi.admin.common.annotation.NoAuth;
+import com.aijiuyi.admin.common.entity.Result;
+import com.aijiuyi.admin.controller.dto.AppVersionCheckVO;
+import com.aijiuyi.admin.service.AppVersionService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * APP端版本更新 Controller
+ * 提供APP客户端检查更新接口(无需Token)
+ */
+@RestController
+@RequestMapping("/app/version")
+public class AppUpdateController {
+
+    @Autowired
+    private AppVersionService appVersionService;
+
+    /**
+     * APP端检查更新
+     * 根据当前平台和版本号检查是否有新版本
+     *
+     * @param platform    平台:1=Android,2=iOS
+     * @param versionCode 当前版本号(如:1.0.0)
+     * @return 更新信息
+     */
+    @NoAuth
+    @GetMapping("/check")
+    public Result<AppVersionCheckVO> checkUpdate(
+            @RequestParam Integer platform,
+            @RequestParam String versionCode) {
+        return Result.success(appVersionService.checkUpdate(platform, versionCode));
+    }
+}

+ 100 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/AppVersionController.java

@@ -0,0 +1,100 @@
+package com.aijiuyi.admin.controller;
+
+import com.aijiuyi.admin.common.annotation.Log;
+import com.aijiuyi.admin.common.entity.Result;
+import com.aijiuyi.admin.common.enums.OperationType;
+import com.aijiuyi.admin.controller.dto.AppVersionQueryDTO;
+import com.aijiuyi.admin.controller.dto.AppVersionSaveDTO;
+import com.aijiuyi.admin.entity.AppVersion;
+import com.aijiuyi.admin.service.AppVersionService;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * APP版本管理 Controller(管理端)
+ * 提供版本的增删改查、启用/禁用接口
+ */
+@RestController
+@RequestMapping("/version")
+public class AppVersionController {
+
+    @Autowired
+    private AppVersionService appVersionService;
+
+    /**
+     * 分页查询版本列表
+     *
+     * @param queryDTO 查询条件
+     * @return 分页结果
+     */
+    @GetMapping("/page")
+    @Log(value = "查询版本列表", module = "版本管理", operationType = OperationType.QUERY)
+    public Result<IPage<AppVersion>> page(AppVersionQueryDTO queryDTO) {
+        return Result.success(appVersionService.pageList(queryDTO));
+    }
+
+    /**
+     * 查询版本详情
+     *
+     * @param id 版本ID
+     * @return 版本详情
+     */
+    @GetMapping("/{id}")
+    @Log(value = "查询版本详情", module = "版本管理", operationType = OperationType.QUERY)
+    public Result<AppVersion> getById(@PathVariable Long id) {
+        return Result.success(appVersionService.getById(id));
+    }
+
+    /**
+     * 新增版本
+     *
+     * @param dto 版本信息
+     * @return 操作结果
+     */
+    @PostMapping
+    @Log(value = "新增版本", module = "版本管理", operationType = OperationType.INSERT)
+    public Result<Void> add(@RequestBody AppVersionSaveDTO dto) {
+        appVersionService.add(dto);
+        return Result.success();
+    }
+
+    /**
+     * 编辑版本
+     *
+     * @param dto 版本信息
+     * @return 操作结果
+     */
+    @PutMapping
+    @Log(value = "编辑版本", module = "版本管理", operationType = OperationType.UPDATE)
+    public Result<Void> update(@RequestBody AppVersionSaveDTO dto) {
+        appVersionService.update(dto);
+        return Result.success();
+    }
+
+    /**
+     * 删除版本(逻辑删除)
+     *
+     * @param id 版本ID
+     * @return 操作结果
+     */
+    @DeleteMapping("/{id}")
+    @Log(value = "删除版本", module = "版本管理", operationType = OperationType.DELETE)
+    public Result<Void> delete(@PathVariable Long id) {
+        appVersionService.delete(id);
+        return Result.success();
+    }
+
+    /**
+     * 切换版本启用/禁用状态
+     *
+     * @param id 版本ID
+     * @return 操作结果
+     */
+    @PutMapping("/{id}/status")
+    @Log(value = "切换版本状态", module = "版本管理", operationType = OperationType.UPDATE)
+    public Result<Void> toggleStatus(@PathVariable Long id) {
+        appVersionService.toggleStatus(id);
+        return Result.success();
+    }
+}

+ 13 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/FileController.java

@@ -51,6 +51,19 @@ public class FileController {
         return Result.success(buildResult(url));
     }
 
+    /**
+     * 上传APK安装包到 OSS
+     *
+     * @param file APK文件
+     * @return 公网访问 URL
+     */
+    @PostMapping("/apk")
+    @Log(value = "上传APK", module = "文件管理", operationType = OperationType.OTHER)
+    public Result<Map<String, String>> uploadApk(@RequestParam("file") MultipartFile file) {
+        String url = ossUtil.uploadApk(file);
+        return Result.success(buildResult(url));
+    }
+
     /**
      * 构造返回结果(同时返回 url 字段和前端富文本编辑器常用的 data 结构)
      *

+ 31 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AppVersionCheckVO.java

@@ -0,0 +1,31 @@
+package com.aijiuyi.admin.controller.dto;
+
+import lombok.Data;
+
+/**
+ * APP端检查更新返回 VO
+ */
+@Data
+public class AppVersionCheckVO {
+
+    /** 是否有更新 */
+    private Boolean hasUpdate;
+
+    /** 最新版本号 */
+    private String versionCode;
+
+    /** 最新版本序号 */
+    private Integer versionNumber;
+
+    /** 更新类型:1=强制更新,2=推荐更新 */
+    private Integer updateType;
+
+    /** 下载地址 */
+    private String downloadUrl;
+
+    /** 更新内容描述 */
+    private String updateContent;
+
+    /** 安装包大小 */
+    private String fileSize;
+}

+ 25 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AppVersionQueryDTO.java

@@ -0,0 +1,25 @@
+package com.aijiuyi.admin.controller.dto;
+
+import lombok.Data;
+
+/**
+ * 版本管理列表查询 DTO
+ */
+@Data
+public class AppVersionQueryDTO {
+
+    /** 平台:1=Android,2=iOS,null=全部 */
+    private Integer platform;
+
+    /** 状态:0=禁用,1=启用,null=全部 */
+    private Integer status;
+
+    /** 版本号(模糊搜索) */
+    private String versionCode;
+
+    /** 当前页码(从1开始) */
+    private Integer pageNum = 1;
+
+    /** 每页条数 */
+    private Integer pageSize = 10;
+}

+ 37 - 0
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AppVersionSaveDTO.java

@@ -0,0 +1,37 @@
+package com.aijiuyi.admin.controller.dto;
+
+import lombok.Data;
+
+/**
+ * 版本新增/编辑请求 DTO
+ */
+@Data
+public class AppVersionSaveDTO {
+
+    /** 主键ID(新增时为null,编辑时必填) */
+    private Long id;
+
+    /** 版本号(如:1.0.1) */
+    private String versionCode;
+
+    /** 版本序号(用于比较大小,如:10001) */
+    private Integer versionNumber;
+
+    /** 平台:1=Android,2=iOS */
+    private Integer platform;
+
+    /** 更新类型:1=强制更新,2=推荐更新 */
+    private Integer updateType;
+
+    /** 下载地址(Android为APK的OSS链接,iOS为App Store链接) */
+    private String downloadUrl;
+
+    /** 更新内容描述 */
+    private String updateContent;
+
+    /** 安装包大小(如:"25.6MB") */
+    private String fileSize;
+
+    /** 状态:0=禁用,1=启用 */
+    private Integer status;
+}

+ 58 - 0
code/backend/src/main/java/com/aijiuyi/admin/entity/AppVersion.java

@@ -0,0 +1,58 @@
+package com.aijiuyi.admin.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * APP版本管理实体
+ * 对应数据库 app_version 表
+ */
+@Data
+@TableName("app_version")
+public class AppVersion {
+
+    /** 主键ID */
+    @TableId(type = IdType.ASSIGN_ID)
+    private Long id;
+
+    /** 版本号(如:1.0.1) */
+    private String versionCode;
+
+    /** 版本序号(用于比较大小,如:10001表示1.0.1) */
+    private Integer versionNumber;
+
+    /** 平台:1=Android,2=iOS */
+    private Integer platform;
+
+    /** 更新类型:1=强制更新,2=推荐更新 */
+    private Integer updateType;
+
+    /** 下载地址(Android为APK链接,iOS为App Store链接) */
+    private String downloadUrl;
+
+    /** 更新内容描述 */
+    private String updateContent;
+
+    /** 安装包大小(如:"25.6MB") */
+    private String fileSize;
+
+    /** 状态:0=禁用,1=启用 */
+    private Integer status;
+
+    /** 发布时间 */
+    private LocalDateTime publishTime;
+
+    /** 逻辑删除:0=未删除,1=已删除 */
+    @TableLogic
+    private Integer deleted;
+
+    /** 创建时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    /** 更新时间(自动填充) */
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 12 - 0
code/backend/src/main/java/com/aijiuyi/admin/mapper/AppVersionMapper.java

@@ -0,0 +1,12 @@
+package com.aijiuyi.admin.mapper;
+
+import com.aijiuyi.admin.entity.AppVersion;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * APP版本管理 Mapper
+ */
+@Mapper
+public interface AppVersionMapper extends BaseMapper<AppVersion> {
+}

+ 59 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/AppVersionService.java

@@ -0,0 +1,59 @@
+package com.aijiuyi.admin.service;
+
+import com.aijiuyi.admin.controller.dto.AppVersionCheckVO;
+import com.aijiuyi.admin.controller.dto.AppVersionQueryDTO;
+import com.aijiuyi.admin.controller.dto.AppVersionSaveDTO;
+import com.aijiuyi.admin.entity.AppVersion;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.service.IService;
+
+/**
+ * APP版本管理 Service 接口
+ */
+public interface AppVersionService extends IService<AppVersion> {
+
+    /**
+     * 分页查询版本列表
+     *
+     * @param queryDTO 查询条件
+     * @return 分页结果
+     */
+    IPage<AppVersion> pageList(AppVersionQueryDTO queryDTO);
+
+    /**
+     * 新增版本
+     *
+     * @param dto 版本信息
+     */
+    void add(AppVersionSaveDTO dto);
+
+    /**
+     * 编辑版本
+     *
+     * @param dto 版本信息
+     */
+    void update(AppVersionSaveDTO dto);
+
+    /**
+     * 删除版本(逻辑删除)
+     *
+     * @param id 版本ID
+     */
+    void delete(Long id);
+
+    /**
+     * 切换版本启用/禁用状态
+     *
+     * @param id 版本ID
+     */
+    void toggleStatus(Long id);
+
+    /**
+     * APP端检查更新
+     *
+     * @param platform    平台:1=Android,2=iOS
+     * @param versionCode 当前版本号(如:1.0.0)
+     * @return 更新信息
+     */
+    AppVersionCheckVO checkUpdate(Integer platform, String versionCode);
+}

+ 204 - 0
code/backend/src/main/java/com/aijiuyi/admin/service/impl/AppVersionServiceImpl.java

@@ -0,0 +1,204 @@
+package com.aijiuyi.admin.service.impl;
+
+import com.aijiuyi.admin.common.exception.BusinessException;
+import com.aijiuyi.admin.common.util.LogUtil;
+import com.aijiuyi.admin.controller.dto.AppVersionCheckVO;
+import com.aijiuyi.admin.controller.dto.AppVersionQueryDTO;
+import com.aijiuyi.admin.controller.dto.AppVersionSaveDTO;
+import com.aijiuyi.admin.entity.AppVersion;
+import com.aijiuyi.admin.mapper.AppVersionMapper;
+import com.aijiuyi.admin.service.AppVersionService;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * APP版本管理 Service 实现类
+ */
+@Service
+public class AppVersionServiceImpl extends ServiceImpl<AppVersionMapper, AppVersion> implements AppVersionService {
+
+    @Override
+    public IPage<AppVersion> pageList(AppVersionQueryDTO queryDTO) {
+        Page<AppVersion> page = new Page<>(queryDTO.getPageNum(), queryDTO.getPageSize());
+        LambdaQueryWrapper<AppVersion> wrapper = new LambdaQueryWrapper<>();
+        if (queryDTO.getPlatform() != null) {
+            wrapper.eq(AppVersion::getPlatform, queryDTO.getPlatform());
+        }
+        if (queryDTO.getStatus() != null) {
+            wrapper.eq(AppVersion::getStatus, queryDTO.getStatus());
+        }
+        if (StringUtils.hasText(queryDTO.getVersionCode())) {
+            wrapper.like(AppVersion::getVersionCode, queryDTO.getVersionCode());
+        }
+        wrapper.orderByDesc(AppVersion::getVersionNumber);
+        return page(page, wrapper);
+    }
+
+    @Override
+    public void add(AppVersionSaveDTO dto) {
+        validateDTO(dto);
+        // 检查同平台同版本号是否已存在
+        long count = lambdaQuery()
+                .eq(AppVersion::getPlatform, dto.getPlatform())
+                .eq(AppVersion::getVersionCode, dto.getVersionCode())
+                .count();
+        if (count > 0) {
+            throw new BusinessException("该平台已存在相同版本号");
+        }
+        AppVersion version = buildFromDTO(dto);
+        version.setPublishTime(LocalDateTime.now());
+        save(version);
+        LogUtil.info(AppVersionServiceImpl.class, "新增版本[{}] 平台[{}]", dto.getVersionCode(), dto.getPlatform());
+    }
+
+    @Override
+    public void update(AppVersionSaveDTO dto) {
+        if (dto.getId() == null) {
+            throw new BusinessException("版本ID不能为空");
+        }
+        AppVersion exist = getById(dto.getId());
+        if (exist == null) {
+            throw new BusinessException("版本记录不存在");
+        }
+        validateDTO(dto);
+        // 检查同平台同版本号是否已被其他记录占用
+        long count = lambdaQuery()
+                .eq(AppVersion::getPlatform, dto.getPlatform())
+                .eq(AppVersion::getVersionCode, dto.getVersionCode())
+                .ne(AppVersion::getId, dto.getId())
+                .count();
+        if (count > 0) {
+            throw new BusinessException("该平台已存在相同版本号");
+        }
+        AppVersion version = buildFromDTO(dto);
+        version.setId(dto.getId());
+        updateById(version);
+        LogUtil.info(AppVersionServiceImpl.class, "编辑版本[{}]", dto.getId());
+    }
+
+    @Override
+    public void delete(Long id) {
+        AppVersion exist = getById(id);
+        if (exist == null) {
+            throw new BusinessException("版本记录不存在");
+        }
+        removeById(id);
+        LogUtil.info(AppVersionServiceImpl.class, "删除版本[{}]", id);
+    }
+
+    @Override
+    public void toggleStatus(Long id) {
+        AppVersion exist = getById(id);
+        if (exist == null) {
+            throw new BusinessException("版本记录不存在");
+        }
+        AppVersion update = new AppVersion();
+        update.setId(id);
+        update.setStatus(exist.getStatus() == 1 ? 0 : 1);
+        updateById(update);
+        LogUtil.info(AppVersionServiceImpl.class, "切换版本[{}]状态为[{}]", id, update.getStatus());
+    }
+
+    @Override
+    public AppVersionCheckVO checkUpdate(Integer platform, String versionCode) {
+        AppVersionCheckVO vo = new AppVersionCheckVO();
+        vo.setHasUpdate(false);
+
+        if (platform == null || !StringUtils.hasText(versionCode)) {
+            return vo;
+        }
+
+        int currentVersionNumber = parseVersionNumber(versionCode);
+
+        // 查找该平台下所有已启用的版本,通过解析versionCode进行比较
+        List<AppVersion> versions = lambdaQuery()
+                .eq(AppVersion::getPlatform, platform)
+                .eq(AppVersion::getStatus, 1)
+                .list();
+
+        AppVersion latest = null;
+        int latestNumber = currentVersionNumber;
+        for (AppVersion v : versions) {
+            int vNumber = parseVersionNumber(v.getVersionCode());
+            if (vNumber > latestNumber) {
+                latestNumber = vNumber;
+                latest = v;
+            }
+        }
+
+        if (latest == null) {
+            return vo;
+        }
+
+        vo.setHasUpdate(true);
+        vo.setVersionCode(latest.getVersionCode());
+        vo.setVersionNumber(latest.getVersionNumber());
+        vo.setUpdateType(latest.getUpdateType());
+        vo.setDownloadUrl(latest.getDownloadUrl());
+        vo.setUpdateContent(latest.getUpdateContent());
+        vo.setFileSize(latest.getFileSize());
+        return vo;
+    }
+
+    /**
+     * 将版本号字符串解析为整数(如 "1.0.1" -> 10001, "1.2.0" -> 10200)
+     * 规则:major * 10000 + minor * 100 + patch
+     */
+    private int parseVersionNumber(String versionCode) {
+        try {
+            String[] parts = versionCode.split("\\.");
+            int major = parts.length > 0 ? Integer.parseInt(parts[0]) : 0;
+            int minor = parts.length > 1 ? Integer.parseInt(parts[1]) : 0;
+            int patch = parts.length > 2 ? Integer.parseInt(parts[2]) : 0;
+            return major * 10000 + minor * 100 + patch;
+        } catch (NumberFormatException e) {
+            return 0;
+        }
+    }
+
+    /**
+     * 校验DTO
+     */
+    private void validateDTO(AppVersionSaveDTO dto) {
+        if (!StringUtils.hasText(dto.getVersionCode())) {
+            throw new BusinessException("版本号不能为空");
+        }
+        if (dto.getPlatform() == null || (dto.getPlatform() != 1 && dto.getPlatform() != 2)) {
+            throw new BusinessException("平台参数不合法");
+        }
+        if (dto.getUpdateType() == null || (dto.getUpdateType() != 1 && dto.getUpdateType() != 2)) {
+            throw new BusinessException("更新类型不合法");
+        }
+        if (!StringUtils.hasText(dto.getDownloadUrl())) {
+            throw new BusinessException("下载地址不能为空");
+        }
+    }
+
+    /**
+     * 从DTO构建实体
+     */
+    private AppVersion buildFromDTO(AppVersionSaveDTO dto) {
+        AppVersion version = new AppVersion();
+        version.setVersionCode(dto.getVersionCode());
+        // versionNumber非必填,未填时自动从versionCode计算
+        if (dto.getVersionNumber() != null && dto.getVersionNumber() > 0) {
+            version.setVersionNumber(dto.getVersionNumber());
+        } else {
+            version.setVersionNumber(parseVersionNumber(dto.getVersionCode()));
+        }
+        version.setPlatform(dto.getPlatform());
+        version.setUpdateType(dto.getUpdateType());
+        version.setDownloadUrl(dto.getDownloadUrl());
+        version.setUpdateContent(dto.getUpdateContent());
+        version.setFileSize(dto.getFileSize());
+        version.setStatus(dto.getStatus() != null ? dto.getStatus() : 1);
+        return version;
+    }
+}

+ 1 - 0
code/backend/src/main/resources/application.yml

@@ -48,6 +48,7 @@ auth:
     - /api/auth/register
     - /api/app/auth/send-code
     - /api/app/auth/login
+    - /api/app/version/check
     - /api/article/view/**
     - /api/actuator/**
     - /api/doc.html

+ 5 - 0
code/backend/src/main/resources/mapper/AppVersionMapper.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.aijiuyi.admin.mapper.AppVersionMapper">
+
+</mapper>

+ 19 - 0
code/backend/src/main/resources/sql/migration_app_version.sql

@@ -0,0 +1,19 @@
+-- APP版本管理表
+CREATE TABLE IF NOT EXISTS `app_version` (
+    `id` bigint NOT NULL COMMENT '主键ID',
+    `version_code` varchar(20) NOT NULL COMMENT '版本号(如:1.0.1)',
+    `version_number` int NOT NULL COMMENT '版本序号(用于比较大小,如:10001)',
+    `platform` tinyint NOT NULL COMMENT '平台:1=Android,2=iOS',
+    `update_type` tinyint NOT NULL DEFAULT 2 COMMENT '更新类型:1=强制更新,2=推荐更新',
+    `download_url` varchar(500) NOT NULL COMMENT '下载地址(Android为APK链接,iOS为App Store链接)',
+    `update_content` text DEFAULT NULL COMMENT '更新内容描述',
+    `file_size` varchar(20) DEFAULT NULL COMMENT '安装包大小(如:25.6MB)',
+    `status` tinyint NOT NULL DEFAULT 1 COMMENT '状态:0=禁用,1=启用',
+    `publish_time` datetime DEFAULT NULL COMMENT '发布时间',
+    `deleted` tinyint NOT NULL DEFAULT 0 COMMENT '逻辑删除:0=未删除,1=已删除',
+    `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+    `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_platform_status` (`platform`, `status`, `deleted`),
+    KEY `idx_version_number` (`version_number`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='APP版本管理表';

+ 3 - 0
code/frontend/src/api/index.js

@@ -37,3 +37,6 @@ export * as contentApi from './content'
 
 // 用户分类管理模块
 export * as userCategoryApi from './userCategory'
+
+// 版本管理模块
+export * as versionApi from './version'

+ 68 - 0
code/frontend/src/api/version.js

@@ -0,0 +1,68 @@
+import request from '@/utils/request'
+
+/**
+ * 版本管理模块 API
+ * 对应后端 AppVersionController
+ */
+
+/**
+ * 分页查询版本列表
+ * @param {Object} params 查询参数:platform/status/versionCode/pageNum/pageSize
+ */
+export function getVersionPage(params) {
+  return request.get('/version/page', { params })
+}
+
+/**
+ * 查询版本详情
+ * @param {string} id 版本ID
+ */
+export function getVersionById(id) {
+  return request.get(`/version/${id}`)
+}
+
+/**
+ * 新增版本
+ * @param {Object} data 版本信息
+ */
+export function addVersion(data) {
+  return request.post('/version', data)
+}
+
+/**
+ * 编辑版本
+ * @param {Object} data 版本信息
+ */
+export function updateVersion(data) {
+  return request.put('/version', data)
+}
+
+/**
+ * 删除版本
+ * @param {string} id 版本ID
+ */
+export function deleteVersion(id) {
+  return request.delete(`/version/${id}`)
+}
+
+/**
+ * 切换版本状态(启用/禁用)
+ * @param {string} id 版本ID
+ */
+export function toggleVersionStatus(id) {
+  return request.put(`/version/${id}/status`)
+}
+
+/**
+ * 上传APK安装包到OSS
+ * @param {File} file APK文件
+ * @returns {Promise<{data:{url:string}}>}
+ */
+export function uploadApk(file) {
+  const formData = new FormData()
+  formData.append('file', file)
+  return request.post('/file/apk', formData, {
+    headers: { 'Content-Type': 'multipart/form-data' },
+    timeout: 300000 // APK文件较大,超时设为5分钟
+  })
+}

+ 6 - 0
code/frontend/src/router/index.js

@@ -130,6 +130,12 @@ const routes = [
             name: 'Log',
             component: () => import('@/views/log/index.vue'),
             meta: { requiresAuth: true, title: '系统日志', icon: 'Document' }
+          },
+          {
+            path: 'version',
+            name: 'AppVersion',
+            component: () => import('@/views/system/version/index.vue'),
+            meta: { requiresAuth: true, title: '版本管理', icon: 'Upload' }
           }
         ]
       }

+ 412 - 0
code/frontend/src/views/system/version/index.vue

@@ -0,0 +1,412 @@
+<template>
+  <div class="page-container">
+    <!-- 搜索区域 -->
+    <el-card class="search-card">
+      <el-form :model="query" inline>
+        <el-form-item label="平台">
+          <el-select v-model="query.platform" placeholder="全部" clearable style="width:120px">
+            <el-option label="Android" :value="1" />
+            <el-option label="iOS" :value="2" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-select v-model="query.status" placeholder="全部" clearable style="width:110px">
+            <el-option label="启用" :value="1" />
+            <el-option label="禁用" :value="0" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="版本号">
+          <el-input v-model="query.versionCode" placeholder="请输入版本号" clearable style="width:140px" />
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
+          <el-button :icon="Refresh" @click="handleReset">重置</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <!-- 操作按钮区 -->
+    <el-card class="action-card">
+      <div class="action-bar">
+        <el-button type="primary" :icon="Plus" @click="handleAdd">新增版本</el-button>
+      </div>
+    </el-card>
+
+    <!-- 表格区域 -->
+    <el-card class="table-card">
+      <el-table :data="tableData" v-loading="loading" border stripe>
+        <el-table-column type="index" label="序号" width="60" align="center" />
+        <el-table-column prop="versionCode" label="版本号" width="100" align="center" />
+        <el-table-column prop="versionNumber" label="版本序号" width="100" align="center" />
+        <el-table-column label="平台" width="100" align="center">
+          <template #default="{ row }">
+            <el-tag :type="row.platform === 1 ? 'success' : 'primary'" size="small">
+              {{ row.platform === 1 ? 'Android' : 'iOS' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="更新类型" width="100" align="center">
+          <template #default="{ row }">
+            <el-tag :type="row.updateType === 1 ? 'danger' : 'warning'" size="small">
+              {{ row.updateType === 1 ? '强制更新' : '推荐更新' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="downloadUrl" label="下载地址" min-width="200" show-overflow-tooltip />
+        <el-table-column prop="fileSize" label="包大小" width="90" align="center">
+          <template #default="{ row }">{{ row.fileSize || '-' }}</template>
+        </el-table-column>
+        <el-table-column label="状态" width="80" align="center">
+          <template #default="{ row }">
+            <el-switch
+              :model-value="row.status === 1"
+              @change="handleToggleStatus(row)"
+              inline-prompt
+              active-text="启"
+              inactive-text="禁"
+            />
+          </template>
+        </el-table-column>
+        <el-table-column prop="publishTime" label="发布时间" width="170">
+          <template #default="{ row }">{{ formatDateTime(row.publishTime) }}</template>
+        </el-table-column>
+        <el-table-column label="操作" width="130" align="center" fixed="right">
+          <template #default="{ row }">
+            <el-button size="small" type="warning" link @click="handleEdit(row)">编辑</el-button>
+            <el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <el-pagination
+        v-model:current-page="pageNum"
+        v-model:page-size="pageSize"
+        :page-sizes="[10, 20, 50]"
+        :total="total"
+        :hide-on-single-page="false"
+        layout="total, sizes, prev, pager, next, jumper"
+        background
+        class="pagination"
+        @size-change="loadData"
+        @current-change="loadData"
+      />
+    </el-card>
+
+    <!-- 新增/编辑弹窗 -->
+    <el-dialog
+      v-model="dialogVisible"
+      :title="dialogMode === 'add' ? '新增版本' : '编辑版本'"
+      width="580px"
+      destroy-on-close
+      :close-on-click-modal="false"
+    >
+      <el-form :model="formData" :rules="formRules" ref="formRef" label-width="100px">
+        <el-form-item label="平台" prop="platform">
+          <el-select v-model="formData.platform" placeholder="请选择平台" style="width:100%">
+            <el-option label="Android" :value="1" />
+            <el-option label="iOS" :value="2" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="版本号" prop="versionCode">
+          <el-input v-model="formData.versionCode" placeholder="如:1.0.1" maxlength="20" />
+        </el-form-item>
+        <el-form-item label="版本序号">
+          <el-input-number
+            v-model="formData.versionNumber"
+            :min="1"
+            :controls="false"
+            placeholder="选填,不填则自动计算"
+            style="width:100%"
+          />
+          <div class="form-tip">非必填,留空时根据版本号自动计算(如 1.0.1 = 10001)</div>
+        </el-form-item>
+        <el-form-item label="更新类型" prop="updateType">
+          <el-select v-model="formData.updateType" placeholder="请选择更新类型" style="width:100%">
+            <el-option label="强制更新" :value="1" />
+            <el-option label="推荐更新" :value="2" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="下载地址" prop="downloadUrl">
+          <!-- Android: 上传APK -->
+          <div v-if="formData.platform === 1" style="width:100%">
+            <el-upload
+              :show-file-list="false"
+              :http-request="handleApkUpload"
+              accept=".apk,.wgt"
+              :disabled="apkUploading"
+            >
+              <el-button type="primary" :loading="apkUploading">
+                {{ apkUploading ? '上传中...' : '上传APK安装包' }}
+              </el-button>
+            </el-upload>
+            <el-input
+              v-model="formData.downloadUrl"
+              placeholder="上传APK后自动填入,或手动输入下载地址"
+              style="margin-top: 8px"
+            />
+          </div>
+          <!-- iOS: 手动输入App Store链接 -->
+          <el-input
+            v-else
+            v-model="formData.downloadUrl"
+            placeholder="请输入App Store链接"
+          />
+        </el-form-item>
+        <el-form-item label="包大小" prop="fileSize">
+          <el-input v-model="formData.fileSize" placeholder="如:25.6MB(Android上传后自动填入)" />
+        </el-form-item>
+        <el-form-item label="更新内容" prop="updateContent">
+          <el-input
+            v-model="formData.updateContent"
+            type="textarea"
+            :rows="4"
+            placeholder="请输入更新内容描述"
+            maxlength="1000"
+            show-word-limit
+          />
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-switch
+            v-model="formData.status"
+            :active-value="1"
+            :inactive-value="0"
+            active-text="启用"
+            inactive-text="禁用"
+          />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="submitLoading" @click="submitForm">保存</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { Search, Refresh, Plus } from '@element-plus/icons-vue'
+import { formatDateTime } from '@/utils/date'
+import { parsePageData } from '@/utils/pagination'
+import {
+  getVersionPage,
+  addVersion,
+  updateVersion,
+  deleteVersion,
+  toggleVersionStatus,
+  uploadApk
+} from '@/api/version'
+
+// ======================== 列表数据 ========================
+const tableData = ref([])
+const total = ref(0)
+const loading = ref(false)
+const pageNum = ref(1)
+const pageSize = ref(10)
+
+const query = reactive({
+  platform: null,
+  status: null,
+  versionCode: ''
+})
+
+async function loadData() {
+  loading.value = true
+  try {
+    const res = await getVersionPage({
+      ...query,
+      pageNum: pageNum.value,
+      pageSize: pageSize.value
+    })
+    const { records, total: pageTotal } = parsePageData(res.data)
+    tableData.value = records
+    total.value = pageTotal
+  } finally {
+    loading.value = false
+  }
+}
+
+function handleSearch() {
+  pageNum.value = 1
+  loadData()
+}
+
+function handleReset() {
+  Object.assign(query, { platform: null, status: null, versionCode: '' })
+  pageNum.value = 1
+  loadData()
+}
+
+// ======================== 新增/编辑 ========================
+const dialogVisible = ref(false)
+const dialogMode = ref('add')
+const submitLoading = ref(false)
+const formRef = ref(null)
+const apkUploading = ref(false)
+
+const formData = reactive({
+  id: null,
+  platform: 1,
+  versionCode: '',
+  versionNumber: null,
+  updateType: 2,
+  downloadUrl: '',
+  updateContent: '',
+  fileSize: '',
+  status: 1
+})
+
+const formRules = {
+  platform: [{ required: true, message: '请选择平台', trigger: 'change' }],
+  versionCode: [{ required: true, message: '请输入版本号', trigger: 'blur' }],
+
+  updateType: [{ required: true, message: '请选择更新类型', trigger: 'change' }],
+  downloadUrl: [{ required: true, message: '请输入或上传下载地址', trigger: 'blur' }]
+}
+
+function handleAdd() {
+  dialogMode.value = 'add'
+  Object.assign(formData, {
+    id: null,
+    platform: 1,
+    versionCode: '',
+    versionNumber: null,
+    updateType: 2,
+    downloadUrl: '',
+    updateContent: '',
+    fileSize: '',
+    status: 1
+  })
+  dialogVisible.value = true
+}
+
+function handleEdit(row) {
+  dialogMode.value = 'edit'
+  Object.assign(formData, {
+    id: row.id,
+    platform: row.platform,
+    versionCode: row.versionCode,
+    versionNumber: row.versionNumber,
+    updateType: row.updateType,
+    downloadUrl: row.downloadUrl,
+    updateContent: row.updateContent || '',
+    fileSize: row.fileSize || '',
+    status: row.status
+  })
+  dialogVisible.value = true
+}
+
+async function submitForm() {
+  await formRef.value.validate()
+  submitLoading.value = true
+  try {
+    const payload = { ...formData }
+    if (dialogMode.value === 'add') {
+      await addVersion(payload)
+      ElMessage.success('新增成功')
+    } else {
+      await updateVersion(payload)
+      ElMessage.success('修改成功')
+    }
+    dialogVisible.value = false
+    loadData()
+  } finally {
+    submitLoading.value = false
+  }
+}
+
+// ======================== APK上传 ========================
+
+async function handleApkUpload({ file }) {
+  apkUploading.value = true
+  try {
+    const res = await uploadApk(file)
+    if (res?.data?.url) {
+      formData.downloadUrl = res.data.url
+      // 自动计算文件大小
+      formData.fileSize = formatFileSize(file.size)
+      ElMessage.success('APK上传成功')
+    }
+  } catch (e) {
+    // 拦截器已提示
+  } finally {
+    apkUploading.value = false
+  }
+}
+
+/**
+ * 格式化文件大小
+ */
+function formatFileSize(bytes) {
+  if (!bytes || bytes === 0) return ''
+  if (bytes < 1024) return bytes + 'B'
+  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB'
+  if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + 'MB'
+  return (bytes / (1024 * 1024 * 1024)).toFixed(2) + 'GB'
+}
+
+// ======================== 状态切换 ========================
+
+async function handleToggleStatus(row) {
+  await toggleVersionStatus(row.id)
+  ElMessage.success('状态切换成功')
+  loadData()
+}
+
+// ======================== 删除 ========================
+
+async function handleDelete(row) {
+  await ElMessageBox.confirm(
+    `确认删除版本「${row.versionCode}」(${row.platform === 1 ? 'Android' : 'iOS'})吗?`,
+    '删除确认',
+    { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' }
+  )
+  await deleteVersion(row.id)
+  ElMessage.success('删除成功')
+  loadData()
+}
+
+onMounted(loadData)
+</script>
+
+<style lang="scss" scoped>
+.page-container {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.search-card {
+  :deep(.el-card__body) {
+    padding: 18px 20px 4px;
+  }
+}
+
+.action-card {
+  :deep(.el-card__body) {
+    padding: 14px 20px;
+  }
+
+  .action-bar {
+    display: flex;
+    align-items: center;
+  }
+}
+
+.table-card {
+  flex: 1;
+}
+
+.pagination {
+  margin-top: 16px;
+  justify-content: flex-end;
+}
+
+.form-tip {
+  margin-top: 4px;
+  color: #909399;
+  font-size: 12px;
+  line-height: 1.4;
+}
+</style>