feat: migrate app routes and business modules

This commit is contained in:
2026-08-12 18:22:59 +08:00
parent 555aa00043
commit cc706378c2
247 changed files with 28623 additions and 14988 deletions
+98
View File
@@ -0,0 +1,98 @@
<template>
<view class="auth-shell">
<view class="auth-shell__header">
<image
class="auth-shell__header-image"
src="/static/assets/modules/auth/opaque/auth-header.png"
mode="widthFix"
/>
<view class="auth-shell__brand-lockup">
<image
class="auth-shell__seal"
src="/static/assets/foundation/transparent/brand-seal.png"
mode="aspectFit"
/>
<text class="auth-shell__project-name">代代相传</text>
</view>
</view>
<view class="auth-shell__paper"><slot /></view>
<slot name="overlay" />
</view>
</template>
<style scoped lang="scss">
.auth-shell {
display: flex;
flex-direction: column;
box-sizing: border-box;
width: 100%;
min-height: var(--app-viewport-height);
overflow-x: hidden;
background: #940400;
color: #493323;
}
.auth-shell__header {
position: relative;
flex: 0 0 auto;
box-sizing: border-box;
width: 100%;
height: calc(var(--app-safe-top) + min(36.5vw, 175px));
overflow: hidden;
padding-top: var(--app-safe-top);
background: #940400;
}
.auth-shell__header-image {
display: block;
width: 100%;
height: auto;
pointer-events: none;
}
.auth-shell__brand-lockup {
position: absolute;
top: calc(var(--app-safe-top) + clamp(46px, 11.5vw, 58px));
left: 50%;
display: flex;
align-items: center;
gap: 12rpx;
transform: translateX(-50%);
white-space: nowrap;
}
.auth-shell__seal {
position: static;
width: clamp(56px, 17vw, 78px);
height: clamp(67px, 20.4vw, 94px);
pointer-events: none;
}
.auth-shell__paper {
display: flex;
flex: 1;
box-sizing: border-box;
width: 100%;
padding-bottom: var(--app-safe-bottom);
background-color: #f7f0e5;
background-image:
url("/static/assets/modules/auth/opaque/sign-in-backdrop.png"),
url("/static/assets/foundation/opaque/auth-page-paper.jpg");
background-position: center calc(-48.18vw), center top;
background-size: 100% auto, 100% auto;
background-repeat: no-repeat, repeat-y;
}
.auth-shell__project-name {
color: #f8dfa6;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(14px, 24rpx, 17px);
font-weight: 700;
letter-spacing: 4rpx;
line-height: 1;
white-space: nowrap;
}
</style>
+355
View File
@@ -0,0 +1,355 @@
<template>
<view
v-show="visible"
class="tac-layer"
:aria-hidden="visible ? 'false' : 'true'"
@click.stop
>
<view
id="jiapu-tac-host"
:prop="renderContext"
:change:prop="tacRenderer.onContextChange"
/>
</view>
</template>
<script>
export default {
name: "TacVerification",
props: {
visible: {
type: Boolean,
default: false,
},
context: {
type: Object,
default: null,
},
},
emits: ["success", "failure", "error", "cancel"],
computed: {
renderContext() {
return {
...(this.context || {}),
visible: this.visible,
};
},
},
methods: {
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,
requestContext: null,
challenge: null,
activeXhr: null,
fatalSent: false,
completionSent: false,
};
},
methods: {
async onContextChange(nextContext) {
const nextVisible = Boolean(nextContext && nextContext.visible === true);
this.generation += 1;
const generation = this.generation;
this.destroyTac();
this.requestContext = 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.requestContext || this.requestContext.visible !== true) return;
this.createTac();
} catch (error) {
if (generation !== this.generation || !this.requestContext || this.requestContext.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.requestContext;
const isCurrent = () =>
generation === this.generation &&
this.requestContext === requestContext &&
requestContext.visible === true;
const config = new window.CaptchaConfig({
bindEl: "#jiapu-tac-host",
requestCaptchaDataUrl: this.requestContext.challengeUrl,
validCaptchaUrl: this.requestContext.verifyUrl,
requestHeaders: {
clientid: this.requestContext.clientId,
},
validSuccess: (response, captcha, tac) => {
if (!isCurrent() || this.completionSent) return;
// 供应商脚本在动画结束、窗口销毁等边界下可能重复触发回调。
// 先封闭本轮并使所有旧闭包失效,再通知逻辑层,避免重复发短信或重复提交认证。
this.completionSent = true;
this.generation += 1;
this.abortActiveRequest();
const verificationData = response && response.data;
tac.destroyWindow();
this.tac = null;
this.$ownerInstance.callMethod("handleTacSuccess", {
requestId: requestContext.requestId,
validToken: verificationData && verificationData.validToken,
expireSeconds: verificationData && verificationData.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);
this.tac.init();
},
beforeRequest(type, request) {
if (type === "requestCaptchaData") {
request.data = {
tenantId: this.requestContext.tenantId,
subject: this.requestContext.subject,
};
return true;
}
if (type === "validCaptcha") {
request.data = window.JiapuTacAdapter.buildVerifyBody(
request.data,
this.requestContext,
this.challenge,
);
}
return true;
},
afterRequest(type, response) {
try {
if (type === "requestCaptchaData") {
const mapped = window.JiapuTacAdapter.normalizeChallengeResponse(response, this.requestContext);
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 parsedResponse = JSON.parse(xhr.responseText);
settle(
parsedResponse && typeof parsedResponse === "object"
? parsedResponse
: { 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.requestContext && this.requestContext.requestId,
message: (error && error.message) || "安全验证暂不可用",
});
},
destroyTac() {
this.abortActiveRequest();
if (this.tac) this.tac.destroyWindow();
this.tac = null;
const host = document.querySelector("#jiapu-tac-host");
if (host) host.innerHTML = "";
},
},
};
</script>
<style scoped>
.tac-layer {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
}
</style>