diff --git a/assets/admin/src/api/modules/users.js b/assets/admin/src/api/modules/users.js index 3fc85192..39fa3c68 100644 --- a/assets/admin/src/api/modules/users.js +++ b/assets/admin/src/api/modules/users.js @@ -5,6 +5,7 @@ export const usersApiMethods = { getAuthSettings() { return this.request('/settings/auth') }, saveAuthSettings(data) { return this.request('/settings/auth', { method: 'POST', body: JSON.stringify(data) }) }, + testSms(phone) { return this.request('/sms/test', { method: 'POST', body: JSON.stringify({ phone }) }) }, getNotificationSettings() { return this.request('/settings/notifications') }, saveNotificationSettings(data) { return this.request('/settings/notifications', { method: 'POST', body: JSON.stringify(data) }) }, diff --git a/assets/admin/src/pages/SchemaPage.vue b/assets/admin/src/pages/SchemaPage.vue index 13415f55..dd8a3e7e 100644 --- a/assets/admin/src/pages/SchemaPage.vue +++ b/assets/admin/src/pages/SchemaPage.vue @@ -6,7 +6,7 @@ :icon="pageMeta.icon" :schema="pageMeta.groups" :form-data="formData" - :dirty="dirty" + :dirty="isSmsPage ? isSmsSettingsDirty : dirty" :loading="loading" :saving="saving" :load-error="loadError" @@ -131,6 +131,7 @@ 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 }, @@ -144,8 +145,17 @@ const captchaProviderTab = ref('tencent') 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) @@ -216,6 +226,10 @@ const { 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] @@ -226,12 +240,34 @@ const { 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) { diff --git a/assets/admin/src/schema/sections/users/sms.js b/assets/admin/src/schema/sections/users/sms.js index 9e910233..4d9d1f39 100644 --- a/assets/admin/src/schema/sections/users/sms.js +++ b/assets/admin/src/schema/sections/users/sms.js @@ -2,143 +2,98 @@ export const smsSchema = { key: 'users.sms', title: '短信接口', icon: 'fa fas fa-comment', - description: '配置短信验证码和短信通知服务的供应商、模板和限流策略。', + description: '设置手机验证码服务。验证码为 6 位数字,有效期 30 分钟。', defaults: { - sms_enabled: false, - sms_provider: 'aliyun', + sms_sdk: 'null', sms_number_limit_ch: true, - sms_access_key: '', - sms_access_secret: '', - sms_sign_name: '', - sms_template_code: '', - sms_code_length: 6, - sms_code_expire: 300, - sms_rate_limit: 60, - sms_daily_limit: 10, - sms_notify_bind: true, - sms_notify_login: false, - sms_notify_register: true, - sms_notify_reset_password: true, sms_ali_api_type: 'sms', + sms_ali_keyid: '', + sms_ali_keysecret: '', + sms_ali_sign_name: '', + sms_ali_template_code: '', sms_region_id: 'cn-hangzhou', sms_tencent_app_id: '', sms_tencent_app_key: '', - sms_tencent_secret_id: '', - sms_tencent_secret_key: '', - sms_tencent_region: 'ap-guangzhou', - sms_huawei_sender: '', - sms_huawei_signature: '', - sms_huawei_endpoint: '', - sms_custom_url: '', - sms_custom_method: 'POST', - sms_custom_auth_type: 'header', - sms_custom_auth_key: '', - sms_custom_auth_value: '', - sms_custom_headers: '', - sms_custom_body: '', - sms_custom_success_rule: '', + sms_tencent_sign_name: '', + sms_tencent_template_id: '', + sms_smsbao_username: '', + sms_smsbao_password: '', + sms_smsbao_api_key: '', + sms_smsbao_template: '', }, groups: [ { key: 'provider', - title: '供应商配置', + title: '短信接口', icon: 'fa fas fa-plug', fields: [ - { key: 'sms_enabled', label: '启用短信服务', type: 'switch', default: false, help: '开启后支持短信验证码和短信通知' }, { - key: 'sms_provider', - label: '短信供应商', + key: 'sms_sdk', + label: '设置短信接口', type: 'select', - default: 'aliyun', + default: 'null', options: [ - { label: '阿里云短信', value: 'aliyun' }, + { label: '不使用', value: 'null' }, + { label: '阿里云短信', value: 'ali' }, { label: '腾讯云短信', value: 'tencent' }, - { label: '华为云短信', value: 'huawei' }, - { label: '自定义接口', value: 'custom' }, + { label: '短信宝', value: 'smsbao' }, ], - dependsOn: { key: 'sms_enabled', value: true }, }, - { key: 'sms_number_limit_ch', label: '仅发国内', type: 'switch', default: true, dependsOn: { key: 'sms_enabled', value: true }, help: '开启后仅支持发送国内短信,关闭后可发送港澳台及国际短信' }, - { key: 'sms_access_key', label: 'AccessKey / SecretId / AppKey', type: 'input', default: '', dependsOn: { key: 'sms_enabled', value: true }, help: '阿里云填 AccessKey,腾讯云填 SecretId,华为云填 AppKey' }, - { key: 'sms_access_secret', label: 'AccessSecret / SecretKey / AppSecret', type: 'input', inputType: 'password', default: '', dependsOn: { key: 'sms_enabled', value: true } }, + { + key: 'sms_number_limit_ch', + label: '限制国内手机号', + type: 'switch', + default: true, + help: '开启后只允许发送到中国大陆手机号。', + }, + { key: 'sms_test_phone', label: '测试手机号', type: 'input', default: '', ui_only: true, help: '保存接口配置后,发送固定验证码 888888 测试短信。' }, + { key: 'sms_test_send', label: '发送测试短信', type: 'button', buttonType: 'primary', buttonText: '发送测试短信', ui_only: true }, ], }, { key: 'aliyun', - title: '阿里云配置', + title: '阿里云', icon: 'fa fas fa-cloud', fields: [ - { key: 'sms_ali_api_type', label: '接口类型', type: 'radio', default: 'sms', options: [{ label: '短信服务', value: 'sms' }, { label: '号码认证', value: 'verify' }], dependsOn: { key: 'sms_provider', value: 'aliyun' } }, - { key: 'sms_region_id', label: 'RegionId', type: 'input', default: 'cn-hangzhou', dependsOn: { key: 'sms_provider', value: 'aliyun' } }, + { + key: 'sms_ali_api_type', + label: '产品', + type: 'radio', + default: 'sms', + options: [ + { label: '短信服务', value: 'sms' }, + { label: '号码认证:短信认证服务', value: 'verify' }, + ], + dependsOn: { key: 'sms_sdk', value: 'ali' }, + }, + { key: 'sms_ali_keyid', label: 'AccessKey Id', type: 'input', default: '', dependsOn: { key: 'sms_sdk', value: 'ali' } }, + { key: 'sms_ali_keysecret', label: 'AccessKey Secret', type: 'input', inputType: 'password', secret: true, default: '', dependsOn: { key: 'sms_sdk', value: 'ali' } }, + { key: 'sms_ali_sign_name', label: '签名', type: 'input', default: '', dependsOn: { key: 'sms_sdk', value: 'ali' } }, + { key: 'sms_ali_template_code', label: '模板 CODE', type: 'input', default: '', dependsOn: { all: [{ key: 'sms_sdk', value: 'ali' }, { key: 'sms_ali_api_type', not: true, value: 'verify' }] }, help: '短信服务需要填写。号码认证产品固定使用平台模板。' }, + { key: 'sms_region_id', label: 'RegionId', type: 'input', default: 'cn-hangzhou', dependsOn: { key: 'sms_sdk', value: 'ali' } }, ], }, { key: 'tencent', - title: '腾讯云配置', + title: '腾讯云', icon: 'fa fas fa-cloud', fields: [ - { key: 'sms_tencent_app_id', label: 'SdkAppId', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'tencent' }, help: '腾讯云短信应用 ID' }, - { key: 'sms_tencent_app_key', label: 'AppKey', type: 'input', inputType: 'password', default: '', dependsOn: { key: 'sms_provider', value: 'tencent' } }, - { key: 'sms_tencent_secret_id', label: 'SecretId', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'tencent' } }, - { key: 'sms_tencent_secret_key', label: 'SecretKey', type: 'input', inputType: 'password', default: '', dependsOn: { key: 'sms_provider', value: 'tencent' } }, - { key: 'sms_tencent_region', label: '地域', type: 'input', default: 'ap-guangzhou', dependsOn: { key: 'sms_provider', value: 'tencent' } }, + { key: 'sms_tencent_app_id', label: 'SDK AppID', type: 'input', default: '', dependsOn: { key: 'sms_sdk', value: 'tencent' } }, + { key: 'sms_tencent_app_key', label: 'App Key', type: 'input', inputType: 'password', secret: true, default: '', dependsOn: { key: 'sms_sdk', value: 'tencent' } }, + { key: 'sms_tencent_sign_name', label: '签名', type: 'input', default: '', dependsOn: { key: 'sms_sdk', value: 'tencent' } }, + { key: 'sms_tencent_template_id', label: '模板 ID', type: 'input', default: '', dependsOn: { key: 'sms_sdk', value: 'tencent' }, help: '模板包含验证码和有效分钟数两个参数。' }, ], }, { - key: 'huawei', - title: '华为云配置', + key: 'smsbao', + title: '短信宝', icon: 'fa fas fa-cloud', fields: [ - { key: 'sms_huawei_sender', label: '通道号 (sender)', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'huawei' } }, - { key: 'sms_huawei_signature', label: '短信签名', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'huawei' }, help: '华为云短信签名内容' }, - { key: 'sms_huawei_endpoint', label: 'API Endpoint', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'huawei' }, help: '留空使用默认 endpoint' }, - ], - }, - { - key: 'custom', - title: '自定义接口配置', - icon: 'fa fas fa-plug', - fields: [ - { key: 'sms_custom_url', label: '请求 URL', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'custom' }, help: '支持 {phone} {code} {time} 变量' }, - { key: 'sms_custom_method', label: '请求方法', type: 'select', default: 'POST', options: [{ label: 'POST', value: 'POST' }, { label: 'GET', value: 'GET' }, { label: 'PUT', value: 'PUT' }], dependsOn: { key: 'sms_provider', value: 'custom' } }, - { key: 'sms_custom_auth_type', label: '鉴权方式', type: 'select', default: 'header', options: [{ label: 'Header', value: 'header' }], dependsOn: { key: 'sms_provider', value: 'custom' } }, - { key: 'sms_custom_auth_key', label: '鉴权 Header 名', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'custom' } }, - { key: 'sms_custom_auth_value', label: '鉴权 Header 值', type: 'input', inputType: 'password', default: '', dependsOn: { key: 'sms_provider', value: 'custom' }, help: '支持 {phone} {code} {time} 变量' }, - { key: 'sms_custom_headers', label: '自定义 Headers', type: 'textarea', default: '', dependsOn: { key: 'sms_provider', value: 'custom' }, help: '每行一个,格式:Key: Value' }, - { key: 'sms_custom_body', label: '请求 Body', type: 'textarea', default: '', dependsOn: { key: 'sms_provider', value: 'custom' }, help: 'JSON 格式,支持 {phone} {code} {time} 变量' }, - { key: 'sms_custom_success_rule', label: '成功判定规则', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'custom' }, help: '格式:status:200 或 json:code=0 或 contains:success' }, - ], - }, - { - key: 'template', - title: '短信模板', - icon: 'fa fas fa-comment', - fields: [ - { key: 'sms_sign_name', label: '短信签名', type: 'input', default: '', dependsOn: { key: 'sms_enabled', value: true }, help: '在短信服务商申请的短信签名(所有供应商共用此字段)' }, - { key: 'sms_template_code', label: '验证码模板 ID', type: 'input', default: '', dependsOn: { key: 'sms_enabled', value: true }, help: '所有供应商共用此模板 ID' }, - { key: 'sms_code_length', label: '验证码长度', type: 'number', default: 6, min: 4, max: 8, dependsOn: { key: 'sms_enabled', value: true } }, - { key: 'sms_code_expire', label: '验证码有效期 (秒)', type: 'number', default: 300, min: 60, max: 600, dependsOn: { key: 'sms_enabled', value: true } }, - ], - }, - { - key: 'rate_limit', - title: '限流策略', - icon: 'fa fas fa-shield-alt', - fields: [ - { key: 'sms_rate_limit', label: '发送间隔 (秒)', type: 'number', default: 60, min: 10, max: 300, dependsOn: { key: 'sms_enabled', value: true }, help: '同一手机号两次发送的最小间隔' }, - { key: 'sms_daily_limit', label: '每日发送上限', type: 'number', default: 10, min: 1, max: 100, dependsOn: { key: 'sms_enabled', value: true }, help: '同一手机号每天最多发送条数' }, - ], - }, - { - key: 'triggers', - title: '通知场景', - icon: 'fa fas fa-bell', - fields: [ - { key: 'sms_notify_bind', label: '绑定手机时验证', type: 'switch', default: true, dependsOn: { key: 'sms_enabled', value: true } }, - { key: 'sms_notify_login', label: '短信登录', type: 'switch', default: false, dependsOn: { key: 'sms_enabled', value: true } }, - { key: 'sms_notify_register', label: '注册验证', type: 'switch', default: true, dependsOn: { key: 'sms_enabled', value: true } }, - { key: 'sms_notify_reset_password', label: '找回密码', type: 'switch', default: true, dependsOn: { key: 'sms_enabled', value: true } }, + { key: 'sms_smsbao_username', label: '用户名', type: 'input', default: '', dependsOn: { key: 'sms_sdk', value: 'smsbao' } }, + { key: 'sms_smsbao_password', label: '密码', type: 'input', inputType: 'password', secret: true, default: '', dependsOn: { key: 'sms_sdk', value: 'smsbao' } }, + { key: 'sms_smsbao_api_key', label: 'ApiKey', type: 'input', inputType: 'password', secret: true, default: '', dependsOn: { key: 'sms_sdk', value: 'smsbao' }, help: '可选;填写后以 ApiKey 认证,可与密码任选其一。' }, + { key: 'sms_smsbao_template', label: '模板内容', type: 'input', default: '', dependsOn: { key: 'sms_sdk', value: 'smsbao' }, help: '必须包含 {code},有效时间变量为 {time}。' }, ], }, ], -} \ No newline at end of file +} diff --git a/assets/js/routes/user-center.js b/assets/js/routes/user-center.js index dcebd010..ec3254e1 100644 --- a/assets/js/routes/user-center.js +++ b/assets/js/routes/user-center.js @@ -1112,10 +1112,16 @@ if (!form) return; if (form.dataset.aksrtSettingsInitialized === 'true') return; form.dataset.aksrtSettingsInitialized = 'true'; + function isValidSmsPhone(phone) { + if (!window.aksrtData || aksrtData.smsNumberLimitCh !== false) { + return /^1[3-9]\d{9}$/.test(phone); + } + return /^(?:\d{5,}|\+\d{6,}|\+?\d{2,}[-\s]\d{2,}[0-9\-\s]{0,15})$/.test(phone); + } var sendButton = form.querySelector('[data-aksrt-sms-trigger]'); if (sendButton) sendButton.addEventListener('click', function () { var phone = String((form.querySelector('[name="phone"]') || {}).value || '').trim(); - if (!/^1[3-9]\d{9}$/.test(phone)) { notyf('请输入正确的手机号', 'warning'); return; } + if (!isValidSmsPhone(phone)) { notyf('请输入正确的手机号', 'warning'); return; } var toastId = 'aksrt-settings-phone-code-toast'; var restRoot = (window.aksrtData && aksrtData.apiUrl) || '/wp-json/aksrt/v1'; var nonce = (window.aksrtData && aksrtData.restNonce) || ''; @@ -1146,11 +1152,11 @@ var codeEl = form.querySelector('[name="code"]'); var phone = phoneEl ? String(phoneEl.value || '').trim() : ''; var code = codeEl ? String(codeEl.value || '').trim() : ''; - if (!/^1[3-9]\d{9}$/.test(phone)) { + if (!isValidSmsPhone(phone)) { notyf('请输入正确的手机号', 'warning'); return; } - if (!code) { + if (!/^\d{6}$/.test(code)) { notyf('请输入短信验证码', 'warning'); return; } diff --git a/inc/helpers/user-center-renderers/profile-account-modals.php b/inc/helpers/user-center-renderers/profile-account-modals.php index d10fab3c..be470e39 100644 --- a/inc/helpers/user-center-renderers/profile-account-modals.php +++ b/inc/helpers/user-center-renderers/profile-account-modals.php @@ -110,12 +110,12 @@ extract($context, EXTR_SKIP); // phpcs:ignore WordPress.PHP.DontExtract.extract_