Files
wordpress-theme-aksrt/assets/admin/src/pages/SchemaPage.vue
T

509 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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">&times;</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>