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.
355 lines
18 KiB
355 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>
|
|
<!-- 下载落地页:编辑/发布分发桶上那份静态页。独立发布,不随本表单的「保存」提交 -->
|
|
<DistPageManager :app-name="scopeApp" class="mb-4" />
|
|
<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'
|
|
import DistPageManager from '~/components/DistPageManager.vue'
|
|
|
|
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>
|
|
|