Browse Source

admin: 「版本控制」与「渠道分发」合成一页「版本与分发」(/appdist)

上半区发版配置、下半区渠道分发,顶部一个应用选择器共用。页面本体拆成
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
Rodger-Wang 3 weeks ago
parent
commit
06c01aa858
  1. 352
      apps/admin/app/components/AppReleasePanel.vue
  2. 422
      apps/admin/app/components/ChannelAppPanel.vue
  3. 94
      apps/admin/app/pages/appdist.vue
  4. 256
      apps/admin/app/pages/appversion.vue
  5. 445
      apps/admin/app/pages/channels.vue
  6. 2
      apps/admin/app/pages/environments.vue
  7. 12
      apps/admin/app/pages/systemconfig.vue
  8. 4
      apps/admin/app/stores/auth.ts
  9. 7
      apps/admin/app/utils/menus.ts

352
apps/admin/app/components/AppReleasePanel.vue

@ -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>

422
apps/admin/app/components/ChannelAppPanel.vue

@ -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>

94
apps/admin/app/pages/appdist.vue

@ -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>

256
apps/admin/app/pages/appversion.vue

@ -1,254 +1,12 @@
<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>
<div class="py-16 text-center text-slate-400 text-sm">正在跳转到「版本与分发」…</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()
})
// 2026-09-23:「版本控制」并入「版本与分发」(/appdist),页面本体已拆成
// components/AppReleasePanel.vue 与 components/ChannelAppPanel.vue。
// 这里只留重定向——后台里这个地址被收藏/直接输入的不在少数,直接删文件会变成 404
// (同「会议模板」并入「会议配置」时的处理)。
definePageMeta({ title: '版本与分发' })
onMounted(() => { navigateTo('/appdist', { replace: true }) })
</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>

445
apps/admin/app/pages/channels.vue

@ -1,443 +1,12 @@
<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>
<p class="text-xs text-slate-400 mt-1">
版本号 / 强制更新 / 送审版本去
<NuxtLink class="link" to="/appversion">服务配置 → 版本控制</NuxtLink>,
游客登录开关去
<NuxtLink class="link" to="/guestaccess">服务配置 → 游客显隐控制</NuxtLink>
—— 那两组是整个应用一套的,不分渠道。
</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="loadAll">
<option value="">全局默认(所有应用兜底)</option>
<option v-for="a in scopeApps" :key="a" :value="a">{{ a }}</option>
</select>
<button class="btn btn-primary btn-sm text-white" @click="startAdd">+ 添加渠道</button>
</div>
</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-if="!scopeApp" class="scope-tip">
当前是<b>全局默认</b>作用域:这里配的渠道对所有应用生效,应用自己配了同渠道则以应用的为准。
</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 各不相同,填错会把用户引到别家商店。
留空则回退到
<NuxtLink class="link" to="/appversion">版本控制</NuxtLink>
里配的「默认下载地址」。
</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>
<div class="py-16 text-center text-slate-400 text-sm">正在跳转到「版本与分发」…</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: '渠道分发' })
// 与后端 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()
const scopeApp = ref('')
const scopeApps = ref<string[]>([])
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 loadScopeApps() {
try {
const list = await consoleApi<Array<{ name: string }>>('products/list', {})
scopeApps.value = (list ?? []).map((p: { name: string }) => p.name).filter(Boolean)
} catch {
scopeApps.value = []
}
}
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(async () => {
await loadScopeApps()
await loadAll()
})
// 2026-09-23:「渠道分发」并入「版本与分发」(/appdist),页面本体已拆成
// components/AppReleasePanel.vue 与 components/ChannelAppPanel.vue。
// 这里只留重定向——后台里这个地址被收藏/直接输入的不在少数,直接删文件会变成 404
// (同「会议模板」并入「会议配置」时的处理)。
definePageMeta({ title: '版本与分发' })
onMounted(() => { navigateTo('/appdist', { replace: true }) })
</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>

2
apps/admin/app/pages/environments.vue

@ -97,7 +97,7 @@
<div class="grid grid-cols-2 gap-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium">仓库地址</span></label>
<input v-model="form.registry" type="text" class="input input-bordered w-full" placeholder="docker-registry.ideapsound.com" />
<input v-model="form.registry" type="text" class="input input-bordered w-full" placeholder="registry.ymaikj.com" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Docker 网络</span></label>

12
apps/admin/app/pages/systemconfig.vue

