Browse Source
上半区发版配置、下半区渠道分发,顶部一个应用选择器共用。页面本体拆成 components/AppReleasePanel.vue 与 ChannelAppPanel.vue;/appversion、/channels 只留重定向。 ⚠️ 合的是入口不是表。app_release 与 channel_app 维度不同,两块各读各的接口、 各自提交,绝不能做成一个大表单整包保存——app_release 那一行还被「游客显隐控制」 页共用,后端是「读整行→只覆盖本组→整行写回」,整包提交会在本页保存时清空游客 登录配置。 ⚠️ 菜单 id 仍是 appversion(只改了 label 与 to):存量后台账号的 access 清单里存 的是这个串,改 id 等于让这些账号丢入口。只勾过旧 id channels 的账号由 auth.ts 的 hasAccess 兼容放行。 发版面板同步本次的新口径:线上版本按 Android / iOS 两栏、最低支持版本可留空 (= 线上版本)、判定预览可切平台;上传 APK 传完把直链填进「默认下载地址」输入框 并写进「渠道分发 → 官方渠道(100)」,下方面板随即重拉。 ⚠️ savechannelapp 是整行覆盖,所以写官方渠道地址必须「读整行→只改 address→写回」; getchannelapps 回的是 {items:[...]} 不是裸数组,当成数组取会永远走新建分支、 把官方渠道已有的上架状态/支付渠道清零且不报任何错。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>main
9 changed files with 895 additions and 699 deletions
@ -0,0 +1,352 @@ |
|||||
|
<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> |
||||
@ -0,0 +1,422 @@ |
|||||
|
<template> |
||||
|
<div> |
||||
|
<div class="flex flex-wrap items-start justify-between gap-3 mb-4"> |
||||
|
<div> |
||||
|
<div class="text-sm font-semibold text-slate-700">渠道分发</div> |
||||
|
<div class="text-xs text-slate-400 mt-1 max-w-3xl"> |
||||
|
<b>因渠道而异</b>的东西:本渠道下载地址、上架状态、支付渠道、是否允许跳过设备绑定。 |
||||
|
下载地址<b>留空就回退上面的「默认下载地址」</b>,所以只有需要直达某家商店时才填。 |
||||
|
支付渠道在这里控制「开放哪几种支付入口」,商户凭据在应用的业务功能配置里填。 |
||||
|
</div> |
||||
|
</div> |
||||
|
<button class="btn btn-primary btn-sm text-white shrink-0" @click="startAdd()">+ 添加渠道</button> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 支付凭据体检:开了支付入口却没配凭据,是这个页面最容易踩的坑 --> |
||||
|
<div v-if="payWarnings.length" class="pay-warn"> |
||||
|
<div class="flex items-start gap-2"> |
||||
|
<span>⚠</span> |
||||
|
<div class="flex-1"> |
||||
|
<div class="text-sm font-semibold">以下支付方式已在渠道里开放,但商户凭据还没配:</div> |
||||
|
<div class="text-xs mt-1"> |
||||
|
<span v-for="w in payWarnings" :key="w.key" class="warn-pill">{{ w.label }}({{ w.channels.join('、') }})</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
<button class="btn btn-xs btn-ghost" @click="gotoPayConfig">去配置 →</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 按渠道大类分组 --> |
||||
|
<div v-for="g in groups" :key="g.label" class="mb-6"> |
||||
|
<div class="flex items-center gap-2 mb-3"> |
||||
|
<span class="group-tag" :style="{ background: g.bg, color: g.color }">{{ g.icon }} {{ g.label }}</span> |
||||
|
<span class="text-xs text-slate-400">{{ g.items.length }} / {{ g.channels.length }} 已配置</span> |
||||
|
</div> |
||||
|
<div class="ch-grid"> |
||||
|
<div v-for="c in g.items" :key="c.channel" class="ch-card" :class="{ off: !c.enable }" @click="startEdit(c)"> |
||||
|
<div class="flex items-start justify-between gap-2"> |
||||
|
<div class="min-w-0"> |
||||
|
<div class="font-semibold text-sm text-slate-800">{{ channelName(c.channel) }}</div> |
||||
|
<div class="text-[11px] text-slate-400 mt-0.5">渠道号 {{ c.channel }}</div> |
||||
|
</div> |
||||
|
<span class="status-badge" :style="statusStyle(c.status)">{{ statusLabel(c.status) }}</span> |
||||
|
</div> |
||||
|
|
||||
|
<div class="flex flex-wrap items-center gap-1.5 mt-2"> |
||||
|
<span v-if="c.allowskipdevicebinding" class="flag">可跳过绑定</span> |
||||
|
<span v-if="!c.enable" class="flag flag-off">已停用</span> |
||||
|
</div> |
||||
|
|
||||
|
<div v-if="payList(c).length" class="flex flex-wrap items-center gap-1.5 mt-2"> |
||||
|
<span |
||||
|
v-for="p in payList(c)" |
||||
|
:key="p" |
||||
|
class="pay-pill" |
||||
|
:class="{ missing: payMissing(p) }" |
||||
|
:title="payMissing(p) ? '该支付方式的商户凭据尚未配置' : ''" |
||||
|
>{{ payLabel(p) }}{{ payMissing(p) ? ' ⚠' : '' }}</span> |
||||
|
</div> |
||||
|
|
||||
|
<div class="text-[11px] mt-2 truncate" :class="c.address ? 'text-slate-400' : 'text-slate-300'"> |
||||
|
{{ c.address || '未填本渠道地址 · 用应用默认' }} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 该组里尚未配置的渠道,点一下直接建 --> |
||||
|
<div |
||||
|
v-for="ch in g.missing" |
||||
|
:key="'m' + ch" |
||||
|
class="ch-card ch-empty" |
||||
|
@click="startAdd(ch)" |
||||
|
> |
||||
|
<div class="text-sm text-slate-400">{{ channelName(ch) }}</div> |
||||
|
<div class="text-[11px] text-slate-300 mt-1">未配置 · 点击添加</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 编辑弹窗 --> |
||||
|
<div v-show="modalOpen" class="ch-overlay"> |
||||
|
<div class="ch-box"> |
||||
|
<div class="flex items-center justify-between mb-4"> |
||||
|
<div class="flex items-center gap-3"> |
||||
|
<h3 class="text-base font-semibold text-slate-800">{{ isEditing ? '编辑渠道' : '添加渠道' }}</h3> |
||||
|
<span class="scope-chip">{{ scopeApp || '全局默认' }}</span> |
||||
|
</div> |
||||
|
<button class="btn btn-ghost btn-xs" @click="closeModal">✕ 关闭</button> |
||||
|
</div> |
||||
|
|
||||
|
<section class="ed-section"> |
||||
|
<div class="ed-section-title">渠道</div> |
||||
|
<div class="grid grid-cols-2 gap-3"> |
||||
|
<div> |
||||
|
<span class="ed-label">分发渠道 <i class="req">*</i></span> |
||||
|
<select v-model.number="form.channel" :disabled="isEditing" class="select select-bordered select-sm w-full"> |
||||
|
<option :value="0" disabled>请选择渠道</option> |
||||
|
<optgroup v-for="g in CHANNEL_GROUPS" :key="g.label" :label="g.label"> |
||||
|
<option v-for="ch in g.channels" :key="ch" :value="ch">{{ channelName(ch) }}({{ ch }})</option> |
||||
|
</optgroup> |
||||
|
</select> |
||||
|
</div> |
||||
|
<div> |
||||
|
<span class="ed-label">上架状态</span> |
||||
|
<select v-model.number="form.status" class="select select-bordered select-sm w-full"> |
||||
|
<option v-for="s in STATUS" :key="s.v" :value="s.v">{{ s.label }}</option> |
||||
|
</select> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="mt-3"> |
||||
|
<span class="ed-label">描述</span> |
||||
|
<input v-model="form.description" class="input input-bordered input-sm w-full" placeholder="这个渠道包的说明" /> |
||||
|
</div> |
||||
|
</section> |
||||
|
|
||||
|
<section class="ed-section"> |
||||
|
<div class="ed-section-title">本渠道下载地址</div> |
||||
|
<input v-model="form.address" class="input input-bordered input-sm w-full" placeholder="本商店的下载链接,留空则用应用默认地址" /> |
||||
|
<p class="ed-tip"> |
||||
|
客户端点「立即更新」时直接打开这个地址,所以<b>必须是本渠道自己的商店链接</b>—— |
||||
|
华为、小米、App Store 各不相同,填错会把用户引到别家商店。 |
||||
|
留空则回退到<b>上方「发版配置」</b>里的「默认下载地址」—— |
||||
|
那里填网页下载页的话,这里留空就等于交给它按品牌自动分流。 |
||||
|
</p> |
||||
|
</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.allowskipdevicebinding" type="checkbox" class="checkbox checkbox-sm checkbox-primary" /> |
||||
|
<span>允许跳过设备绑定</span> |
||||
|
</label> |
||||
|
<p class="ed-tip"> |
||||
|
游客登录开关已移到 |
||||
|
<NuxtLink class="link" to="/guestaccess">服务配置 → 游客显隐控制</NuxtLink> |
||||
|
(按应用配,不分渠道)。 |
||||
|
</p> |
||||
|
</section> |
||||
|
|
||||
|
<section class="ed-section"> |
||||
|
<div class="ed-section-title">支付渠道</div> |
||||
|
<div class="pay-grid"> |
||||
|
<label v-for="p in PAY_CHANNELS" :key="p.key" class="pay-item" :class="{ checked: payChecked(p.key) }"> |
||||
|
<input type="checkbox" class="checkbox checkbox-xs checkbox-primary" :checked="payChecked(p.key)" @change="togglePay(p.key)" /> |
||||
|
<span>{{ p.label }}</span> |
||||
|
<span v-if="payChecked(p.key) && payMissing(p.key)" class="pay-miss">未配凭据</span> |
||||
|
</label> |
||||
|
</div> |
||||
|
<p class="ed-tip"> |
||||
|
这里只决定「这个渠道开放哪几种支付入口」;商户号、密钥等凭据在 |
||||
|
<a v-if="scopeApp" class="link" @click.prevent="gotoPayConfig">应用「{{ scopeApp }}」→ 业务功能配置 → 支付</a> |
||||
|
<span v-else>各应用的「业务功能配置 → 支付」</span> |
||||
|
里填。 |
||||
|
</p> |
||||
|
</section> |
||||
|
|
||||
|
<div class="ed-footer"> |
||||
|
<label class="flex items-center gap-2 text-sm cursor-pointer"> |
||||
|
<input v-model="form.enable" type="checkbox" class="checkbox checkbox-sm checkbox-primary" /> |
||||
|
<span>启用</span> |
||||
|
<span class="text-xs text-slate-400">停用后该渠道配置不下发客户端</span> |
||||
|
</label> |
||||
|
<div class="flex gap-2"> |
||||
|
<button v-if="isEditing" class="btn btn-ghost btn-sm text-error mr-auto" @click="del">删除</button> |
||||
|
<button class="btn btn-ghost btn-sm" @click="closeModal">取消</button> |
||||
|
<button class="btn btn-primary btn-sm text-white" :disabled="submitting" @click="save">保存</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</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' |
||||
|
|
||||
|
// 与后端 comm.ChannelApp 一一对应。 |
||||
|
// ⚠️ 版本号 / 最低支持版本 / 强制更新 / 更新日志 / 送审版本 / 游客登录开关**不在这里**: |
||||
|
// 它们是「整个应用一套」的,已搬到 app_release(服务配置 → 版本控制 / 游客显隐控制)。 |
||||
|
// 别再加回来——按渠道各存一份意味着同一个开关有 8 处能改而客户端只读一处。 |
||||
|
interface ChannelApp { |
||||
|
row_id: number |
||||
|
app_name: string |
||||
|
channel: number |
||||
|
address: string |
||||
|
status: number |
||||
|
description: string |
||||
|
allowskipdevicebinding: boolean |
||||
|
paychannels: string |
||||
|
enable: boolean |
||||
|
} |
||||
|
|
||||
|
// 渠道枚举(与后端 pb.UserChannel / comm.Channel* 一致) |
||||
|
const CHANNELS: Record<number, string> = { |
||||
|
1: '华为', 2: '小米', 3: 'OPPO', 4: 'VIVO', 5: '荣耀', |
||||
|
100: '官方包', 101: 'App Store', 102: 'Google Play', |
||||
|
} |
||||
|
function channelName(v: number): string { |
||||
|
return CHANNELS[v] ?? '渠道' + v |
||||
|
} |
||||
|
const CHANNEL_GROUPS = [ |
||||
|
{ label: '中国厂商', icon: '📱', color: '#0369a1', bg: '#dbeafe', channels: [1, 2, 3, 4, 5] }, |
||||
|
{ label: '全球商店', icon: '🌍', color: '#c2410c', bg: '#fff7ed', channels: [101, 102] }, |
||||
|
{ label: '官方渠道', icon: '🏠', color: '#059669', bg: '#dcfce7', channels: [100] }, |
||||
|
] |
||||
|
|
||||
|
// 上架状态(与 comm.ChannelStatus* 一致) |
||||
|
const STATUS = [ |
||||
|
{ v: 0, label: '草稿(不下发)', color: '#64748b', bg: '#f1f5f9' }, |
||||
|
{ v: 1, label: '已上架', color: '#059669', bg: '#dcfce7' }, |
||||
|
{ v: 2, label: '审核中', color: '#b45309', bg: '#fef9c3' }, |
||||
|
{ v: 3, label: '已下架', color: '#be123c', bg: '#ffe4e6' }, |
||||
|
] |
||||
|
function statusLabel(v: number): string { |
||||
|
return STATUS.find((s) => s.v === v)?.label ?? '未知' |
||||
|
} |
||||
|
function statusStyle(v: number) { |
||||
|
const s = STATUS.find((x) => x.v === v) |
||||
|
return s ? { background: s.bg, color: s.color } : {} |
||||
|
} |
||||
|
|
||||
|
// 支付渠道 → 业务功能配置里的模块键(与后端 comm.PayChannelModule 一致) |
||||
|
const PAY_CHANNELS = [ |
||||
|
{ key: 'wxpay', label: '微信支付', module: 'wechatpay' }, |
||||
|
{ key: 'alipay', label: '支付宝', module: 'alipay' }, |
||||
|
{ key: 'paypal', label: 'PayPal', module: 'paypal' }, |
||||
|
{ key: 'apple', label: 'Apple 内购', module: 'appleiap' }, |
||||
|
{ key: 'google', label: 'Google 内购', module: 'googleiap' }, |
||||
|
] |
||||
|
function payLabel(k: string): string { |
||||
|
return PAY_CHANNELS.find((p) => p.key === k)?.label ?? k |
||||
|
} |
||||
|
|
||||
|
const { webApi, consoleApi } = useApi() |
||||
|
const { success, error } = useToast() |
||||
|
const router = useRouter() |
||||
|
|
||||
|
|
||||
|
// 作用域由外层「版本与分发」页统一给:两块配置共用一个应用选择器。 |
||||
|
// 用 computed 顶替原来的 scopeApp ref,正文里所有 scopeApp.value 的写法原样保留 —— |
||||
|
// 逐个改成 props.appName 只会在这次合并里多制造几十处可能改漏的地方。 |
||||
|
const props = defineProps<{ appName: string }>() |
||||
|
const scopeApp = computed(() => props.appName) |
||||
|
const items = ref<ChannelApp[]>([]) |
||||
|
const payConfigured = ref<string[]>([]) // 已配凭据的模块键 |
||||
|
const payChecked_ = ref(false) // 是否成功查到(全局作用域查不了) |
||||
|
const modalOpen = ref(false) |
||||
|
const isEditing = ref(false) |
||||
|
const submitting = ref(false) |
||||
|
|
||||
|
function emptyForm(): ChannelApp { |
||||
|
return { |
||||
|
row_id: 0, app_name: '', channel: 0, address: '', status: 1, description: '', |
||||
|
allowskipdevicebinding: false, paychannels: '', enable: true, |
||||
|
} |
||||
|
} |
||||
|
const form = reactive<ChannelApp>(emptyForm()) |
||||
|
|
||||
|
const groups = computed(() => |
||||
|
CHANNEL_GROUPS.map((g) => ({ |
||||
|
...g, |
||||
|
items: items.value.filter((c) => g.channels.includes(c.channel)).sort((a, b) => a.channel - b.channel), |
||||
|
missing: g.channels.filter((ch) => !items.value.some((c) => c.channel === ch)), |
||||
|
})), |
||||
|
) |
||||
|
|
||||
|
function payList(c: ChannelApp): string[] { |
||||
|
return (c.paychannels || '').split(',').map((x) => x.trim()).filter(Boolean) |
||||
|
} |
||||
|
// 某支付方式是否「开了但没配凭据」。查不到配置状态时一律不报警,避免误伤。 |
||||
|
function payMissing(key: string): boolean { |
||||
|
if (!payChecked_.value) return false |
||||
|
const mod = PAY_CHANNELS.find((p) => p.key === key)?.module |
||||
|
return !!mod && !payConfigured.value.includes(mod) |
||||
|
} |
||||
|
// 汇总所有渠道里「开了但没配」的支付方式,连带列出是哪几个渠道开的 |
||||
|
const payWarnings = computed(() => { |
||||
|
if (!payChecked_.value) return [] |
||||
|
const map: Record<string, string[]> = {} |
||||
|
for (const c of items.value) { |
||||
|
if (!c.enable) continue |
||||
|
for (const p of payList(c)) { |
||||
|
if (!payMissing(p)) continue |
||||
|
;(map[p] = map[p] || []).push(channelName(c.channel)) |
||||
|
} |
||||
|
} |
||||
|
return Object.keys(map).map((k) => ({ key: k, label: payLabel(k), channels: map[k]! })) |
||||
|
}) |
||||
|
|
||||
|
// ===== 加载 ===== |
||||
|
// ===== 加载 ===== |
||||
|
async function loadItems() { |
||||
|
try { |
||||
|
const d = await webApi<{ items: ChannelApp[] }>('getchannelapps', { app_name: scopeApp.value }) |
||||
|
items.value = d?.items ?? [] |
||||
|
} catch (e: any) { |
||||
|
error(e?.message ?? '加载失败') |
||||
|
items.value = [] |
||||
|
} |
||||
|
} |
||||
|
async function loadPayStatus() { |
||||
|
try { |
||||
|
const d = await webApi<{ configured: string[]; checked: boolean }>('getchannelpaystatus', { app_name: scopeApp.value }) |
||||
|
payConfigured.value = d?.configured ?? [] |
||||
|
payChecked_.value = !!d?.checked |
||||
|
} catch { |
||||
|
payConfigured.value = [] |
||||
|
payChecked_.value = false |
||||
|
} |
||||
|
} |
||||
|
async function loadAll() { |
||||
|
await Promise.all([loadItems(), loadPayStatus()]) |
||||
|
} |
||||
|
|
||||
|
// ===== 编辑 ===== |
||||
|
function startAdd(channel?: number) { |
||||
|
Object.assign(form, emptyForm()) |
||||
|
if (typeof channel === 'number') form.channel = channel |
||||
|
isEditing.value = false |
||||
|
modalOpen.value = true |
||||
|
} |
||||
|
function startEdit(c: ChannelApp) { |
||||
|
Object.assign(form, emptyForm(), c) |
||||
|
isEditing.value = true |
||||
|
modalOpen.value = true |
||||
|
} |
||||
|
function closeModal() { |
||||
|
modalOpen.value = false |
||||
|
} |
||||
|
function payChecked(key: string): boolean { |
||||
|
return payList(form).includes(key) |
||||
|
} |
||||
|
function togglePay(key: string) { |
||||
|
const cur = payList(form) |
||||
|
const i = cur.indexOf(key) |
||||
|
if (i >= 0) cur.splice(i, 1) |
||||
|
else cur.push(key) |
||||
|
form.paychannels = cur.join(',') |
||||
|
} |
||||
|
|
||||
|
async function save() { |
||||
|
if (!form.channel) { error('请选择分发渠道'); return } |
||||
|
submitting.value = true |
||||
|
try { |
||||
|
await webApi('savechannelapp', { ...form, app_name: scopeApp.value }) |
||||
|
success('保存成功') |
||||
|
closeModal() |
||||
|
await loadItems() |
||||
|
} catch (e: any) { |
||||
|
error(e?.message ?? '保存失败') |
||||
|
} finally { |
||||
|
submitting.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function del() { |
||||
|
if (!window.confirm(`确定删除「${channelName(form.channel)}」这个渠道的配置吗?`)) return |
||||
|
webApi('delchannelapp', { app_name: scopeApp.value, channel: form.channel }) |
||||
|
.then(() => { success('已删除'); closeModal(); loadItems() }) |
||||
|
.catch((e: any) => error(e?.message ?? '删除失败')) |
||||
|
} |
||||
|
|
||||
|
function gotoPayConfig() { |
||||
|
if (!scopeApp.value) { error('请先在上方选择一个应用'); return } |
||||
|
router.push(`/apps/${encodeURIComponent(scopeApp.value)}?tab=modules`) |
||||
|
} |
||||
|
|
||||
|
// ⚠️ 初次加载必须放 onMounted,不能用 watch 的 immediate:immediate 会在 **SSR 阶段** |
||||
|
// 就执行,而这些接口要登录态,服务端渲染时必然失败 —— 表现是整页 500(踩过)。 |
||||
|
// watch 只负责「外层换了应用」时重新拉。 |
||||
|
onMounted(loadAll) |
||||
|
watch(() => props.appName, () => { loadAll() }) |
||||
|
// 给外层「版本与分发」页用:上面的面板传完安装包会改写官方渠道的地址,这边要重拉才看得到。 |
||||
|
defineExpose({ reload: loadAll }) |
||||
|
</script> |
||||
|
|
||||
|
<style scoped> |
||||
|
.group-tag { font-size: 12px; font-weight: 700; padding: 3px 10px; border-radius: 8px; } |
||||
|
.ch-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); } |
||||
|
.ch-card { background: #fff; border: 1px solid #eef2f7; border-radius: 14px; padding: 14px 16px; cursor: pointer; transition: all .15s; } |
||||
|
.ch-card:hover { border-color: #c7d2fe; box-shadow: 0 4px 14px rgba(99, 102, 241, .08); } |
||||
|
.ch-card.off { opacity: .55; } |
||||
|
.ch-empty { border-style: dashed; background: #fafbfc; display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 92px; } |
||||
|
.ch-empty:hover { border-color: #c7d2fe; background: #f8fafc; } |
||||
|
|
||||
|
.status-badge { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 6px; white-space: nowrap; } |
||||
|
|
||||
|
.flag { font-size: 10px; font-weight: 600; padding: 2px 7px; border-radius: 5px; background: #f1f5f9; color: #475569; } |
||||
|
.flag-off { background: #fee2e2; color: #b91c1c; } |
||||
|
.pay-pill { font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 6px; background: #eef2ff; color: #4338ca; } |
||||
|
.pay-pill.missing { background: #fef3c7; color: #b45309; } |
||||
|
|
||||
|
.pay-warn { background: #fffbeb; border: 1px solid #fde68a; color: #92400e; border-radius: 12px; padding: 12px 14px; margin-bottom: 16px; } |
||||
|
.warn-pill { display: inline-block; margin-right: 8px; padding: 1px 8px; border-radius: 6px; background: #fef3c7; } |
||||
|
.scope-tip { background: #f8fafc; border: 1px solid #eef2f7; color: #64748b; font-size: 12px; border-radius: 10px; padding: 9px 13px; margin-bottom: 16px; } |
||||
|
|
||||
|
/* 弹窗 */ |
||||
|
.ch-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, .45); z-index: 100; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(2px); padding: 24px; } |
||||
|
.ch-box { background: #fff; border-radius: 18px; padding: 24px; width: 700px; max-width: 96vw; max-height: 90vh; overflow-y: auto; box-shadow: 0 24px 80px rgba(0, 0, 0, .18); } |
||||
|
.scope-chip { font-size: 11px; font-weight: 600; padding: 2px 9px; border-radius: 6px; background: #eef2ff; color: #4338ca; } |
||||
|
|
||||
|
.ed-section { padding: 14px 0; border-top: 1px dashed #eef2f7; } |
||||
|
.ed-section:first-of-type { padding-top: 0; border-top: none; } |
||||
|
.ed-section-title { font-size: 11px; font-weight: 700; letter-spacing: .04em; color: #94a3b8; margin-bottom: 10px; } |
||||
|
.ed-label { display: block; font-size: 11px; color: #64748b; margin-bottom: 4px; } |
||||
|
.ed-warn { font-size: 11px; color: #b45309; margin-top: 8px; } |
||||
|
.ed-tip { font-size: 11px; color: #94a3b8; margin-top: 8px; } |
||||
|
.req { color: #dc2626; font-style: normal; } |
||||
|
.link { color: #4f46e5; cursor: pointer; text-decoration: underline; } |
||||
|
|
||||
|
.pay-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } |
||||
|
.pay-item { display: flex; align-items: center; gap: 7px; padding: 8px 10px; border-radius: 9px; border: 1.5px solid #e2e8f0; cursor: pointer; font-size: 13px; color: #475569; transition: all .15s; } |
||||
|
.pay-item:hover { border-color: #c7d2fe; background: #f8fafc; } |
||||
|
.pay-item.checked { border-color: #6366f1; background: #eef2ff; color: #4338ca; } |
||||
|
.pay-miss { font-size: 10px; color: #b45309; background: #fef3c7; padding: 1px 5px; border-radius: 4px; margin-left: auto; } |
||||
|
|
||||
|
.ed-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 14px; border-top: 1px solid #eef2f7; } |
||||
|
|
||||
|
@media (max-width: 860px) { |
||||
|
.pay-grid { grid-template-columns: repeat(2, 1fr); } |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,94 @@ |
|||||
|
<template> |
||||
|
<div> |
||||
|
<!-- 页头 + 应用选择:上下两块配置共用一个作用域 --> |
||||
|
<div class="flex flex-wrap items-start justify-between gap-3 mb-4"> |
||||
|
<div> |
||||
|
<h2 class="text-lg font-semibold text-slate-800">版本与分发</h2> |
||||
|
<p class="text-xs text-slate-400 mt-1 max-w-3xl"> |
||||
|
发一次版要配的东西都在这一页:上半区是<b>整个应用一套</b>的版本号 / 强更 / 更新日志 / 默认下载地址, |
||||
|
下半区是<b>因渠道而异</b>的各商店链接与上架状态。两者存在不同的表里, |
||||
|
<b>提交也各管各的</b>——原因见下方说明。 |
||||
|
</p> |
||||
|
</div> |
||||
|
<div class="flex items-center gap-2"> |
||||
|
<span class="text-sm text-slate-500">应用</span> |
||||
|
<select v-model="scopeApp" class="select select-bordered select-sm min-w-40"> |
||||
|
<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>作用域:这里配的对所有应用生效,某个应用单独配了则以应用的为准。 |
||||
|
<b>上传安装包必须先选具体应用</b>——安装包按应用分目录存放。 |
||||
|
</div> |
||||
|
|
||||
|
<section class="panel p-5 mb-4"> |
||||
|
<AppReleasePanel :app-name="scopeApp" @uploaded="channelPanel?.reload()" /> |
||||
|
</section> |
||||
|
|
||||
|
<section class="panel p-5"> |
||||
|
<ChannelAppPanel ref="channelPanel" :app-name="scopeApp" /> |
||||
|
</section> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script setup lang="ts"> |
||||
|
import { ref, onMounted } from 'vue' |
||||
|
import { useApi } from '~/composables/useApi' |
||||
|
import AppReleasePanel from '~/components/AppReleasePanel.vue' |
||||
|
import ChannelAppPanel from '~/components/ChannelAppPanel.vue' |
||||
|
|
||||
|
definePageMeta({ title: '版本与分发' }) |
||||
|
|
||||
|
/* |
||||
|
「版本控制」与「渠道分发」2026-09-23 合并成本页。 |
||||
|
|
||||
|
⚠️ **合的是入口,不是表。** app_release(一应用一行) 与 channel_app((应用,渠道) 一行) |
||||
|
维度不同,2026-08-31 正是从一张表拆开的:版本号和游客开关本来就整个应用一套, |
||||
|
按渠道各存一份意味着同一个开关有 8 处能改而客户端只读一处,出过 |
||||
|
「后台明明开了游客登录、App 就是不显示」的事故。合回去等于把那个坑重新挖开。 |
||||
|
|
||||
|
⚠️ **两块各自提交,不要做成一个大表单整包保存。** app_release 那一行还被 |
||||
|
「游客显隐控制」页共用,后端是「读整行 → 只覆盖本组字段 → 整行写回」; |
||||
|
做成整行提交的话,在本页点一次保存就会把游客登录配置清空。 |
||||
|
*/ |
||||
|
|
||||
|
const { webApi, consoleApi } = useApi() |
||||
|
|
||||
|
const scopeApp = ref('') |
||||
|
// 上面的面板传完安装包后会改写官方渠道地址,这里拿到下方面板的句柄让它重拉。 |
||||
|
const channelPanel = ref<{ reload: () => Promise<void> } | null>(null) |
||||
|
const scopeApps = ref<string[]>([]) |
||||
|
|
||||
|
// 应用名来源:应用中心的应用表 + 部署注册表(兜底老数据);无权读这两张表时改走 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() |
||||
|
} |
||||
|
|
||||
|
onMounted(loadScopeApps) |
||||
|
</script> |
||||
|
|
||||
|
<style scoped> |
||||
|
.panel { background: #fff; border: 1px solid #e2e8f0; border-radius: 14px; } |
||||
|
.scope-tip { background: #f8fafc; border: 1px solid #e2e8f0; color: #475569; font-size: 12px; padding: 8px 12px; border-radius: 10px; margin-bottom: 16px; } |
||||
|
</style> |
||||
Loading…
Reference in new issue