完成部分短信发送界面
parent
c59c9c906d
commit
3a7b15dd34
@ -0,0 +1,98 @@
|
||||
class Message {
|
||||
constructor(messageList) {
|
||||
this.messages = new Map();
|
||||
this.setMessages(messageList);
|
||||
}
|
||||
|
||||
setMessages(messageList) {
|
||||
if (messageList instanceof Map) {
|
||||
this.messages = messageList;
|
||||
} else {
|
||||
this.messages.clear();
|
||||
for (let key in messageList) {
|
||||
this.messages.set(key, messageList[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
localize(msgId, args) {
|
||||
if (this.messages.has(msgId)) {
|
||||
return this.replaceVariables(this.messages.get(msgId) || '{' + msgId + '}', args);
|
||||
} else {
|
||||
return '{' + msgId + '}';
|
||||
}
|
||||
}
|
||||
|
||||
// Alias for compatibility
|
||||
text(msgId, args) {
|
||||
return this.localize(msgId, args);
|
||||
}
|
||||
|
||||
replaceVariables(template, params = []) {
|
||||
let result = template;
|
||||
let paramId;
|
||||
result = result.replace(/(?<!\\)\$\{(\w+)\}/g, (...matches) => {
|
||||
paramId = parseInt(matches[1]) - 1;
|
||||
console.log(paramId);
|
||||
if (params[paramId]) {
|
||||
return params[paramId];
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static makeGlobal(messageList = []) {
|
||||
window.msg = new Message(messageList);
|
||||
}
|
||||
}
|
||||
|
||||
Message.makeGlobal();
|
||||
|
||||
class Config {
|
||||
constructor(configList) {
|
||||
this.configs = new Map();
|
||||
this.setConfigs(configList);
|
||||
}
|
||||
|
||||
setConfigs(configList) {
|
||||
if (configList instanceof Map) {
|
||||
this.configs = configList;
|
||||
} else {
|
||||
this.configs.clear();
|
||||
for (let key in configList) {
|
||||
this.configs.set(key, configList[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get(configId, defaultValue = null) {
|
||||
if (this.configs.has(configId)) {
|
||||
return this.configs.get(configId)
|
||||
} else {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
static makeGlobal(configList = []) {
|
||||
window.config = new Config(configList);
|
||||
}
|
||||
}
|
||||
|
||||
Config.makeGlobal();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const messages = document.querySelector('script[type="application/json"][data-messages]');
|
||||
if (messages) {
|
||||
const messageList = JSON.parse(messages.textContent);
|
||||
window.msg.setMessages(messageList);
|
||||
}
|
||||
|
||||
const configs = document.querySelector('script[type="application/json"][data-configs]');
|
||||
if (configs) {
|
||||
const configList = JSON.parse(configs.textContent);
|
||||
window.config.setConfigs(configList);
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,64 @@
|
||||
export class PhoneProviderGeetestCaptcha {
|
||||
constructor() {
|
||||
this._captchaObj = null;
|
||||
this._shouldCallCaptcha = false;
|
||||
this._onSuccess = null;
|
||||
this._onError = null;
|
||||
|
||||
this.realmUrl = config.get('realmUrl');
|
||||
|
||||
fetch(this.realmUrl + '/geetest/code').then((res) => {
|
||||
if (res.ok) {
|
||||
return res.json();
|
||||
} else {
|
||||
throw msg.text('captcha-code-api-error');
|
||||
}
|
||||
}).then((data) => {
|
||||
initGeetest({
|
||||
gt: data.gt,
|
||||
challenge: data.challenge,
|
||||
offline: !data.success,
|
||||
new_captcha: true,
|
||||
product: 'bind',
|
||||
}, (obj) => {
|
||||
this._captchaObj = obj;
|
||||
if (this._shouldCallCaptcha) { //加载完成后立刻调用
|
||||
this._captchaObj.verify();
|
||||
this._captchaObj.onSuccess(this.buildOnSuccess(this._onSuccess));
|
||||
this._shouldCallCaptcha = false;
|
||||
this._onSuccess = null;
|
||||
this._onError = null;
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
verify() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this._captchaObj) {
|
||||
this._shouldCallCaptcha = true;
|
||||
this._onSuccess = resolve;
|
||||
this._onError = reject;
|
||||
} else { //已经加载成功的情况,直接调用
|
||||
this._captchaObj.verify();
|
||||
this._captchaObj.onSuccess(this.buildOnSuccess(resolve));
|
||||
this._captchaObj.onError((err) => {
|
||||
alert(msg.text('captcha-load-error'));
|
||||
reject('error', err);
|
||||
});
|
||||
|
||||
this._captchaObj.onClose(() => {
|
||||
reject('close');
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
buildOnSuccess(callback) {
|
||||
return (function() {
|
||||
var result = this._captchaObj.getValidate();
|
||||
callback(result);
|
||||
this._captchaObj.reset();
|
||||
}).bind(this);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,127 @@
|
||||
const _pfui = {
|
||||
fadeOutElement: ($el, duration = 150, useHidden = false) => {
|
||||
return new Promise(resolve => {
|
||||
$el.style.transition = `opacity ${duration}ms linear`;
|
||||
$el.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
if (useHidden) {
|
||||
$el.hidden = true;
|
||||
} else {
|
||||
$el.style.display = 'none';
|
||||
}
|
||||
resolve();
|
||||
}, duration);
|
||||
});
|
||||
},
|
||||
fadeInElement: ($el, duration = 150, useHidden = false, display = 'block') => {
|
||||
return new Promise(resolve => {
|
||||
if (useHidden) {
|
||||
$el.hidden = false;
|
||||
} else {
|
||||
$el.style.display = display;
|
||||
}
|
||||
$el.style.opacity = '0';
|
||||
$el.style.transition = `opacity ${duration}ms`;
|
||||
requestAnimationFrame(() => {
|
||||
$el.style.opacity = '1';
|
||||
});
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, duration);
|
||||
});
|
||||
},
|
||||
compControllers: [],
|
||||
init: () => {
|
||||
// Initialize components if any
|
||||
document.querySelectorAll('.pf-v5-c-tabs').forEach($tabsBar => {
|
||||
if (!_pfui.compControllers.find(c => c.$el === $tabsBar)) {
|
||||
const tabs = new Tabs($tabsBar);
|
||||
_pfui.compControllers.push({ $el: $tabsBar, controller: tabs });
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
class Tabs {
|
||||
constructor($tabsBar) {
|
||||
this.$tabsBar = $tabsBar;
|
||||
this._handleTabClickEvent = this._handleTabClick.bind(this);
|
||||
|
||||
// Find the associated tab panel
|
||||
if (this.$tabsBar && this.$tabsBar.dataset.tabPanel) {
|
||||
this.$tabPanel = document.getElementById(this.$tabsBar.dataset.tabPanel);
|
||||
if (this.$tabPanel) {
|
||||
this._init();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_init() {
|
||||
this.$tabsBar.querySelectorAll('.pf-v5-c-tabs__link').forEach($tab => {
|
||||
$tab.addEventListener('click', this._handleTabClickEvent);
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.$tabsBar.querySelectorAll('.pf-v5-c-tabs__link').forEach($tab => {
|
||||
$tab.removeEventListener('click', this._handleTabClickEvent);
|
||||
});
|
||||
}
|
||||
|
||||
_handleTabClick(e) {
|
||||
e.preventDefault();
|
||||
const $tab = e.currentTarget;
|
||||
this._activateTab($tab);
|
||||
}
|
||||
|
||||
_activateTab($tab) {
|
||||
const targetId = $tab.dataset.tabTarget;
|
||||
if (!targetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Deactivate all tabs
|
||||
this.$tabsBar.querySelectorAll('.pf-v5-c-tabs__item').forEach($item => {
|
||||
$item.classList.remove('pf-m-current');
|
||||
const $link = $item.querySelector('.pf-v5-c-tabs__link');
|
||||
if ($link) {
|
||||
$link.setAttribute('aria-selected', 'false');
|
||||
}
|
||||
});
|
||||
|
||||
// Activate the clicked tab
|
||||
const $tabItem = $tab.closest('.pf-v5-c-tabs__item');
|
||||
if ($tabItem) {
|
||||
$tabItem.classList.add('pf-m-current');
|
||||
}
|
||||
$tab.setAttribute('aria-selected', 'true');
|
||||
|
||||
// Hide all tab panels
|
||||
let promises = [];
|
||||
this.$tabPanel.querySelectorAll('.pf-v5-c-tab-content').forEach($panel => {
|
||||
if (!$panel.hidden) {
|
||||
promises.push(_pfui.fadeOutElement($panel, 200, true));
|
||||
}
|
||||
});
|
||||
|
||||
// Show the corresponding tab panel
|
||||
Promise.all(promises).then(() => {
|
||||
const $targetPanel = this.$tabPanel.querySelector(`.pf-v5-c-tab-content[data-tab-id="${targetId}"]`);
|
||||
if ($targetPanel) {
|
||||
_pfui.fadeInElement($targetPanel, 200, true);
|
||||
}
|
||||
});
|
||||
|
||||
// Send pf.tab.activate event
|
||||
const activateEvent = new CustomEvent('pf.tab.activate', {
|
||||
detail: {
|
||||
tabId: targetId
|
||||
}
|
||||
});
|
||||
this.$tabsBar.dispatchEvent(activateEvent);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
_pfui.init();
|
||||
});
|
||||
@ -1,14 +1,226 @@
|
||||
import './lib/gt.js';
|
||||
import './lib/tom-select.complete.min.js';
|
||||
import { PhoneProviderGeetestCaptcha } from './lib/gtutils.js';
|
||||
|
||||
class SmsSenderForm {
|
||||
constructor(type, formSelector) {
|
||||
this.type = type;
|
||||
this.formElem = document.querySelector(formSelector);
|
||||
this.realmUrl = config.get('realmUrl');
|
||||
|
||||
this.selectAreaCode = document.querySelector("#areaCodeInput");
|
||||
this.inputPhoneNumber = document.querySelector("#phoneNumberInput");
|
||||
this.inputCode = document.querySelector("#smsCodeInput");
|
||||
this.btnSend = document.querySelector("#btnSendSmsCode");
|
||||
|
||||
this.countdown = null;
|
||||
this.resendTimeout = 120;
|
||||
this.areaCodeSelect = null;
|
||||
|
||||
this.bindEvents();
|
||||
this.initConfig();
|
||||
this.initCaptcha();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
this.btnSend.addEventListener('click', this.onBtnSendClick.bind(this));
|
||||
|
||||
this.inputPhoneNumber.addEventListener('input', () => {
|
||||
// Hide error message when user types
|
||||
const errorMsg = this.formElem.querySelector('.pf-v5-c-form__helper-text--error');
|
||||
if (errorMsg) {
|
||||
errorMsg.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
this.formElem.addEventListener('submit', () => {
|
||||
if (this.areaCodeSelect) {
|
||||
this.areaCodeSelect.enable();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initCaptcha() {
|
||||
this.captcha = new PhoneProviderGeetestCaptcha();
|
||||
}
|
||||
|
||||
initConfig() {
|
||||
fetch(this.realmUrl + '/sms').then((res) => {
|
||||
if (res.ok) {
|
||||
return res.json();
|
||||
} else {
|
||||
throw new Error(msg.localize('cannotGetConfig'));
|
||||
}
|
||||
}).then((res) => {
|
||||
this.setAreaCodeList(res.areaCodeList || []);
|
||||
|
||||
// Initialize TomSelect after data is loaded
|
||||
this.areaCodeSelect = new TomSelect('#areaCodeInput', {
|
||||
maxItems: 1,
|
||||
valueField: 'value',
|
||||
labelField: 'text',
|
||||
searchField: ['text', 'value'],
|
||||
create: false,
|
||||
});
|
||||
|
||||
if (res.areaLocked) {
|
||||
this.areaCodeSelect.disable();
|
||||
}
|
||||
|
||||
const defaultAreaCode = this.selectAreaCode.getAttribute('data-value') || res.defaultAreaCode || '86';
|
||||
this.areaCodeSelect.setValue(defaultAreaCode.toString());
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
this.showError(err.message);
|
||||
});
|
||||
}
|
||||
|
||||
getFilledAreaCode(areaCode) {
|
||||
const maxLen = 4;
|
||||
let areaCodeStr = areaCode.toString();
|
||||
return '+' + areaCodeStr + ('\u2002'.repeat(maxLen - areaCodeStr.length));
|
||||
}
|
||||
|
||||
getLocalizedName(nameList, langCode) {
|
||||
if (langCode in nameList) {
|
||||
return nameList[langCode];
|
||||
} else {
|
||||
return nameList['en'];
|
||||
}
|
||||
}
|
||||
|
||||
showError(errorMessage) {
|
||||
// Find or create error message element
|
||||
let errorElement = this.formElem.querySelector('.phone-error-message');
|
||||
if (!errorElement) {
|
||||
errorElement = document.createElement('div');
|
||||
errorElement.className = 'pf-v5-c-form__helper-text pf-v5-c-form__helper-text--error phone-error-message';
|
||||
const phoneGroup = this.formElem.querySelector('[name="phoneNumber"]').closest('.pf-v5-c-form__group');
|
||||
if (phoneGroup) {
|
||||
phoneGroup.appendChild(errorElement);
|
||||
}
|
||||
}
|
||||
errorElement.textContent = errorMessage;
|
||||
errorElement.style.display = 'block';
|
||||
}
|
||||
|
||||
setAreaCodeList(areaCodeList) {
|
||||
const langCode = msg.localize('countryNameLangCode');
|
||||
|
||||
// Clear existing options
|
||||
this.selectAreaCode.innerHTML = '';
|
||||
|
||||
// Add new options
|
||||
areaCodeList.forEach((info) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = info.areaCode;
|
||||
const displayName = this.getFilledAreaCode(info.areaCode) + '\u2002\u2002' + this.getLocalizedName(info.name, langCode);
|
||||
option.textContent = displayName;
|
||||
this.selectAreaCode.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
startCountdown() {
|
||||
let currentTimeout = this.resendTimeout;
|
||||
const resendMsg = msg.localize('resendVerificationCode');
|
||||
const secondMsg = msg.localize('second');
|
||||
|
||||
this.countdown = setInterval(() => {
|
||||
currentTimeout -= 1;
|
||||
if (currentTimeout <= 0) {
|
||||
this.stopCountdown();
|
||||
return;
|
||||
}
|
||||
this.btnSend.textContent = resendMsg + ' (' + currentTimeout.toString() + secondMsg + ')';
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
stopCountdown() {
|
||||
if (this.countdown !== null) {
|
||||
clearInterval(this.countdown);
|
||||
this.countdown = null;
|
||||
this.btnSend.textContent = msg.localize('resendVerificationCode');
|
||||
this.btnSend.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
checkInput() {
|
||||
if (this.inputPhoneNumber.value.trim() === "") {
|
||||
this.showError(msg.localize('phoneNumberIsEmpty'));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
onBtnSendClick() {
|
||||
if (!this.checkInput()) return;
|
||||
|
||||
this.captcha.verify().then((data) => {
|
||||
this.btnSend.disabled = true;
|
||||
this.btnSend.textContent = msg.localize('sending');
|
||||
|
||||
data.areaCode = this.areaCodeSelect.getValue();
|
||||
data.phoneNumber = this.inputPhoneNumber.value;
|
||||
|
||||
fetch(this.realmUrl + '/sms/' + this.type + '-code', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}).then((res) => {
|
||||
if (res.ok) {
|
||||
return res.json();
|
||||
} else {
|
||||
throw new Error(msg.localize('sendVerificationError', [res.status + ' ' + res.statusText]));
|
||||
}
|
||||
}).then((res) => {
|
||||
if (res.status == 1) {
|
||||
this.startCountdown();
|
||||
} else if (res.status == 0) {
|
||||
throw new Error(res.errormsg ? msg.localize(res.errormsg, [res.error]) : res.error);
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
this.showError(err.message);
|
||||
|
||||
this.btnSend.disabled = false;
|
||||
this.btnSend.textContent = msg.localize('sendVerificationCode');
|
||||
});
|
||||
}).catch((err) => {
|
||||
if (err !== 'close') { // User closed captcha
|
||||
console.error(err);
|
||||
this.showError(typeof err === 'string' ? err : err.message);
|
||||
this.btnSend.disabled = false;
|
||||
this.btnSend.textContent = msg.localize('sendVerificationCode');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Initialize TomSelect for area code selection
|
||||
const areaCodeSelect = new TomSelect('#areaCodeInput', {
|
||||
maxItems: 1,
|
||||
valueField: 'areaCode',
|
||||
labelField: 'countryName',
|
||||
searchField: ['countryName', 'countryCode', 'areaCode'],
|
||||
create: false,
|
||||
// Initialize SMS sender form for login
|
||||
window.SmsSenderForm = new SmsSenderForm("login", "#kc-form-login");
|
||||
|
||||
// Handle tab switching to set the login type
|
||||
document.addEventListener('pf.tab.activate', function(event) {
|
||||
const tabId = event.detail.tabId;
|
||||
let loginTypeInput = document.querySelector('#loginType');
|
||||
|
||||
if (!loginTypeInput) {
|
||||
loginTypeInput = document.createElement('input');
|
||||
loginTypeInput.type = 'hidden';
|
||||
loginTypeInput.name = 'loginType';
|
||||
loginTypeInput.id = 'loginType';
|
||||
document.querySelector('#kc-form-login').appendChild(loginTypeInput);
|
||||
}
|
||||
|
||||
loginTypeInput.value = tabId; // 'password' or 'phone'
|
||||
});
|
||||
|
||||
|
||||
// Set initial login type to password (default active tab)
|
||||
const initialLoginType = document.createElement('input');
|
||||
initialLoginType.type = 'hidden';
|
||||
initialLoginType.name = 'loginType';
|
||||
initialLoginType.id = 'loginType';
|
||||
initialLoginType.value = 'password';
|
||||
document.querySelector('#kc-form-login').appendChild(initialLoginType);
|
||||
});
|
||||
@ -0,0 +1,77 @@
|
||||
<#macro tabsBar panelId filled=false>
|
||||
<div
|
||||
class="pf-v5-c-tabs<#if filled> pf-m-fill</#if>"
|
||||
role="region"
|
||||
id="default-tabs"
|
||||
<#if panelId??>data-tab-panel="${panelId}"</#if>
|
||||
>
|
||||
<button
|
||||
class="pf-v5-c-tabs__scroll-button"
|
||||
type="button"
|
||||
disabled
|
||||
aria-hidden="true"
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<i class="fas fa-angle-left" aria-hidden="true">
|
||||
</i>
|
||||
</button>
|
||||
<ul class="pf-v5-c-tabs__list" role="tablist">
|
||||
<#nested>
|
||||
</ul>
|
||||
<button
|
||||
class="pf-v5-c-tabs__scroll-button"
|
||||
type="button"
|
||||
disabled
|
||||
aria-hidden="true"
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<i class="fas fa-angle-right" aria-hidden="true">
|
||||
</i>
|
||||
</button>
|
||||
</div>
|
||||
</#macro>
|
||||
|
||||
<#macro tabButton tabId label panelId="" active=false disabled=false>
|
||||
<li class="pf-v5-c-tabs__item<#if active> pf-m-current</#if>" role="presentation">
|
||||
<a
|
||||
class="pf-v5-c-tabs__link"
|
||||
<#if tabId??>
|
||||
href="#${tabId}"
|
||||
data-tab-target="${tabId}"
|
||||
</#if>
|
||||
<#if panelId??>
|
||||
aria-controls="${panelId}"
|
||||
</#if>
|
||||
role="tab"
|
||||
aria-selected="<#if active>true<#else>false</#if>"
|
||||
<#if disabled>
|
||||
aria-disabled="true"
|
||||
disabled
|
||||
</#if>
|
||||
>
|
||||
${kcSanitize(msg(label))?no_esc}
|
||||
</a>
|
||||
</li>
|
||||
</#macro>
|
||||
|
||||
<#macro tabPanel panelId>
|
||||
<div
|
||||
class="pf-v5-c-tab-panel"
|
||||
id="${panelId}"
|
||||
>
|
||||
<#nested>
|
||||
</div>
|
||||
</#macro>
|
||||
|
||||
<#macro tabContent tabId active=false>
|
||||
<div
|
||||
data-tab-id="${tabId}"
|
||||
role="tabpanel"
|
||||
class="pf-v5-c-tab-content"
|
||||
<#if !active>
|
||||
hidden
|
||||
</#if>
|
||||
>
|
||||
<#nested>
|
||||
</div>
|
||||
</#macro>
|
||||
Loading…
Reference in New Issue