@ -164,16 +164,10 @@ const CATALOG = reactive<CatDef[]>([
{ key: 'bucket', label: 'Bucket', value: '', placeholder: 'yunyan-firmware', mono: true, required: true },
{ key: 'access_key_id', label: 'AccessKey ID', value: '', mono: true, encrypted: true, required: true },
{ key: 'access_key_secret', label: 'AccessKey Secret', value: '', mono: true, encrypted: true, required: true },
{ key: 'domain', label: '自定义域名 (CDN)', value: '', placeholder: 'cdn.yunyan.com', wide: true, hint: '可选,配置后文件下载 URL 走该域名' },
]),
mk('storage_tencent_cos', 'tencent_cos', '腾讯云 COS', 'COS', 'Tencent', '#00a4ff', [
{ key: 'region', label: 'Region', value: '', placeholder: 'ap-guangzhou', mono: true, required: true },
{ key: 'appid', label: 'AppId', value: '', mono: true, required: true },
{ key: 'bucket', label: 'Bucket', value: '', mono: true, required: true },
{ key: 'secret_id', label: 'SecretId', value: '', mono: true, encrypted: true, required: true },
{ key: 'secret_key', label: 'SecretKey', value: '', mono: true, encrypted: true, required: true },
{ key: 'domain', label: '自定义域名 (CDN)', value: '', wide: true, hint: '可选' },
{ key: 'domain', label: '自定义域名 (CDN)', value: '', placeholder: 'oss.ymaikj.com', wide: true, hint: '可选,配置后文件下载 URL 走该域名' },
]),
// 2026-09-23:腾讯云 COS 卡片已删除。服务端读存储配置只认 provider=aliyun_oss
// (comm.LoadAliyunOSSConf),配了 COS 也不会有任何地方读它。
],
},
{

4
apps/admin/app/stores/auth.ts

@ -183,6 +183,10 @@ export const useAuthStore = defineStore('auth', {
// 2026-09-12「应用环境配置」并入「服务与环境配置」(svcconfig):只勾过旧 id 的存量账号
// 不该因为一次合并就失去入口。反向不成立——勾了 svcconfig 本来就包含这一页。
if (pageId === 'svcconfig' && this.access.includes('appconfig')) return true
// 2026-09-23「渠道分发」(channels) 并入「版本与分发」(appversion):只勾过 channels
// 的存量账号不该因为一次合并就失去入口。菜单 id 沿用 appversion 未变,
// 所以勾过 appversion 的账号本来就有权限,不需要反向兼容。
if (pageId === 'appversion' && this.access.includes('channels')) return true
// 管理员未显式配置权限时,按默认权限集放行(除 SaaS 管理 / 系统管理 外全部)。
if (this.identity === IDENTITY_MANAGER && this.access.length === 0) {
return DEFAULT_ADMIN_ACCESS.includes(pageId)

7
apps/admin/app/utils/menus.ts

@ -81,7 +81,11 @@ export const MENU_GROUPS: MenuGroup[] = [
// 版本控制 / 游客显隐控制:按应用配、不分渠道(存 app_release 表)。
// 这两组开关原先埋在「SaaS 管理 → 渠道分发」里按渠道各存一份,同一个开关有 8 处能改,
// 客户端只读一处,出过「后台开了游客登录但 App 里没有」的事故,故拆到这里单独管。
{ id: 'appversion', label: '版本控制', to: '/appversion', icon: 'M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2' },
// 2026-09-23:「渠道分发」并入本页,标签与路由改了,**菜单 id 仍是 appversion** ——
// 存量后台账号的 access 清单里存的是这个串,改 id 等于让这些账号丢入口
// (同「会议模板 → 会议配置」那次的处理)。只勾过旧 id 'channels' 的账号
// 由 auth.ts 的 hasAccess 兼容放行。
{ id: 'appversion', label: '版本与分发', to: '/appdist', icon: 'M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2' },
{ id: 'guestaccess', label: '游客显隐控制', to: '/guestaccess', icon: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z' },
],
},
@ -91,7 +95,6 @@ export const MENU_GROUPS: MenuGroup[] = [
items: [
{ id: 'apps', label: '应用中心', to: '/apps', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' },
{ id: 'environments', label: '环境节点', to: '/environments', icon: 'M5 3h14a2 2 0 012 2v4a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2zM5 13h14a2 2 0 012 2v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4a2 2 0 012-2zM7 7h.01M7 17h.01' },
{ id: 'channels', label: '渠道分发', to: '/channels', icon: 'M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2' },
],
},
{

Loading…
Cancel
Save