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.
 
 
 
 
 
 

352 lines
18 KiB

<template>
<div>
<div class="mb-4">
<div class="text-sm font-semibold text-slate-700">发版配置</div>
<div class="text-xs text-slate-400 mt-1 max-w-3xl">
线上版本、强制更新、送审版本与更新日志。<b>Android / iOS 各一套版本号</b>(iOS 审核慢,两端线上版本经常不同步),
其余整个应用一套、不分渠道;下面的「渠道分发」只覆盖各商店各自不同的那部分。
</div>
</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 class="plat-col">
<div class="plat-title">Android</div>
<span class="ed-label">当前线上版本</span>
<input v-model="form.version" class="input input-bordered input-sm w-full" placeholder="如 1.0.3" />
<span class="ed-label mt-2">最低支持版本(可留空)</span>
<input v-model="form.min_version" class="input input-bordered input-sm w-full" placeholder="留空 = 线上版本" />
</div>
<div class="plat-col">
<div class="plat-title">iOS</div>
<span class="ed-label">当前线上版本</span>
<input v-model="form.ios_version" class="input input-bordered input-sm w-full" placeholder="如 1.0.2" />
<span class="ed-label mt-2">最低支持版本(可留空)</span>
<input v-model="form.ios_min_version" class="input input-bordered input-sm w-full" placeholder="留空 = 线上版本" />
</div>
</div>
<p class="ed-hint">
客户端低于本平台「线上版本」时提示<b>推荐更新</b>(可点「稍后提醒」,同一版本 24 小时内不再弹)。
填客户端上报的净版本号,<b>不含构建号</b>(<code>1.0.1</code> 而不是 <code>1.0.1+5</code>)。
iOS 那一栏留空 = iOS 不提示任何更新,<b>不会拿 Android 的版本去判 iOS</b>。
</p>
<div class="mt-3">
<span class="ed-label">送审版本号</span>
<input v-model="form.review_version" class="input input-bordered input-sm w-full max-w-xs" placeholder="过审后清空" />
<p class="ed-hint">客户端上报的版本正好等于它时,会隐藏未过审的功能。</p>
</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>
<p class="ed-hint pl-7">
开启后,低于本平台「最低支持版本」的客户端会弹出<b>不可关闭</b>的升级框,无法继续使用。
最低支持版本<b>留空 = 线上版本</b>,即所有低版本都强更;填了则只强更低于它的,介于两者之间的仍是推荐更新。
强更不受渠道上架状态限制:渠道商店里还没有新包时,下载地址会自动换成官方渠道的包。
</p>
<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>
<div class="flex gap-2">
<input v-model="form.address" class="input input-bordered input-sm w-full" :placeholder="DIST_DOMAIN" />
<button class="btn btn-outline btn-sm whitespace-nowrap" :disabled="apkUploading" @click="pickApk">
{{ apkUploading ? `上传中 ${apkPercent}%` : '上传 APK' }}
</button>
<input ref="apkInput" type="file" accept=".apk,application/vnd.android.package-archive" class="hidden" @change="onApkPicked" />
</div>
<progress v-if="apkUploading" class="progress progress-primary w-full mt-2 h-1.5" :value="apkPercent" max="100" />
<p class="ed-hint">
<b>推荐填网页下载页 <code>{{ DIST_DOMAIN }}</code></b>:那个页面会按手机品牌自动跳对应商店,
认不出品牌就直接给安装包。客户端点「立即更新」时,本渠道在<b>下方「渠道分发」</b>
里填了地址就用渠道的,<b>没填就回退到这里</b>——所以这一个地址能兜住所有渠道。
</p>
<p class="ed-hint">
「上传 APK」把安装包传到分发桶的 <code>android/{{ scopeApp || '应用名' }}/</code> 目录
(按应用分目录,不同应用的包不会互相覆盖)。传完后安装包的<b>下载直链会填进上面的输入框</b>,
可直接作为官方包的下载地址;同时<b>自动写进下方「渠道分发 → 官方渠道(100)」</b>——
网页下载页的安装包直链取的就是那一条。<b>输入框里的地址要点「保存」才会生效。</b>
</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>
<div class="flex gap-2 mb-2">
<button class="btn btn-xs" :class="probePlatform === 'android' ? 'btn-primary text-white' : 'btn-ghost'" @click="probePlatform = 'android'">Android</button>
<button class="btn btn-xs" :class="probePlatform === 'ios' ? 'btn-primary text-white' : 'btn-ghost'" @click="probePlatform = 'ios'">iOS</button>
</div>
<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>
<p class="ed-hint mt-3">
这里不含两条与渠道有关的规则:渠道<b>未上架</b>(审核中/已下架)时不推送推荐更新;
算不出任何下载地址时两种更新都不提示。
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
interface AppRelease {
version: string
min_version: string
force_update: boolean
changelog: string
address: string
review_version: string
ios_version: string
ios_min_version: string
}
const { webApi, consoleApi, uploadFile } = useApi()
const { success, error } = useToast()
// 分发桶的公开域名,仅用于界面提示;真实地址以后端返回的为准
// (后端读【第三方服务配置 → 存储】的 dist_domain,留空时用这个默认值)。
const DIST_DOMAIN = 'https://dl.ymaikj.com'
const apkInput = ref<HTMLInputElement | null>(null)
const apkUploading = ref(false)
const apkPercent = ref(0)
function pickApk() {
apkInput.value?.click()
}
async function onApkPicked(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
// ⚠️ 先把 input 清空再做别的:不清的话选同一个文件第二次不会触发 change,
// 表现为「上传失败后重选同一个包没反应」。
input.value = ''
if (!file) return
if (!file.name.toLowerCase().endsWith('.apk')) {
error('请选择 .apk 文件')
return
}
// 安装包按应用分目录存,没选应用就不知道往哪放。后端也会拦(缺 X-App-Id 直接拒),
// 这里先挡一次是为了给一句人话,而不是让用户对着「缺少 X-App-Id」发懵。
if (!scopeApp.value) {
error('请先在页面顶部选择应用,再上传安装包')
return
}
apkUploading.value = true
apkPercent.value = 0
try {
const url = await uploadFile(file, 'android', {
target: 'dist',
appName: scopeApp.value,
onProgress: (p) => { apkPercent.value = p },
})
// 传完的直链要让人看得见、拿得走:填进「默认下载地址」框(2026-09-23 用户要求),
// 这个地址就是官方包的下载地址。填进表单不等于已保存,所以提示里点明要点「保存」。
form.address = url
await writeOfficialChannelAddress(url)
emit('uploaded', url)
success('安装包已上传:地址已填入「默认下载地址」并写入「官方渠道」,记得点「保存」')
} catch (err: any) {
error(err?.message || '上传失败')
} finally {
apkUploading.value = false
}
}
// 官方渠道的渠道号,与客户端 UserChannel.OFFICIAL 一致。
// 网页下载页的安装包直链取的就是这一条,所以传完包要落在这里。
const CHANNEL_OFFICIAL = 100
// 把新包地址写进「渠道分发 → 官方渠道」。
//
// ⚠️ savechannelapp 是**整行覆盖**(后端 ShouldBindJSON 到整个 comm.ChannelApp 再 Save),
// 所以必须先读回整行、只改 address 再写回。只发 {channel, address} 会把上架状态、
// 支付渠道、是否允许跳过设备绑定这些字段统统清零 —— 而且不会报任何错。
async function writeOfficialChannelAddress(address: string) {
// ⚠️ 这个接口回的是 { items: [...] } 而不是裸数组。当成数组取会永远找不到已有行,
// 于是每次都走「新建」分支,把官方渠道已有的上架状态/支付渠道/描述统统覆盖成默认值。
const d = await webApi<{ items?: any[] }>('getchannelapps', { app_name: scopeApp.value })
const old = (d?.items ?? []).find((r: any) => Number(r?.channel) === CHANNEL_OFFICIAL)
const row = old
? { ...old, address }
: {
// 这个渠道还不存在就建一条。enable 必须为 true、status 不能是草稿(0),
// 否则服务端 getChannelApps 会整条跳过,等于传了包却没人取得到。
app_name: scopeApp.value,
channel: CHANNEL_OFFICIAL,
address,
status: 1, // 已上架
enable: true,
description: '官方渠道(网页下载页的安装包直链)',
paychannels: '',
allowskipdevicebinding: false,
}
await webApi('savechannelapp', { ...row, app_name: scopeApp.value })
}
// 作用域由外层「版本与分发」页统一给:两块配置共用一个应用选择器。
// 用 computed 顶替原来的 scopeApp ref,正文里所有 scopeApp.value 的写法原样保留 ——
// 逐个改成 props.appName 只会在这次合并里多制造几十处可能改漏的地方。
const props = defineProps<{ appName: string }>()
// 传完安装包通知外层:下方「渠道分发」面板要重新拉一次,才能看到刚写进官方渠道的地址。
const emit = defineEmits<{ (e: 'uploaded', url: string): void }>()
const scopeApp = computed(() => props.appName)
const submitting = ref(false)
const probe = ref('')
function emptyForm(): AppRelease {
return { version: '', min_version: '', force_update: false, changelog: '', address: '', review_version: '', ios_version: '', ios_min_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.version.trim() && !form.ios_version.trim()) {
return '开启强制更新时至少要填一个平台的「当前线上版本」,否则强更没有任何效果'
}
if (form.min_version.trim() && form.version.trim() && cmpVer(form.min_version, form.version) > 0) {
return 'Android「最低支持版本」高于「当前线上版本」,会把所有线上用户判定为需强更却无更高版本可更'
}
if (form.ios_min_version.trim() && form.ios_version.trim() && cmpVer(form.ios_min_version, form.ios_version) > 0) {
return 'iOS「最低支持版本」高于「当前线上版本」,会把所有线上用户判定为需强更却无更高版本可更'
}
return ''
})
// 判定预览按平台看:与服务端 comm.AppRelease.VersionFor / MinVersionFor 同口径(最低版本留空 = 线上版本)。
const probePlatform = ref<'android' | 'ios'>('android')
// 与服务端 evalChannelUpgrade 逐条对齐(含「强更必然意味着有更新」这条兜底);
// 渠道上架状态与下载地址那两条规则依赖渠道行,这里不模拟。
const probeResult = computed(() => {
const v = probe.value.trim()
if (!v) return { need: false, force: false, reviewing: false }
const ver = (probePlatform.value === 'ios' ? form.ios_version : form.version).trim()
const minRaw = (probePlatform.value === 'ios' ? form.ios_min_version : form.min_version).trim()
const min = minRaw || ver
const need = !!ver && cmpVer(v, ver) < 0
const force = form.force_update && !!min && cmpVer(v, min) < 0
const reviewing = !!form.review_version.trim() && cmpVer(v, form.review_version) === 0
return { need: need || force, force, reviewing }
})
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,
ios_version: form.ios_version,
ios_min_version: form.ios_min_version,
})
success('保存成功')
await load()
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
submitting.value = false
}
}
// ⚠️ 初次加载必须放 onMounted,不能用 watch 的 immediate:immediate 会在 **SSR 阶段**
// 就执行,而这些接口要登录态,服务端渲染时必然失败 —— 表现是整页 500(踩过)。
// watch 只负责「外层换了应用」时重新拉。
onMounted(load)
watch(() => props.appName, () => { 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; }
.plat-col { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 10px 12px; }
.plat-title { font-size: 12px; font-weight: 600; color: #334155; margin-bottom: 6px; }
.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>