612 lines
20 KiB
Vue
612 lines
20 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="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">×</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>
|