565 lines
17 KiB
Vue
565 lines
17 KiB
Vue
<template>
|
||
<view
|
||
v-show="visible"
|
||
class="tac-layer"
|
||
:class="{ 'tac-layer--visible': visible }"
|
||
:aria-hidden="visible ? 'false' : 'true'"
|
||
@click.stop="requestCancel"
|
||
>
|
||
<view
|
||
id="jiapu-tac-dialog"
|
||
class="tac-panel"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
tabindex="-1"
|
||
aria-labelledby="jiapu-tac-title"
|
||
aria-describedby="jiapu-tac-description"
|
||
@click.stop
|
||
@keydown.esc.stop.prevent="requestCancel"
|
||
>
|
||
<view class="tac-heading">
|
||
<view class="tac-heading__copy">
|
||
<text id="jiapu-tac-title" class="tac-title">安全验证</text>
|
||
<text id="jiapu-tac-description" class="tac-description"
|
||
>拖动滑块完成验证;可刷新当前挑战或关闭返回。</text
|
||
>
|
||
</view>
|
||
<view class="tac-tools" role="group" aria-label="安全验证操作">
|
||
<button
|
||
class="tac-tool tac-tool--refresh"
|
||
aria-label="刷新安全验证"
|
||
@click.stop="requestRefresh"
|
||
>↻</button>
|
||
<button
|
||
class="tac-tool tac-tool--close"
|
||
aria-label="关闭安全验证"
|
||
@click.stop="requestCancel"
|
||
>×</button>
|
||
</view>
|
||
</view>
|
||
<view
|
||
id="jiapu-tac-host"
|
||
class="tac-host"
|
||
:prop="renderContext"
|
||
:change:prop="tacRenderer.onContextChange"
|
||
/>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
export default {
|
||
name: "TacVerification",
|
||
data() {
|
||
return {
|
||
refreshSequence: 0,
|
||
};
|
||
},
|
||
props: {
|
||
visible: {
|
||
type: Boolean,
|
||
default: false,
|
||
},
|
||
context: {
|
||
type: Object,
|
||
default: null,
|
||
},
|
||
},
|
||
emits: ["success", "failure", "error", "cancel"],
|
||
computed: {
|
||
renderContext() {
|
||
return {
|
||
...(this.context || {}),
|
||
visible: this.visible,
|
||
refreshSequence: this.refreshSequence,
|
||
};
|
||
},
|
||
},
|
||
methods: {
|
||
requestCancel() {
|
||
if (this.visible) this.$emit("cancel");
|
||
},
|
||
requestRefresh() {
|
||
if (this.visible) this.refreshSequence += 1;
|
||
},
|
||
handleTacSuccess(payload) {
|
||
this.$emit("success", payload);
|
||
},
|
||
handleTacFailure(payload) {
|
||
this.$emit("failure", payload);
|
||
},
|
||
handleTacError(payload) {
|
||
this.$emit("error", payload);
|
||
},
|
||
handleTacCancel(payload) {
|
||
this.$emit("cancel", payload);
|
||
},
|
||
},
|
||
};
|
||
</script>
|
||
|
||
<script module="tacRenderer" lang="renderjs">
|
||
const TAC_STYLE_URL = "./static/tac/css/tac.css";
|
||
const TAC_SCRIPT_URL = "./static/tac/js/tac.min.js";
|
||
const TAC_ADAPTER_URL = "./static/tac/js/jiapu-tac-adapter.js";
|
||
|
||
const loadStyle = (href) => {
|
||
const existing = document.querySelector(`link[data-jiapu-tac="${href}"]`);
|
||
if (existing && (existing.dataset.jiapuState === "loaded" || existing.sheet)) {
|
||
return Promise.resolve();
|
||
}
|
||
if (existing && existing.dataset.jiapuState === "loading") {
|
||
return new Promise((resolve, reject) => {
|
||
existing.addEventListener("load", resolve, { once: true });
|
||
existing.addEventListener("error", () => reject(new Error(`安全验证样式加载失败:${href}`)), { once: true });
|
||
});
|
||
}
|
||
if (existing) existing.remove();
|
||
return new Promise((resolve, reject) => {
|
||
const link = document.createElement("link");
|
||
link.rel = "stylesheet";
|
||
link.href = href;
|
||
link.setAttribute("data-jiapu-tac", href);
|
||
link.dataset.jiapuState = "loading";
|
||
link.onload = () => {
|
||
link.dataset.jiapuState = "loaded";
|
||
resolve();
|
||
};
|
||
link.onerror = () => {
|
||
link.remove();
|
||
reject(new Error(`安全验证样式加载失败:${href}`));
|
||
};
|
||
document.head.appendChild(link);
|
||
});
|
||
};
|
||
|
||
const loadScript = (src, ready) => {
|
||
if (ready()) return Promise.resolve();
|
||
const existing = document.querySelector(`script[data-jiapu-tac="${src}"]`);
|
||
if (existing && existing.dataset.jiapuState === "loading") {
|
||
return new Promise((resolve, reject) => {
|
||
existing.addEventListener("load", () => {
|
||
if (ready()) {
|
||
existing.dataset.jiapuState = "loaded";
|
||
resolve();
|
||
} else {
|
||
existing.remove();
|
||
reject(new Error(`安全验证脚本未提供预期能力:${src}`));
|
||
}
|
||
}, { once: true });
|
||
existing.addEventListener("error", () => {
|
||
existing.remove();
|
||
reject(new Error(`安全验证脚本加载失败:${src}`));
|
||
}, { once: true });
|
||
});
|
||
}
|
||
if (existing) existing.remove();
|
||
return new Promise((resolve, reject) => {
|
||
const script = document.createElement("script");
|
||
script.src = src;
|
||
script.setAttribute("data-jiapu-tac", src);
|
||
script.dataset.jiapuState = "loading";
|
||
script.onload = () => {
|
||
if (!ready()) {
|
||
script.remove();
|
||
reject(new Error(`安全验证脚本未提供预期能力:${src}`));
|
||
return;
|
||
}
|
||
script.dataset.jiapuState = "loaded";
|
||
resolve();
|
||
};
|
||
script.onerror = () => {
|
||
script.remove();
|
||
reject(new Error(`安全验证脚本加载失败:${src}`));
|
||
};
|
||
document.head.appendChild(script);
|
||
});
|
||
};
|
||
|
||
export default {
|
||
data() {
|
||
return {
|
||
generation: 0,
|
||
tac: null,
|
||
context: null,
|
||
challenge: null,
|
||
activeXhr: null,
|
||
fatalSent: false,
|
||
completionSent: false,
|
||
previousFocus: null,
|
||
focusTrapTarget: null,
|
||
focusTrapHandler: null,
|
||
};
|
||
},
|
||
methods: {
|
||
async onContextChange(nextContext) {
|
||
const wasVisible = Boolean(this.context && this.context.visible === true);
|
||
const nextVisible = Boolean(nextContext && nextContext.visible === true);
|
||
this.generation += 1;
|
||
const generation = this.generation;
|
||
if (nextVisible && !wasVisible) this.capturePreviousFocus();
|
||
this.destroyTac(!nextVisible);
|
||
this.context = nextContext || null;
|
||
this.challenge = null;
|
||
this.fatalSent = false;
|
||
this.completionSent = false;
|
||
if (!nextContext || nextContext.visible !== true) return;
|
||
|
||
try {
|
||
await Promise.all([
|
||
loadStyle(TAC_STYLE_URL),
|
||
loadScript(TAC_SCRIPT_URL, () => Boolean(window.TAC && window.CaptchaConfig)),
|
||
]);
|
||
await loadScript(TAC_ADAPTER_URL, () => Boolean(window.JiapuTacAdapter));
|
||
if (generation !== this.generation || !this.context || this.context.visible !== true) return;
|
||
this.createTac();
|
||
} catch (error) {
|
||
if (generation !== this.generation || !this.context || this.context.visible !== true) return;
|
||
this.notifyFatal(error);
|
||
}
|
||
},
|
||
createTac() {
|
||
if (!window.TAC || !window.CaptchaConfig || !window.JiapuTacAdapter) {
|
||
throw new Error("安全验证组件未完整加载");
|
||
}
|
||
this.completionSent = false;
|
||
const generation = this.generation;
|
||
const requestContext = this.context;
|
||
const isCurrent = () =>
|
||
generation === this.generation &&
|
||
this.context === requestContext &&
|
||
requestContext.visible === true;
|
||
const config = new window.CaptchaConfig({
|
||
bindEl: "#jiapu-tac-host",
|
||
requestCaptchaDataUrl: this.context.challengeUrl,
|
||
validCaptchaUrl: this.context.verifyUrl,
|
||
requestHeaders: {
|
||
clientid: this.context.clientId,
|
||
},
|
||
validSuccess: (response, captcha, tac) => {
|
||
if (!isCurrent() || this.completionSent) return;
|
||
// 供应商脚本在动画结束、窗口销毁等边界下可能重复触发回调。
|
||
// 先封闭本轮并使所有旧闭包失效,再通知逻辑层,避免重复发短信或重复提交认证。
|
||
this.completionSent = true;
|
||
this.generation += 1;
|
||
this.abortActiveRequest();
|
||
const data = response && response.data;
|
||
tac.destroyWindow();
|
||
this.tac = null;
|
||
this.$ownerInstance.callMethod("handleTacSuccess", {
|
||
requestId: requestContext.requestId,
|
||
validToken: data && data.validToken,
|
||
expireSeconds: data && data.expireSeconds,
|
||
});
|
||
},
|
||
validFail: (response, captcha, tac) => {
|
||
if (!isCurrent()) return;
|
||
this.$ownerInstance.callMethod("handleTacFailure", {
|
||
requestId: requestContext.requestId,
|
||
message: (response && response.msg) || "行为验证未通过,请重试",
|
||
});
|
||
tac.reloadCaptcha();
|
||
},
|
||
btnCloseFun: (event, tac) => {
|
||
if (!isCurrent() || this.completionSent) return;
|
||
this.completionSent = true;
|
||
this.generation += 1;
|
||
this.abortActiveRequest();
|
||
tac.destroyWindow();
|
||
this.tac = null;
|
||
this.$ownerInstance.callMethod("handleTacCancel", {
|
||
requestId: requestContext.requestId,
|
||
});
|
||
},
|
||
btnRefreshFun: (event, tac) => {
|
||
if (isCurrent()) tac.reloadCaptcha();
|
||
},
|
||
});
|
||
|
||
// SDK 原生传输会把空体 HTTP 500 当成功,并且 verify 只看外层 code。
|
||
// 此处统一替换为严格 2xx JSON 传输,再由唯一适配器验证 passed 与 validToken。
|
||
config.doSendRequest = (options) => this.sendStrictRequest(options);
|
||
config.addRequestChain({
|
||
preRequest: (type, request) => this.beforeRequest(type, request),
|
||
postRequest: (type, request, response) => this.afterRequest(type, response),
|
||
});
|
||
this.tac = new window.TAC(config, {
|
||
logoUrl: null,
|
||
i18n: {
|
||
tips_success: "验证成功",
|
||
tips_error: "验证失败,请重新尝试",
|
||
slider_title: "拖动滑块完成安全验证",
|
||
rotate_title: "拖动滑块完成安全验证",
|
||
concat_title: "拖动滑块完成拼图",
|
||
image_click_title: "请依次点击",
|
||
},
|
||
});
|
||
this.tac.init();
|
||
this.activateFocusTrap();
|
||
},
|
||
beforeRequest(type, request) {
|
||
if (type === "requestCaptchaData") {
|
||
request.data = {
|
||
tenantId: this.context.tenantId,
|
||
clientId: this.context.clientId,
|
||
sceneCode: this.context.sceneCode,
|
||
subject: this.context.subject,
|
||
};
|
||
return true;
|
||
}
|
||
if (type === "validCaptcha") {
|
||
request.data = window.JiapuTacAdapter.buildVerifyBody(
|
||
request.data,
|
||
this.context,
|
||
this.challenge,
|
||
);
|
||
}
|
||
return true;
|
||
},
|
||
afterRequest(type, response) {
|
||
try {
|
||
if (type === "requestCaptchaData") {
|
||
const mapped = window.JiapuTacAdapter.normalizeChallengeResponse(response, this.context);
|
||
this.challenge = mapped.challenge;
|
||
this.replaceResponse(response, mapped.sdkResponse);
|
||
} else if (type === "validCaptcha") {
|
||
const mapped = window.JiapuTacAdapter.normalizeVerifyResponse(response);
|
||
this.replaceResponse(response, mapped);
|
||
}
|
||
} catch (error) {
|
||
const failure = window.JiapuTacAdapter.toSdkFailure(error);
|
||
this.replaceResponse(response, failure);
|
||
if (type === "requestCaptchaData") this.notifyFatal(error);
|
||
}
|
||
return true;
|
||
},
|
||
replaceResponse(target, source) {
|
||
Object.keys(target).forEach((key) => delete target[key]);
|
||
Object.assign(target, source);
|
||
},
|
||
sendStrictRequest(options) {
|
||
return new Promise((resolve) => {
|
||
this.abortActiveRequest();
|
||
const generation = this.generation;
|
||
const xhr = new XMLHttpRequest();
|
||
this.activeXhr = xhr;
|
||
let settled = false;
|
||
const settle = (response) => {
|
||
if (settled || generation !== this.generation || this.activeXhr !== xhr) return;
|
||
settled = true;
|
||
this.activeXhr = null;
|
||
resolve(response);
|
||
};
|
||
xhr.open(options.method || "POST", options.url);
|
||
xhr.timeout = 15000;
|
||
Object.keys(options.headers || {}).forEach((name) => {
|
||
xhr.setRequestHeader(name, String(options.headers[name]));
|
||
});
|
||
xhr.onreadystatechange = () => {
|
||
if (xhr.readyState !== XMLHttpRequest.DONE) return;
|
||
if (!(xhr.status >= 200 && xhr.status < 300)) {
|
||
settle({ code: xhr.status || 503, msg: `安全验证服务请求失败(${xhr.status || "网络异常"})` });
|
||
return;
|
||
}
|
||
try {
|
||
const data = JSON.parse(xhr.responseText);
|
||
settle(data && typeof data === "object" ? data : { code: 502, msg: "安全验证服务返回无效数据" });
|
||
} catch (error) {
|
||
settle({ code: 502, msg: "安全验证服务返回了非 JSON 数据" });
|
||
}
|
||
};
|
||
xhr.onerror = () => settle({ code: 503, msg: "安全验证服务网络连接失败" });
|
||
xhr.ontimeout = () => settle({ code: 504, msg: "安全验证服务请求超时" });
|
||
const payload = typeof options.data === "string" ? options.data : JSON.stringify(options.data || {});
|
||
xhr.send(payload);
|
||
});
|
||
},
|
||
abortActiveRequest() {
|
||
const xhr = this.activeXhr;
|
||
this.activeXhr = null;
|
||
if (!xhr || xhr.readyState === XMLHttpRequest.DONE) return;
|
||
xhr.onreadystatechange = null;
|
||
xhr.onerror = null;
|
||
xhr.ontimeout = null;
|
||
xhr.onabort = null;
|
||
xhr.abort();
|
||
},
|
||
notifyFatal(error) {
|
||
if (this.fatalSent) return;
|
||
this.fatalSent = true;
|
||
this.$ownerInstance.callMethod("handleTacError", {
|
||
requestId: this.context && this.context.requestId,
|
||
message: (error && error.message) || "安全验证暂不可用",
|
||
});
|
||
},
|
||
capturePreviousFocus() {
|
||
const previousFocus = document.activeElement;
|
||
this.previousFocus = previousFocus && typeof previousFocus.focus === "function"
|
||
? previousFocus
|
||
: null;
|
||
},
|
||
activateFocusTrap() {
|
||
this.deactivateFocusTrap(false);
|
||
const panel = document.querySelector("#jiapu-tac-dialog");
|
||
if (!panel) return;
|
||
const getFocusable = () => Array.from(panel.querySelectorAll(
|
||
'button:not([disabled]), [href], input:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||
)).filter((element) => element.offsetParent !== null);
|
||
this.focusTrapHandler = (event) => {
|
||
if (event.key !== "Tab") return;
|
||
const focusable = getFocusable();
|
||
if (focusable.length === 0) {
|
||
event.preventDefault();
|
||
panel.focus();
|
||
return;
|
||
}
|
||
const first = focusable[0];
|
||
const last = focusable[focusable.length - 1];
|
||
if (event.shiftKey && (document.activeElement === first || document.activeElement === panel)) {
|
||
event.preventDefault();
|
||
last.focus();
|
||
} else if (!event.shiftKey && document.activeElement === last) {
|
||
event.preventDefault();
|
||
first.focus();
|
||
}
|
||
};
|
||
this.focusTrapTarget = panel;
|
||
panel.addEventListener("keydown", this.focusTrapHandler);
|
||
const initialFocus = panel.querySelector(".tac-tool--refresh") || panel;
|
||
setTimeout(() => initialFocus.focus?.(), 0);
|
||
},
|
||
deactivateFocusTrap(restoreFocus = false) {
|
||
if (this.focusTrapTarget && this.focusTrapHandler) {
|
||
this.focusTrapTarget.removeEventListener("keydown", this.focusTrapHandler);
|
||
}
|
||
this.focusTrapTarget = null;
|
||
this.focusTrapHandler = null;
|
||
if (!restoreFocus) return;
|
||
const previousFocus = this.previousFocus;
|
||
this.previousFocus = null;
|
||
setTimeout(() => previousFocus?.focus?.(), 0);
|
||
},
|
||
destroyTac(restoreFocus = true) {
|
||
this.abortActiveRequest();
|
||
if (this.tac) this.tac.destroyWindow();
|
||
this.tac = null;
|
||
const host = document.querySelector("#jiapu-tac-host");
|
||
if (host) host.innerHTML = "";
|
||
this.deactivateFocusTrap(restoreFocus);
|
||
},
|
||
},
|
||
};
|
||
</script>
|
||
|
||
<style scoped>
|
||
.tac-layer {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 1200;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-sizing: border-box;
|
||
padding: 32rpx;
|
||
background: rgba(34, 19, 12, 0.62);
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
transition: opacity 160ms ease;
|
||
}
|
||
|
||
.tac-layer--visible {
|
||
opacity: 1;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.tac-panel {
|
||
box-sizing: border-box;
|
||
width: 318px;
|
||
max-width: 100%;
|
||
max-height: calc(var(--app-viewport-height) - 2px);
|
||
min-height: 318px;
|
||
border: 2rpx solid rgba(117, 25, 19, 0.48);
|
||
border-radius: 12rpx;
|
||
background: #f7f0e5;
|
||
box-shadow: 0 16rpx 52rpx rgba(39, 18, 10, 0.28);
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.tac-heading {
|
||
display: flex;
|
||
min-height: 58px;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
box-sizing: border-box;
|
||
padding: 6px 4px 6px 12px;
|
||
border-bottom: 1px solid rgba(117, 25, 19, 0.18);
|
||
}
|
||
|
||
.tac-heading__copy {
|
||
display: flex;
|
||
min-width: 0;
|
||
flex: 1;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.tac-title {
|
||
color: #8f160f;
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.tac-description {
|
||
margin-top: 2px;
|
||
color: #5c4330;
|
||
font-size: 12px;
|
||
line-height: 1.35;
|
||
}
|
||
|
||
.tac-tools {
|
||
display: flex;
|
||
flex: 0 0 auto;
|
||
gap: 2px;
|
||
}
|
||
|
||
.tac-tool {
|
||
display: inline-flex;
|
||
min-width: 48px;
|
||
min-height: 48px;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-sizing: border-box;
|
||
margin: 0;
|
||
padding: 0;
|
||
border: 0;
|
||
background: transparent;
|
||
color: #8f160f;
|
||
font-size: 25px;
|
||
line-height: 1;
|
||
}
|
||
|
||
.tac-tool::after {
|
||
border: 0;
|
||
}
|
||
|
||
:deep(.slider-bottom .close-btn),
|
||
:deep(.slider-bottom .refresh-btn) {
|
||
display: none !important;
|
||
}
|
||
|
||
.tac-host {
|
||
width: 318px;
|
||
max-width: 100%;
|
||
min-height: 318px;
|
||
}
|
||
|
||
@media (max-width: 340px) {
|
||
.tac-layer {
|
||
padding: 1px;
|
||
}
|
||
|
||
.tac-panel {
|
||
border: 0;
|
||
}
|
||
}
|
||
</style>
|