feat: add PC admin App Version Management page with APK upload

- Backend: CRUD endpoints for app versions (list, create, delete, download)
- Backend: APK file upload with multipart form data
- Backend: Version data stored in JSON file, APK files in data/app_versions/
- Frontend: Vue 3 + Element Plus page at /app-versions
- Frontend: Drag-and-drop APK upload, version publish, force update config
- Frontend: Version history table with "set as latest" and delete actions
- Menu item under System Settings (admin only)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 22:37:19 +08:00
parent 92ae0b5e46
commit fee1df2c2f
4 changed files with 433 additions and 17 deletions

View File

@@ -104,7 +104,7 @@ import {
Grid, Document, User, ChatLineSquare, Share, UserFilled,
Clock, Monitor, List, Connection, Setting, Star, Shop,
Tools, Bell, Tickets, OfficeBuilding, Lock, ArrowDown, DataAnalysis,
Sunny, Moon
Sunny, Moon, Iphone
} from '@element-plus/icons-vue'
import GlobalSearch from '@/components/GlobalSearch.vue'
import BreadcrumbNav from '@/components/BreadcrumbNav.vue'
@@ -189,6 +189,7 @@ const menuGroups = computed<MenuGroup[]>(() => {
{ index: 'model-configs', label: '模型配置管理', description: '管理LLM模型', path: '/model-configs', icon: Setting },
{ index: 'workspaces', label: '工作区', description: '管理多工作区', path: '/workspaces', pathPattern: '/workspace', icon: OfficeBuilding },
{ index: 'permissions', label: '权限管理', description: '用户权限管理', path: '/permissions', icon: Lock, adminOnly: true },
{ index: 'app-versions', label: 'App版本管理', description: '发布App新版本', path: '/app-versions', icon: Iphone, adminOnly: true },
],
},
]

View File

@@ -207,6 +207,12 @@ const router = createRouter({
name: 'teams',
component: () => import('@/views/TeamBuilder.vue'),
meta: { requiresAuth: true }
},
{
path: '/app-versions',
name: 'app-versions',
component: () => import('@/views/AppVersionManage.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
}
]
})

View File

