You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

244 lines
11 KiB

<template>
<div>
<!-- 顶部 -->
<div class="flex items-center justify-between mb-5 flex-wrap gap-3">
<div>
<h2 class="text-lg font-semibold text-slate-800">游客显隐控制</h2>
<p class="text-xs text-slate-400 mt-1">
控制登录页的「游客登录」入口对哪一版客户端可见。<b>整个应用一套,不分渠道。</b>
</p>
</div>
<div class="flex items-center gap-3">
<span class="text-sm text-slate-500">应用</span>
<select v-model="scopeApp" class="select select-bordered select-sm" @change="load">
<option value="">全局默认(所有应用兜底)</option>
<option v-for="a in scopeApps" :key="a" :value="a">{{ a }}</option>
</select>
</div>
</div>
<div v-if="!scopeApp" class="scope-tip">
当前是<b>全局默认</b>作用域:这里配的对所有应用生效;某个应用单独配了,则以应用的为准。
</div>
<div class="guest-wrap">
<div class="card">
<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.tourists" type="checkbox" class="checkbox checkbox-sm checkbox-primary" />
<span>允许游客登录</span>
</label>
<div v-if="form.tourists" class="pl-7 mt-3">
<span class="ed-label">生效版本(必填)</span>
<input v-model="form.touristsversion" 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>
<p v-if="warn" class="ed-warn">⚠ {{ warn }}</p>
</section>
<section class="ed-section">
<div class="ed-section-title">当前效果</div>
<div class="effect" :class="effect.tone">
<span class="effect-icon">{{ effect.icon }}</span>
<div>
<div class="effect-title">{{ effect.title }}</div>
<div class="effect-desc">{{ effect.desc }}</div>
</div>
</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 note">
<div class="ed-section-title">这个开关怎么判定</div>
<p class="note-p">
<b>白名单语义:只有填在「生效版本」里的那一版能看到入口,其余版本一律隐藏。</b>
</p>
<table class="note-table">
<thead>
<tr><th>开关</th><th>生效版本</th><th>效果</th></tr>
</thead>
<tbody>
<tr><td>关</td><td>任意</td><td>全部版本隐藏</td></tr>
<tr><td>开</td><td>空</td><td>全部版本隐藏(保存时会被拦下)</td></tr>
<tr><td>开</td><td><code>1.0.1</code></td><td><b>只有 1.0.1 可见</b>,其余隐藏</td></tr>
</tbody>
</table>
<p class="note-p">
典型用法是送审:只给正在过审的那一版开游客登录,线上其它版本不受影响。
</p>
<p class="note-p muted">
判定在服务端做(客户端只拿结论,不拿规则)。留空按「全隐藏」而非「全放行」处理是刻意选的失败方向——
判宽会让不该露入口的版本露出去(过审风险、白嫖开户礼),判严只是入口少显示。
<b>要全部隐藏请直接取消勾选</b>,不要靠留空版本号表达。
</p>
<p class="note-p muted">
客户端拿不到配置(无网/接口失败)时同样按隐藏处理,所以断网启动后短时间内入口不出现是预期行为。
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
definePageMeta({ title: '游客显隐控制' })
interface GuestForm {
tourists: boolean
touristsversion: string
}
const { webApi, consoleApi } = useApi()
const { success, error } = useToast()
const scopeApp = ref('')
const scopeApps = ref<string[]>([])
const submitting = ref(false)
// 线上版本号只读,用来提示「生效版本填得比线上还高 = 目前没人看得到」
const onlineVersion = ref('')
function emptyForm(): GuestForm {
return { tourists: false, touristsversion: '' }
}
const form = reactive<GuestForm>(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
}
// 这里只提示,真正的拦截在后端 saveAppTourists(前端提示挡不住直接调接口)
const warn = computed(() => {
if (!form.tourists) return ''
const v = form.touristsversion.trim()
if (!v) return '开启游客登录必须填生效版本,留空等于所有版本都看不到入口'
if (onlineVersion.value.trim() && cmpVer(v, onlineVersion.value) > 0) {
return `生效版本高于「当前线上版本 ${onlineVersion.value}」,目前线上没有任何一版客户端能看到入口`
}
return ''
})
const effect = computed(() => {
if (!form.tourists) {
return { tone: 'off', icon: '🚫', title: '全部版本隐藏', desc: '所有客户端的登录页都不显示游客登录入口。' }
}
const v = form.touristsversion.trim()
if (!v) {
return { tone: 'warn', icon: '⚠️', title: '等于没开', desc: '勾了开关却没点名版本,所有版本仍然看不到入口。保存时会被拦下。' }
}
return { tone: 'on', icon: '✅', title: `只有 ${v} 可见`, desc: `上报版本号等于 ${v} 的客户端会显示游客登录入口,其余版本一律隐藏。` }
})
// 应用名来源:应用中心的应用表 + 部署注册表(兜底老数据);无权读这两张表时改走 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: GuestForm & { version?: string } }>('getapprelease', { app_name: scopeApp.value })
Object.assign(form, emptyForm(), {
tourists: d?.item?.tourists ?? false,
touristsversion: d?.item?.touristsversion ?? '',
})
onlineVersion.value = d?.item?.version ?? ''
} catch (e: any) {
error(e?.message ?? '加载失败')
Object.assign(form, emptyForm())
onlineVersion.value = ''
}
}
async function save() {
// 关掉开关时顺手清空生效版本:输入框此时是隐藏的,留着旧值会被后端的
// 「填了版本必须勾选开关」校验挡下,用户看不到是哪一项有问题。
if (!form.tourists) form.touristsversion = ''
if (warn.value) { error(warn.value); return }
submitting.value = true
try {
// 只提交游客这一组字段:版本控制和它同一行,整行提交会把对方清空。
await webApi('saveapptourists', {
app_name: scopeApp.value,
tourists: form.tourists,
touristsversion: form.touristsversion,
})
success('保存成功')
await load()
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
submitting.value = false
}
}
onMounted(async () => {
await loadScopeApps()
await load()
})
</script>
<style scoped>
.scope-tip { background: #f8fafc; border: 1px solid #e2e8f0; color: #475569; font-size: 12px; padding: 8px 12px; border-radius: 10px; margin-bottom: 16px; }
.guest-wrap { display: grid; gap: 16px; grid-template-columns: minmax(0, 1fr) minmax(300px, 1fr); align-items: start; }
@media (max-width: 900px) { .guest-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; }
.effect { display: flex; gap: 10px; align-items: flex-start; border-radius: 10px; padding: 10px 12px; }
.effect.on { background: #dcfce7; }
.effect.off { background: #f1f5f9; }
.effect.warn { background: #fffbeb; }
.effect-icon { font-size: 16px; line-height: 1.3; }
.effect-title { font-size: 13px; font-weight: 700; color: #334155; }
.effect-desc { font-size: 11px; color: #64748b; margin-top: 2px; line-height: 1.6; }
.note { background: #fcfcfd; }
.note-p { font-size: 12px; color: #475569; line-height: 1.8; margin-bottom: 10px; }
.note-p.muted { color: #94a3b8; font-size: 11px; }
.note-table { width: 100%; font-size: 11px; border-collapse: collapse; margin-bottom: 12px; }
.note-table th, .note-table td { border: 1px solid #e2e8f0; padding: 5px 8px; text-align: left; color: #475569; }
.note-table th { background: #f8fafc; color: #64748b; font-weight: 600; }
code { background: #f1f5f9; padding: 0 4px; border-radius: 4px; }
</style>