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