You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
254 lines
12 KiB
254 lines
12 KiB
<template>
|
|
<div>
|
|
<!-- 顶部 -->
|
|
<div class="flex items-center justify-between mb-5 flex-wrap gap-3">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-slate-800">版本控制</h2>
|
|
<p class="text-xs text-slate-400 mt-1">
|
|
按应用配置线上版本、强制更新与送审版本。<b>整个应用一套,不分渠道</b>——各商店的下载地址可在「渠道分发」里单独覆盖。
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<span class="text-sm text-slate-500">应用</span>
|
|
<select v-model="scopeApp" class="select select-bordered select-sm" @change="load">
|
|
<option value="">全局默认(所有应用兜底)</option>
|
|
<option v-for="a in scopeApps" :key="a" :value="a">{{ a }}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="!scopeApp" class="scope-tip">
|
|
当前是<b>全局默认</b>作用域:这里配的对所有应用生效;某个应用单独配了,则以应用的为准。
|
|
</div>
|
|
|
|
<div class="ver-wrap">
|
|
<!-- 左:表单 -->
|
|
<div class="card">
|
|
<section class="ed-section">
|
|
<div class="ed-section-title">线上版本</div>
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<span class="ed-label">当前线上版本</span>
|
|
<input v-model="form.version" class="input input-bordered input-sm w-full" placeholder="如 1.0.1" />
|
|
<p class="ed-hint">高于客户端本机版本时提示「有新版本」。填客户端上报的净版本号,<b>不含构建号</b>(<code>1.0.1</code> 而不是 <code>1.0.1+5</code>)。</p>
|
|
</div>
|
|
<div>
|
|
<span class="ed-label">送审版本号</span>
|
|
<input v-model="form.review_version" class="input input-bordered input-sm w-full" placeholder="过审后清空" />
|
|
<p class="ed-hint">客户端上报的版本正好等于它时,会隐藏未过审的功能。</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="ed-section">
|
|
<div class="ed-section-title">强制更新</div>
|
|
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
|
<input v-model="form.force_update" type="checkbox" class="checkbox checkbox-sm checkbox-primary" />
|
|
<span>开启强制更新</span>
|
|
</label>
|
|
<div v-if="form.force_update" class="pl-7 mt-2">
|
|
<span class="ed-label">最低支持版本(必填)</span>
|
|
<input v-model="form.min_version" class="input input-bordered input-sm w-full" placeholder="如 1.0.0" />
|
|
<p class="ed-hint">
|
|
低于这个版本的客户端会弹出<b>不可关闭</b>的升级框,无法继续使用。
|
|
不填等于开了强更却挡不住任何人。
|
|
</p>
|
|
</div>
|
|
<p v-if="versionWarn" class="ed-warn">⚠ {{ versionWarn }}</p>
|
|
</section>
|
|
|
|
<section class="ed-section">
|
|
<div class="ed-section-title">下载与说明</div>
|
|
<span class="ed-label">默认下载地址</span>
|
|
<input v-model="form.address" class="input input-bordered input-sm w-full" placeholder="应用商店链接或 apk 直链" />
|
|
<p class="ed-hint">
|
|
<b>各商店链接不一样</b>,这里填的是兜底值;华为/小米/App Store 等请在
|
|
<NuxtLink class="link" to="/channels">渠道分发</NuxtLink>
|
|
里给对应渠道填「本渠道下载地址」,渠道填了就以渠道的为准。
|
|
</p>
|
|
<div class="mt-3">
|
|
<span class="ed-label">更新日志</span>
|
|
<textarea v-model="form.changelog" class="textarea textarea-bordered w-full text-xs" rows="4" placeholder="本次更新内容,会下发给客户端在升级框里展示" />
|
|
</div>
|
|
</section>
|
|
|
|
<div class="ed-footer">
|
|
<button class="btn btn-ghost btn-sm" :disabled="submitting" @click="load">重置</button>
|
|
<button class="btn btn-primary btn-sm text-white" :disabled="submitting" @click="save">保存</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 右:判定预览。版本比较是这块最容易出错的地方,直接把结论摆出来 -->
|
|
<div class="card preview">
|
|
<div class="ed-section-title">判定预览</div>
|
|
<p class="text-xs text-slate-400 mb-3">输入一个客户端版本号,看它会被判成什么。与服务端 comm.CompareVersion 同一套逐段整数比较。</p>
|
|
<input v-model="probe" class="input input-bordered input-sm w-full" placeholder="如 1.0.1" />
|
|
<div class="probe-list">
|
|
<div class="probe-row">
|
|
<span>有更新可用</span>
|
|
<span class="badge" :class="probeResult.need ? 'on' : 'off'">{{ probeResult.need ? '是' : '否' }}</span>
|
|
</div>
|
|
<div class="probe-row">
|
|
<span>必须更新(阻断使用)</span>
|
|
<span class="badge" :class="probeResult.force ? 'warn' : 'off'">{{ probeResult.force ? '是' : '否' }}</span>
|
|
</div>
|
|
<div class="probe-row">
|
|
<span>处于审核期</span>
|
|
<span class="badge" :class="probeResult.reviewing ? 'on' : 'off'">{{ probeResult.reviewing ? '是' : '否' }}</span>
|
|
</div>
|
|
</div>
|
|
<p v-if="!probe.trim()" class="ed-hint mt-3">
|
|
客户端没上报版本号时三项<b>一律为否</b>——拿不到版本就判强更会把所有老客户端挡在门外。
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, reactive, computed, onMounted } from 'vue'
|
|
import { useApi } from '~/composables/useApi'
|
|
import { useToast } from '~/composables/useToast'
|
|
|
|
definePageMeta({ title: '版本控制' })
|
|
|
|
interface AppRelease {
|
|
version: string
|
|
min_version: string
|
|
force_update: boolean
|
|
changelog: string
|
|
address: string
|
|
review_version: string
|
|
}
|
|
|
|
const { webApi, consoleApi } = useApi()
|
|
const { success, error } = useToast()
|
|
|
|
const scopeApp = ref('')
|
|
const scopeApps = ref<string[]>([])
|
|
const submitting = ref(false)
|
|
const probe = ref('')
|
|
|
|
function emptyForm(): AppRelease {
|
|
return { version: '', min_version: '', force_update: false, changelog: '', address: '', review_version: '' }
|
|
}
|
|
const form = reactive<AppRelease>(emptyForm())
|
|
|
|
// 与后端 comm.CompareVersion 同语义:切掉构建号、逐段按整数比、短的补 0、段内取前导数字。
|
|
// 后台这份只用于即时提示,真正的判定在服务端——两边不一致时以服务端为准。
|
|
function cmpVer(a: string, b: string): number {
|
|
const as = a.split('+')[0]!.trim().split('.')
|
|
const bs = b.split('+')[0]!.trim().split('.')
|
|
const n = Math.max(as.length, bs.length)
|
|
for (let i = 0; i < n; i++) {
|
|
const x = parseInt(as[i] ?? '0') || 0
|
|
const y = parseInt(bs[i] ?? '0') || 0
|
|
if (x !== y) return x < y ? -1 : 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
const versionWarn = computed(() => {
|
|
if (form.force_update && !form.min_version.trim()) {
|
|
return '开启强制更新时必须填「最低支持版本」,否则挡不住任何人'
|
|
}
|
|
if (form.min_version.trim() && form.version.trim() && cmpVer(form.min_version, form.version) > 0) {
|
|
return '「最低支持版本」高于「当前线上版本」,会把所有线上用户判定为需强更却无更高版本可更'
|
|
}
|
|
return ''
|
|
})
|
|
|
|
// 与服务端 evalChannelUpgrade 逐条对齐(含「强更必然意味着有更新」这条兜底)
|
|
const probeResult = computed(() => {
|
|
const v = probe.value.trim()
|
|
if (!v) return { need: false, force: false, reviewing: false }
|
|
const need = !!form.version.trim() && cmpVer(v, form.version) < 0
|
|
const force = form.force_update && !!form.min_version.trim() && cmpVer(v, form.min_version) < 0
|
|
const reviewing = !!form.review_version.trim() && cmpVer(v, form.review_version) === 0
|
|
return { need: need || force, force, reviewing }
|
|
})
|
|
|
|
// 应用名来源:应用中心的应用表 + 部署注册表(兜底老数据);无权读这两张表时改走 api_getmyapps。
|
|
async function loadScopeApps() {
|
|
const names = new Set<string>()
|
|
const [products, deploys] = await Promise.all([
|
|
consoleApi<Array<{ name: string }>>('products/list', {}).catch(() => [] as Array<{ name: string }>),
|
|
consoleApi<Array<{ app_name?: string; name: string }>>('apps/list', {}).catch(() => [] as Array<{ app_name?: string; name: string }>),
|
|
])
|
|
;(products ?? []).forEach((p) => p.name && names.add(p.name))
|
|
;(deploys ?? []).forEach((d) => (d.app_name || d.name) && names.add(d.app_name || d.name))
|
|
if (!names.size) {
|
|
try {
|
|
const d = await webApi<{ apps: Array<{ name: string; app_name?: string }> }>('getmyapps', {})
|
|
;(d?.apps ?? []).forEach((a) => {
|
|
const n = a.app_name || a.name
|
|
if (n) names.add(n)
|
|
})
|
|
} catch {
|
|
// 拿不到就只剩「全局默认」,不阻断页面
|
|
}
|
|
}
|
|
scopeApps.value = [...names].sort()
|
|
}
|
|
|
|
async function load() {
|
|
try {
|
|
const d = await webApi<{ item: AppRelease }>('getapprelease', { app_name: scopeApp.value })
|
|
Object.assign(form, emptyForm(), d?.item ?? {})
|
|
} catch (e: any) {
|
|
error(e?.message ?? '加载失败')
|
|
Object.assign(form, emptyForm())
|
|
}
|
|
}
|
|
|
|
async function save() {
|
|
if (versionWarn.value) { error(versionWarn.value); return }
|
|
submitting.value = true
|
|
try {
|
|
// 只提交版本这一组字段:游客登录配置和它同一行,整行提交会把对方清空。
|
|
await webApi('saveappversion', {
|
|
app_name: scopeApp.value,
|
|
version: form.version,
|
|
min_version: form.min_version,
|
|
force_update: form.force_update,
|
|
changelog: form.changelog,
|
|
address: form.address,
|
|
review_version: form.review_version,
|
|
})
|
|
success('保存成功')
|
|
await load()
|
|
} catch (e: any) {
|
|
error(e?.message ?? '保存失败')
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await loadScopeApps()
|
|
await load()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.scope-tip { background: #f8fafc; border: 1px solid #e2e8f0; color: #475569; font-size: 12px; padding: 8px 12px; border-radius: 10px; margin-bottom: 16px; }
|
|
.ver-wrap { display: grid; gap: 16px; grid-template-columns: minmax(0, 2fr) minmax(260px, 1fr); align-items: start; }
|
|
@media (max-width: 900px) { .ver-wrap { grid-template-columns: 1fr; } }
|
|
.card { background: #fff; border: 1px solid #eef2f7; border-radius: 14px; padding: 18px 20px; }
|
|
.ed-section { padding-bottom: 16px; margin-bottom: 16px; border-bottom: 1px solid #f1f5f9; }
|
|
.ed-section:last-of-type { border-bottom: 0; margin-bottom: 0; }
|
|
.ed-section-title { font-size: 13px; font-weight: 700; color: #334155; margin-bottom: 10px; }
|
|
.ed-label { display: block; font-size: 12px; color: #64748b; margin-bottom: 4px; }
|
|
.ed-hint { font-size: 11px; color: #94a3b8; margin-top: 4px; line-height: 1.6; }
|
|
.ed-warn { font-size: 12px; color: #b45309; background: #fffbeb; border: 1px solid #fde68a; border-radius: 8px; padding: 6px 10px; margin-top: 10px; }
|
|
.ed-footer { display: flex; justify-content: flex-end; gap: 8px; padding-top: 14px; border-top: 1px solid #f1f5f9; }
|
|
.preview { position: sticky; top: 16px; }
|
|
.probe-list { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; }
|
|
.probe-row { display: flex; align-items: center; justify-content: space-between; font-size: 12px; color: #475569; }
|
|
.badge { font-size: 11px; font-weight: 700; padding: 2px 10px; border-radius: 8px; }
|
|
.badge.on { background: #dcfce7; color: #059669; }
|
|
.badge.warn { background: #ffe4e6; color: #be123c; }
|
|
.badge.off { background: #f1f5f9; color: #94a3b8; }
|
|
.link { color: #4f46e5; text-decoration: underline; }
|
|
code { background: #f1f5f9; padding: 0 4px; border-radius: 4px; }
|
|
</style>
|
|
|