@@ -0,0 +1,248 @@
<template>
<MainLayout>
<div class="app-version-page">
<el-card>
<template #header>
<div class="card-header">
<h2>App 版本管理</h2>
</div>
</template>
<!-- 当前最新版本 -->
<el-alert
v-if="latest.version"
:title="`当前已发布: v${latest.version} (${latest.platform || 'android'})`"
type="success"
:closable="false"
show-icon
style="margin-bottom: 20px"
/>
<el-alert
v-else
title="尚未发布任何版本"
type="warning"
:closable="false"
show-icon
style="margin-bottom: 20px"
/>
<!-- 发布新版本 -->
<el-divider content-position="left">发布新版本</el-divider>
<el-form :model="form" label-width="120px" style="max-width: 600px">
<el-form-item label="APK 文件">
<el-upload
ref="uploadRef"
:auto-upload="false"
:limit="1"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
accept=".apk"
drag
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">拖拽 APK 文件到此处 <em>点击上传</em></div>
</el-upload>
</el-form-item>
<el-form-item label="版本号" required>
<el-input v-model="form.version" placeholder="如 1.0.1" style="width: 200px" />
</el-form-item>
<el-form-item label="强制更新最低版本">
<el-input v-model="form.forceUpdateMin" placeholder="低于此版本将强制更新,如 1.0.0" />
<div style="font-size: 12px; color: #909399; margin-top: 4px">
当前版本低于此值时客户端将强制要求更新不可跳过
</div>
</el-form-item>
<el-form-item label="更新日志">
<el-input
v-model="form.releaseNotes"
type="textarea"
:rows="4"
placeholder="描述本次更新内容,支持换行"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handlePublish" :loading="publishing">
<el-icon><Upload /></el-icon>
{{ form.version && latest.version && form.version !== latest.version ? '发布新版本' : '更新发布' }}
</el-button>
</el-form-item>
</el-form>
<!-- 版本历史 -->
<el-divider content-position="left">版本历史</el-divider>
<el-table :data="versions" stripe>
<el-table-column prop="version" label="版本号" width="120" />
<el-table-column prop="platform" label="平台" width="80" />
<el-table-column prop="release_notes" label="更新日志" min-width="200" />
<el-table-column prop="force_update_min" label="强制更新阈值" width="140" />
<el-table-column prop="created_at" label="发布时间" width="180">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button
size="small"
type="primary"
link
@click="handleSetLatest(row)"
>
设为最新
</el-button>
<el-popconfirm
title="确定删除此版本?"
@confirm="handleDelete(row.version)"
>
<template #reference>
<el-button size="small" type="danger" link>删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</MainLayout>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Upload, UploadFilled } from '@element-plus/icons-vue'
import MainLayout from '@/components/MainLayout.vue'
import api from '@/api'
import type { UploadFile, UploadInstance } from 'element-plus'
const uploadRef = ref<UploadInstance>()
const publishing = ref(false)
const latest = ref<Record<string, any>>({})
const versions = ref<Array<Record<string, any>>>([])
const selectedFile = ref<File | null>(null)
const form = reactive({
version: '',
forceUpdateMin: '',
releaseNotes: '',
})
async function loadData() {
try {
const res = await api.get('/api/v1/app/versions')
latest.value = res.data.latest || {}
versions.value = res.data.versions || []
// 预填表单
if (latest.value.version && !form.version) {
form.version = bumpVersion(latest.value.version)
form.forceUpdateMin = latest.value.force_update_min || ''
form.releaseNotes = ''
}
} catch (e: any) {
ElMessage.error('加载版本列表失败')
}
}
function bumpVersion(v: string): string {
const parts = v.split('.').map(Number)
parts[parts.length - 1]++
return parts.join('.')
}
function handleFileChange(file: UploadFile) {
selectedFile.value = file.raw || null
}
function handleFileRemove() {
selectedFile.value = null
}
async function handlePublish() {
if (!form.version) {
ElMessage.warning('请输入版本号')
return
}
publishing.value = true
try {
const fd = new FormData()
if (selectedFile.value) {
fd.append('file', selectedFile.value)
}
fd.append('version', form.version)
fd.append('platform', 'android')
fd.append('force_update_min', form.forceUpdateMin)
fd.append('release_notes', form.releaseNotes)
await api.post('/api/v1/app/versions', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
ElMessage.success(`版本 ${form.version} 发布成功!`)
uploadRef.value?.clearFiles()
selectedFile.value = null
form.version = bumpVersion(form.version)
form.releaseNotes = ''
await loadData()
} catch (e: any) {
const msg = e.response?.data?.detail || e.message || '发布失败'
ElMessage.error(msg)
} finally {
publishing.value = false
}
}
async function handleSetLatest(row: Record<string, any>) {
try {
const fd = new FormData()
fd.append('version', row.version)
fd.append('platform', row.platform)
fd.append('force_update_min', row.force_update_min || '')
fd.append('release_notes', row.release_notes || '')
await api.post('/api/v1/app/versions', fd)
ElMessage.success(`已将 v${row.version} 设为最新版本`)
await loadData()
} catch (e: any) {
ElMessage.error(e.message || '操作失败')
}
}
async function handleDelete(versionStr: string) {
try {
await api.delete(`/api/v1/app/versions/${versionStr}`)
ElMessage.success('版本已删除')
await loadData()
} catch (e: any) {
ElMessage.error(e.message || '删除失败')
}
}
function formatDate(d: string): string {
if (!d) return '-'
try {
return new Date(d).toLocaleString('zh-CN')
} catch {
return d
}
}
onMounted(loadData)
</script>
<style scoped>
.app-version-page {
max-width: 900px;
margin: 0 auto;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header h2 {
margin: 0;
}
</style>