509 lines
16 KiB
Vue
509 lines
16 KiB
Vue
<template>
|
||
<div class="aksrt-schema-page">
|
||
<AksFormRenderer
|
||
:title="pageMeta.title"
|
||
:description="pageMeta.description"
|
||
:icon="pageMeta.icon"
|
||
:schema="pageMeta.groups"
|
||
:form-data="formData"
|
||
:dirty="dirty"
|
||
:loading="loading"
|
||
:saving="saving"
|
||
:load-error="loadError"
|
||
:show-search="showSearch"
|
||
@update:form-data="formData = $event"
|
||
@save="handleSave"
|
||
@reload="handleReload"
|
||
>
|
||
<template
|
||
v-if="isPermissionsLogin"
|
||
#field-tencent="{ value, setValue }"
|
||
>
|
||
<TencentConfig :model-value="value || {}" @update:model-value="setValue" />
|
||
</template>
|
||
|
||
<template
|
||
v-if="isPermissionsLogin"
|
||
#field-aliyun="{ value, setValue }"
|
||
>
|
||
<AliyunConfig :model-value="value || {}" @update:model-value="setValue" />
|
||
</template>
|
||
|
||
<template
|
||
v-if="isPermissionsLogin"
|
||
#field-cloudflare="{ value, setValue }"
|
||
>
|
||
<CloudflareConfig :model-value="value || {}" @update:model-value="setValue" />
|
||
</template>
|
||
|
||
<template
|
||
v-if="isPermissionsLogin"
|
||
#field-geetest="{ value, setValue }"
|
||
>
|
||
<GeetestConfig :model-value="value || {}" @update:model-value="setValue" />
|
||
</template>
|
||
|
||
<template
|
||
v-if="isPermissionsModeration"
|
||
#field-geoip_block_countries="{ value, setValue }"
|
||
>
|
||
<n-dynamic-tags :value="Array.isArray(value) ? value : []" @update:value="setValue" />
|
||
<p class="aksrt-schema-page__field-hint">使用 ISO 国家代码,如 CN / US / RU。</p>
|
||
</template>
|
||
|
||
<!-- Slot for custom group content (override per page if needed) -->
|
||
<template v-for="(_, name) in $slots" #[name]="slotData">
|
||
<slot :name="name" v-bind="slotData" />
|
||
</template>
|
||
</AksFormRenderer>
|
||
|
||
<div
|
||
v-if="isPermissionsLogin && !loading"
|
||
class="aksrt-schema-page__tool-actions"
|
||
>
|
||
<n-button
|
||
:disabled="!formData?.enabled || !formData?.active_provider"
|
||
@click="handleCaptchaTest"
|
||
>
|
||
测试验证
|
||
</n-button>
|
||
</div>
|
||
|
||
<div v-if="showTestModal" class="captcha-test-overlay" @click.self="closeTestModal">
|
||
<div class="captcha-test-panel">
|
||
<div class="captcha-test-header">
|
||
<span>测试验证码</span>
|
||
<button class="captcha-test-close" @click="closeTestModal">×</button>
|
||
</div>
|
||
<div class="captcha-test-body">
|
||
<div v-if="testLoading" style="text-align:center;padding:40px;">
|
||
<n-spin />
|
||
<p style="margin-top:10px;color:#999;">加载中...</p>
|
||
</div>
|
||
<div v-if="testResult && !testLoading" :style="{color: testResult.success ? '#18a058' : '#d03050', textAlign:'center', padding:'20px'}">
|
||
<p style="font-size:16px;margin:0;">{{ testResult.message }}</p>
|
||
</div>
|
||
<div v-if="!testLoading && !testResult" id="captcha-test-box" style="min-height:150px;"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
/**
|
||
* SchemaPage — generic Schema-driven settings page.
|
||
*
|
||
* Reads the schema from the Vue-side schema registry, fetches/saves settings
|
||
* via the API client, and renders using AksFormRenderer.
|
||
*
|
||
* Usage (in router or parent):
|
||
* <SchemaPage domain="global" section="logo" />
|
||
*
|
||
* Or auto-detect from route meta:
|
||
* The router's meta.schemaKey provides domain/section lookup.
|
||
*/
|
||
import { computed, watch, ref, nextTick, onBeforeUnmount } from 'vue'
|
||
import { useRoute } from 'vue-router'
|
||
import { useMessage } from 'naive-ui'
|
||
import AksFormRenderer from '@/components/form/AksFormRenderer.vue'
|
||
import { useSectionForm } from '@/composables/useSectionForm'
|
||
import { getSchema, normalizeSchemaDomain } from '@/schema/index.js'
|
||
import api from '@/api/index.js'
|
||
import TencentConfig from '@/components/captcha/TencentConfig.vue'
|
||
import AliyunConfig from '@/components/captcha/AliyunConfig.vue'
|
||
import CloudflareConfig from '@/components/captcha/CloudflareConfig.vue'
|
||
import GeetestConfig from '@/components/captcha/GeetestConfig.vue'
|
||
|
||
const props = defineProps({
|
||
/** Schema domain key (e.g. 'global') */
|
||
domain: { type: String, default: '' },
|
||
/** Schema section key (e.g. 'logo') */
|
||
section: { type: String, default: '' },
|
||
/** Whether to show inline search */
|
||
showSearch: { type: Boolean, default: true },
|
||
})
|
||
|
||
const route = useRoute()
|
||
const message = useMessage()
|
||
|
||
const showTestModal = ref(false)
|
||
const testLoading = ref(false)
|
||
const testResult = ref(null)
|
||
|
||
/** Resolve domain/section from props or route meta (canonicalDomain + section, with legacy fallback) */
|
||
const resolvedDomain = computed(() => normalizeSchemaDomain(props.domain || route.meta?.canonicalDomain || route.meta?.schemaDomain || resolveDomainFromPath(route.path)))
|
||
const resolvedSection = computed(() => props.section || route.meta?.section || route.meta?.schemaSection || resolveSectionFromPath(route.path))
|
||
const isPermissionsLogin = computed(() => resolvedDomain.value === 'permissions' && resolvedSection.value === 'login')
|
||
const isPermissionsModeration = computed(() => resolvedDomain.value === 'permissions' && resolvedSection.value === 'moderation')
|
||
|
||
function resolveDomainFromPath(path) {
|
||
const parts = path.split('/').filter(Boolean)
|
||
return parts[0] || 'global'
|
||
}
|
||
|
||
/** Extract section from route path: /global/logo → logo */
|
||
function resolveSectionFromPath(path) {
|
||
const parts = path.split('/').filter(Boolean)
|
||
return parts[parts.length - 1] || 'common'
|
||
}
|
||
|
||
/** Load schema from the Vue-side registry */
|
||
const pageMeta = computed(() => {
|
||
const schema = getSchema(resolvedDomain.value, resolvedSection.value)
|
||
if (schema && schema.groups && schema.groups.length > 0) {
|
||
return {
|
||
title: schema.title || '',
|
||
description: schema.description || '',
|
||
icon: schema.icon || '',
|
||
groups: schema.groups,
|
||
}
|
||
}
|
||
// Fallback: read from route meta
|
||
return {
|
||
title: route.meta?.title || '',
|
||
description: route.meta?.desc || '',
|
||
icon: '',
|
||
groups: [],
|
||
}
|
||
})
|
||
|
||
/** Default values from schema */
|
||
const defaults = computed(() => {
|
||
const schema = getSchema(resolvedDomain.value, resolvedSection.value)
|
||
if (!schema) return {}
|
||
const d = {}
|
||
for (const group of schema.groups || []) {
|
||
for (const field of group.fields || []) {
|
||
if (field.default !== undefined) {
|
||
d[field.key] = field.default
|
||
}
|
||
}
|
||
}
|
||
return d
|
||
})
|
||
|
||
/** Use the section form composable */
|
||
const {
|
||
formData, loading, saving, loadError, dirty,
|
||
save: doSave, reload,
|
||
} = useSectionForm({
|
||
fetch: () => api.getSchemaPage(resolvedDomain.value, resolvedSection.value),
|
||
submit: (data) => api.saveSchemaPage(resolvedDomain.value, resolvedSection.value, data),
|
||
defaults: defaults.value,
|
||
onSaved: () => {
|
||
message.success('设置已保存')
|
||
},
|
||
})
|
||
|
||
async function handleSave() {
|
||
try {
|
||
await doSave()
|
||
} catch (err) {
|
||
message.error('保存失败: ' + (err?.message || '未知错误'))
|
||
}
|
||
}
|
||
|
||
function handleReload() {
|
||
closeTestModal()
|
||
reload()
|
||
message.info('已放弃更改')
|
||
}
|
||
|
||
function getCaptchaProviderConfig(provider) {
|
||
if (!formData.value || !provider) return {}
|
||
const cfg = formData.value[provider]
|
||
return cfg && typeof cfg === 'object' ? cfg : {}
|
||
}
|
||
|
||
function handleCaptchaTest() {
|
||
const provider = formData.value?.active_provider
|
||
if (!provider) return
|
||
|
||
const cfg = getCaptchaProviderConfig(provider)
|
||
const required = {
|
||
tencent: ['secret_id', 'secret_key', 'app_id', 'app_secret_key'],
|
||
aliyun: ['region', 'prefix', 'scene_id', 'access_key_id', 'access_key_secret'],
|
||
cloudflare: ['site_key', 'secret_key'],
|
||
geetest: ['captcha_id', 'captcha_key'],
|
||
}
|
||
const missing = (required[provider] || []).filter((key) => !cfg[key])
|
||
if (missing.length) {
|
||
message.warning(`请先填写必填字段: ${missing.join(', ')}`)
|
||
return
|
||
}
|
||
|
||
if (provider === 'tencent') {
|
||
loadTencentTest(cfg)
|
||
return
|
||
}
|
||
|
||
showTestModal.value = true
|
||
testResult.value = null
|
||
testLoading.value = true
|
||
|
||
nextTick(() => {
|
||
createTestIframe(provider, cfg)
|
||
})
|
||
}
|
||
|
||
function loadTencentTest(cfg) {
|
||
const sdkSrc = 'https://turing.captcha.qcloud.com/TJCaptcha.js'
|
||
|
||
if (typeof window.TencentCaptcha !== 'undefined') {
|
||
try {
|
||
new window.TencentCaptcha(cfg.app_id, function(res) {
|
||
if (res.ret === 0) message.success('腾讯云验证码测试成功!配置正确。')
|
||
else message.warning('验证已取消。')
|
||
}, { userLanguage: 'zh-cn' }).show()
|
||
} catch (error) {
|
||
message.error('初始化失败: ' + error.message)
|
||
}
|
||
return
|
||
}
|
||
|
||
const existingScript = document.querySelector('script[src="' + sdkSrc + '"]')
|
||
if (existingScript) {
|
||
existingScript.addEventListener('load', function() { loadTencentTest(cfg) }, { once: true })
|
||
return
|
||
}
|
||
|
||
const script = document.createElement('script')
|
||
script.src = sdkSrc
|
||
script.onload = function () {
|
||
try {
|
||
new window.TencentCaptcha(cfg.app_id, function(res) {
|
||
if (res.ret === 0) message.success('腾讯云验证码测试成功!')
|
||
else message.warning('验证已取消。')
|
||
}, { userLanguage: 'zh-cn' }).show()
|
||
} catch (error) {
|
||
message.error('初始化失败: ' + error.message)
|
||
}
|
||
}
|
||
script.onerror = function () { message.error('加载腾讯验证码SDK失败') }
|
||
document.head.appendChild(script)
|
||
}
|
||
|
||
function createTestIframe(provider, cfg) {
|
||
setTimeout(() => {
|
||
testLoading.value = false
|
||
loadProviderSDK(provider, cfg)
|
||
}, 100)
|
||
}
|
||
|
||
function loadProviderSDK(provider, cfg) {
|
||
const oldScript = document.getElementById('captcha-test-sdk')
|
||
if (oldScript) oldScript.remove()
|
||
|
||
if (window.__gtTestCaptcha) {
|
||
try { window.__gtTestCaptcha.destroy() } catch (error) {}
|
||
window.__gtTestCaptcha = null
|
||
}
|
||
|
||
const script = document.createElement('script')
|
||
script.id = 'captcha-test-sdk'
|
||
|
||
switch (provider) {
|
||
case 'tencent':
|
||
script.src = 'https://turing.captcha.qcloud.com/TJCaptcha.js'
|
||
script.onload = function () {
|
||
try {
|
||
new window.TencentCaptcha(cfg.app_id, function(res) {
|
||
testResult.value = { success: res.ret === 0, message: res.ret === 0 ? '验证成功!' : '验证已取消。' }
|
||
}, { userLanguage: 'zh-cn' }).show()
|
||
} catch (error) {
|
||
testResult.value = { success: false, message: '初始化失败: ' + error.message }
|
||
}
|
||
}
|
||
script.onerror = function () { testResult.value = { success: false, message: '加载SDK失败' } }
|
||
break
|
||
|
||
case 'cloudflare':
|
||
window.onTurnstileTestLoad = function() {
|
||
try {
|
||
window.turnstile.render('#captcha-test-box', {
|
||
sitekey: cfg.site_key,
|
||
theme: 'auto',
|
||
size: cfg.size || 'normal',
|
||
callback: function() {
|
||
testResult.value = { success: true, message: '验证成功!' }
|
||
message.success('Cloudflare Turnstile 测试成功!')
|
||
setTimeout(closeTestModal, 1500)
|
||
},
|
||
'error-callback': function() {
|
||
testResult.value = { success: false, message: '验证失败' }
|
||
},
|
||
})
|
||
} catch (error) {
|
||
testResult.value = { success: false, message: '初始化失败: ' + error.message }
|
||
}
|
||
}
|
||
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileTestLoad'
|
||
script.onerror = function () { testResult.value = { success: false, message: '加载SDK失败' } }
|
||
break
|
||
|
||
case 'geetest':
|
||
script.src = 'https://static.geetest.com/v4/gt4.js'
|
||
script.onload = function () {
|
||
try {
|
||
window.initGeetest4({ captchaId: cfg.captcha_id, product: cfg.product || 'float' }, function(captchaObj) {
|
||
window.__gtTestCaptcha = captchaObj
|
||
const onSuccess = function() {
|
||
testResult.value = { success: true, message: '验证成功!' }
|
||
message.success('极验 GeeTest 测试成功!')
|
||
setTimeout(closeTestModal, 1500)
|
||
}
|
||
const onError = function(error) {
|
||
testResult.value = { success: false, message: '出错: ' + (error.msg || '?') }
|
||
}
|
||
const onClose = function() {
|
||
if (!testResult.value) testResult.value = { success: false, message: '已关闭' }
|
||
}
|
||
if ((cfg.product || 'float') === 'bind') {
|
||
captchaObj.onReady(() => captchaObj.showCaptcha()).onSuccess(onSuccess).onError(onError).onClose(onClose)
|
||
} else {
|
||
captchaObj.appendTo('#captcha-test-box').onReady(() => {}).onSuccess(onSuccess).onError(onError).onClose(onClose)
|
||
}
|
||
})
|
||
} catch (error) {
|
||
testResult.value = { success: false, message: '初始化失败: ' + error.message }
|
||
}
|
||
}
|
||
script.onerror = function () { testResult.value = { success: false, message: '加载SDK失败' } }
|
||
break
|
||
|
||
case 'aliyun':
|
||
window.AliyunCaptchaConfig = { region: cfg.region || 'cn', prefix: cfg.prefix }
|
||
script.src = 'https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js'
|
||
script.onload = function () {
|
||
try {
|
||
window.initAliyunCaptcha({
|
||
SceneId: cfg.scene_id,
|
||
mode: 'embed',
|
||
element: '#captcha-test-box',
|
||
success: function() {
|
||
testResult.value = { success: true, message: '验证成功!' }
|
||
message.success('阿里云测试成功!')
|
||
setTimeout(closeTestModal, 1500)
|
||
},
|
||
fail: function(result) {
|
||
testResult.value = { success: false, message: String(result) }
|
||
},
|
||
getInstance: function() {},
|
||
slideStyle: { width: 360, height: 40 },
|
||
language: 'cn',
|
||
})
|
||
} catch (error) {
|
||
testResult.value = { success: false, message: '初始化失败: ' + error.message }
|
||
}
|
||
}
|
||
script.onerror = function () { testResult.value = { success: false, message: '加载SDK失败' } }
|
||
break
|
||
|
||
default:
|
||
testResult.value = { success: false, message: '未知服务商' }
|
||
return
|
||
}
|
||
|
||
document.head.appendChild(script)
|
||
}
|
||
|
||
function closeTestModal() {
|
||
showTestModal.value = false
|
||
testResult.value = null
|
||
testLoading.value = false
|
||
if (window.__gtTestCaptcha) {
|
||
try { window.__gtTestCaptcha.destroy() } catch (error) {}
|
||
window.__gtTestCaptcha = null
|
||
}
|
||
const script = document.getElementById('captcha-test-sdk')
|
||
if (script) script.remove()
|
||
if (window.onTurnstileTestLoad) {
|
||
try { delete window.onTurnstileTestLoad } catch (error) {}
|
||
}
|
||
}
|
||
|
||
watch([resolvedDomain, resolvedSection], ([nextDomain, nextSection], [prevDomain, prevSection]) => {
|
||
if (nextDomain !== prevDomain || nextSection !== prevSection) {
|
||
closeTestModal()
|
||
reload()
|
||
}
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
closeTestModal()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.aksrt-schema-page {
|
||
max-width: 1100px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.aksrt-schema-page__tool-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.aksrt-schema-page__field-hint {
|
||
margin: 8px 0 0;
|
||
font-size: 12px;
|
||
color: #8a909c;
|
||
}
|
||
|
||
.captcha-test-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
z-index: 9999;
|
||
}
|
||
|
||
.captcha-test-panel {
|
||
background: #fff;
|
||
border-radius: 12px;
|
||
width: 420px;
|
||
max-width: 90%;
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
|
||
}
|
||
|
||
.captcha-test-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 18px 22px;
|
||
border-bottom: 1px solid #e8e8ec;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.captcha-test-close {
|
||
background: none;
|
||
border: none;
|
||
font-size: 24px;
|
||
cursor: pointer;
|
||
color: #999;
|
||
padding: 0;
|
||
line-height: 1;
|
||
}
|
||
|
||
.captcha-test-close:hover {
|
||
color: #333;
|
||
}
|
||
|
||
.captcha-test-body {
|
||
padding: 24px;
|
||
min-height: 180px;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
</style>
|