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

612 lines
20 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="isSmsPage ? isSmsSettingsDirty : dirty"
:loading="loading"
:saving="saving"
:load-error="loadError"
:show-search="showSearch"
@update:form-data="formData = $event"
@save="handleSave"
@reload="handleReload"
@field-action="handleFieldAction"
>
<template
v-if="isPermissionsLogin"
#group-providers
>
<n-tabs
v-model:value="captchaProviderTab"
type="segment"
animated
class="aksrt-tabs aksrt-captcha-provider-tabs"
>
<n-tab-pane
v-for="provider in captchaProviders"
:key="provider.key"
:name="provider.key"
:tab="provider.label"
>
<component
:is="provider.component"
:model-value="getCaptchaProviderConfig(provider.key)"
@update:model-value="setCaptchaProviderConfig(provider.key, $event)"
@clear-secret="markCaptchaSecretForClear(provider.key, $event)"
@restore-secret="unmarkCaptchaSecretForClear(provider.key, $event)"
/>
</n-tab-pane>
</n-tabs>
</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 ? 'var(--aksrt-color-primary)' : '#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)
const serverSchema = ref(null)
const smsSavedSettings = ref('')
const captchaSecretsToClear = ref(new Set())
const captchaProviders = [
{ key: 'tencent', label: '腾讯云 Captcha', component: TencentConfig },
{ key: 'aliyun', label: '阿里云 CAPTCHA2.0', component: AliyunConfig },
{ key: 'cloudflare', label: 'Cloudflare Turnstile', component: CloudflareConfig },
{ key: 'geetest', label: '极验 Geetest', component: GeetestConfig },
]
const captchaProviderTab = ref('tencent')
/** 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 isEmailPage = computed(() => resolvedDomain.value === 'global' && resolvedSection.value === 'email')
const isSmsPage = computed(() => resolvedDomain.value === 'users' && resolvedSection.value === 'sms')
const isPermissionsLogin = computed(() => resolvedDomain.value === 'permissions' && resolvedSection.value === 'login')
const isPermissionsModeration = computed(() => resolvedDomain.value === 'permissions' && resolvedSection.value === 'moderation')
const isSmsSettingsDirty = computed(() => smsSavedSettings.value !== '' && smsSavedSettings.value !== smsSettingsSnapshot(formData.value))
function smsSettingsSnapshot(data) {
const settings = { ...(data || {}) }
delete settings.sms_test_phone
delete settings.sms_test_send
return JSON.stringify(settings)
}
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 = (isEmailPage.value || resolvedDomain.value === 'page_display')
? serverSchema.value
: 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 = (isEmailPage.value || resolvedDomain.value === 'page_display')
? serverSchema.value
: 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: async () => {
const response = await api.getSchemaPage(resolvedDomain.value, resolvedSection.value)
serverSchema.value = response?.schema || serverSchema.value
return response
},
submit: async (data) => {
const payload = { ...data }
const clearSecrets = []
Object.keys(payload).forEach((key) => {
if (payload[key] === '__AKSRT_CLEAR_SECRET__') {
clearSecrets.push(key)
delete payload[key]
}
})
if (isSmsPage.value) {
delete payload.sms_test_phone
delete payload.sms_test_send
}
if (clearSecrets.length) payload.__clear_secrets = clearSecrets
if (isPermissionsLogin.value && captchaSecretsToClear.value.size) {
payload.__clear_secrets = [...(payload.__clear_secrets || []), ...captchaSecretsToClear.value]
}
const response = await api.saveSchemaPage(resolvedDomain.value, resolvedSection.value, payload)
clearSecrets.forEach((key) => { data[key] = '' })
if (isPermissionsLogin.value) captchaSecretsToClear.value = new Set()
return response
},
defaults: defaults.value,
onLoaded: (data) => {
if (isSmsPage.value) smsSavedSettings.value = smsSettingsSnapshot(data)
},
onSaved: () => {
if (isSmsPage.value) smsSavedSettings.value = smsSettingsSnapshot(formData.value)
message.success('设置已保存')
},
})
async function handleFieldAction(key) {
if (key === 'sms_test_send' && isSmsPage.value) {
const phone = String(formData.value?.sms_test_phone || '').trim()
if (!phone) {
message.warning('请先填写测试手机号')
return
}
if (isSmsSettingsDirty.value) {
message.warning('请先保存当前配置,再发送测试短信')
return
}
try {
const response = await api.testSms(phone)
message.success(response?.message || '测试短信已发送')
} catch (error) {
message.error(error?.message || '测试短信发送失败')
}
return
}
if (key !== 'email_test_send' || !isEmailPage.value) return
const to = String(formData.value?.email_test_to || '').trim()
if (!to) {
message.warning('请先填写测试收件地址')
return
}
if (dirty.value) {
message.warning('请先保存当前配置,再发送测试邮件')
return
}
try {
const response = await api.testMail(to)
message.success(response?.message || '测试邮件已发送')
} catch (error) {
message.error(error?.message || '测试邮件发送失败')
}
}
async function handleSave() {
try {
await doSave()
} catch (err) {
message.error('保存失败: ' + (err?.message || '未知错误'))
}
}
async function handleReload() {
closeTestModal()
captchaSecretsToClear.value = new Set()
await reload()
syncCaptchaProviderTab()
message.info('已放弃更改')
}
function markCaptchaSecretForClear(provider, field) {
const path = `${provider}.${field}`
const next = new Set(captchaSecretsToClear.value)
next.add(path)
captchaSecretsToClear.value = next
}
function unmarkCaptchaSecretForClear(provider, field) {
const path = `${provider}.${field}`
if (!captchaSecretsToClear.value.has(path)) return
const next = new Set(captchaSecretsToClear.value)
next.delete(path)
captchaSecretsToClear.value = next
}
function setCaptchaProviderConfig(provider, value) {
formData.value = { ...formData.value, [provider]: value || {} }
}
function syncCaptchaProviderTab() {
const provider = formData.value?.active_provider
captchaProviderTab.value = captchaProviders.some(({ key }) => key === provider) ? provider : 'tencent'
}
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 (dirty.value) {
message.warning('请先保存当前配置,再执行真实服务端验签。')
return
}
showTestModal.value = true
testResult.value = null
testLoading.value = true
nextTick(() => {
loadProviderSDK(provider, cfg)
})
}
async function submitCaptchaTest(provider, token) {
testLoading.value = true
try {
const response = await api.testCaptcha(provider, token)
testResult.value = { success: true, message: response?.message || '服务端同源验签通过' }
} catch (error) {
testResult.value = { success: false, message: error?.message || '服务端验签失败' }
} finally {
testLoading.value = false
}
}
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 () {
testLoading.value = false
try {
new window.TencentCaptcha(cfg.app_id, function(res) {
if (res.ret === 0) {
submitCaptchaTest('tencent', {
tencent_ticket: res.ticket || '',
tencent_randstr: res.randstr || '',
})
} else {
testResult.value = { success: false, message: '验证已取消。' }
}
}, { userLanguage: 'zh-cn' }).show()
} catch (error) {
testResult.value = { success: false, message: '初始化失败: ' + error.message }
}
}
script.onerror = function () { testLoading.value = false; testResult.value = { success: false, message: '加载SDK失败' } }
break
case 'cloudflare':
window.onTurnstileTestLoad = function() {
testLoading.value = false
try {
window.turnstile.render('#captcha-test-box', {
sitekey: cfg.site_key,
theme: cfg.theme || 'auto',
size: cfg.size || 'normal',
action: 'aksrt-admin-test',
cdata: 'admin-captcha-test',
callback: function(token) {
submitCaptchaTest('cloudflare', { cf_turnstile_response: token })
},
'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 () { testLoading.value = false; testResult.value = { success: false, message: '加载SDK失败' } }
break
case 'geetest':
script.src = 'https://static.geetest.com/v4/gt4.js'
script.onload = function () {
testLoading.value = false
try {
window.initGeetest4({ captchaId: cfg.captcha_id, product: cfg.product || 'float' }, function(captchaObj) {
window.__gtTestCaptcha = captchaObj
const onSuccess = function() {
const result = captchaObj.getValidate ? captchaObj.getValidate() : null
if (!result) {
testResult.value = { success: false, message: '未取得极验验证参数' }
return
}
submitCaptchaTest('geetest', {
geetest_lot_number: result.lot_number || '',
geetest_captcha_output: result.captcha_output || '',
geetest_pass_token: result.pass_token || '',
geetest_gen_time: result.gen_time || '',
})
}
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 () { testLoading.value = false; 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 () {
testLoading.value = false
try {
window.initAliyunCaptcha({
SceneId: cfg.scene_id,
mode: 'embed',
element: '#captcha-test-box',
success: function(captchaVerifyParam) {
submitCaptchaTest('aliyun', { aliyun_captcha_verify_param: String(captchaVerifyParam || '') })
},
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 () { testLoading.value = false; 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(() => formData.value?.active_provider, syncCaptchaProviderTab)
watch([resolvedDomain, resolvedSection], ([nextDomain, nextSection], [prevDomain, prevSection]) => {
if (nextDomain !== prevDomain || nextSection !== prevSection) {
closeTestModal()
serverSchema.value = null
reload()
}
})
onBeforeUnmount(() => {
closeTestModal()
})
</script>
<style scoped>
.aksrt-schema-page {
max-width: none;
margin: 0;
}
.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: var(--aksrt-color-muted);
}
.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>