Files
console-dev/app/lib/auth.js
T

934 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 控制台统一请求与登录会话入口。
* 页面通过 useApi 访问 /console/v1,通过 useAuth 共享登录状态;本模块只在客户端使用。
* 默认 JSON 请求须同时满足 HTTP 成功和信封 status === 200;显式兼容项可接受无 status 的 data 数组。
* 文件下载使用独立响应规则,不能把默认 JSON 信封要求直接套用于文件内容。
*/
import CryptoJS from "crypto-js";
import { defineStore } from "pinia";
import { navigateTo, useRuntimeConfig } from "#app";
import { computed, ref } from "vue";
// 将供应商/模块内部错误转成面向会员的提示,避免在 UI 中暴露上游实现细节。
var ce = [
[/产品类型错误.{0,30}安全组[^\n]*/g, `该商品未接入安全组`],
[/商品未关联.{0,20}模块接口/g, `该商品暂不可用`],
[/未关联.{0,12}模块接口/g, `该商品暂不可用`],
[/尚未接入.{0,12}模块/g, `该商品暂不可用`],
];
var le = [
[/快快云-弹性云|弹性云/g, ``],
[/快快云|快快/g, ``],
[/魔方云|魔方财务|智简魔方|智简/g, ``],
[/美得云|美德云|meidecloud/gi, ``],
[/dwvps[a-z0-9_]*/gi, ``],
[/miloce[a-z0-9_]*/gi, ``],
[/jinghanyun[a-z0-9_]*/gi, ``],
[/kkidc[a-z0-9_]*/gi, ``],
[/kky[a-z0-9_]*/gi, ``],
[/idcsmart[a-z0-9_]*/gi, ``],
[/mf[_-]?cloud[a-z0-9_]*/gi, ``],
[/mf[_-]?dcim[a-z0-9_]*/gi, ``],
[/common[_-]?cloud[a-z0-9_]*/gi, ``],
[/pve[_-]?cloud[a-z0-9_]*/gi, ``],
];
var ue =
/模块接口|自定义配置|模块未关联|模块标识|上游|第三方云|[a-z]{2,}[_-][a-z0-9_]+|\bDCIM\b|whmcs|reserver|https?:\/\//i;
/** 非字符串输入视为空;替换后仍包含内部标识的消息统一降级,不将原始异常直接展示。 */
function W(e) {
let t = typeof e == `string` ? e : ``;
if (!t) return ``;
let n = t;
for (let [e, t] of ce) n = n.replace(e, t);
for (let [e, t] of le) n = n.replace(e, t);
return (
(n = n
.replace(/\s{2,}/g, ` `)
.replace(/[,,、]{2,}/g, `,`)
.replace(/^[\s,,、::;;-]+|[\s,,、::;;-]+$/g, ``)
.trim()),
n.length < 2 || ue.test(n) ? `操作失败,请稍后重试或联系客服` : n
);
}
// 操作日志中的熔断事件单独翻译;其余日志保留原文,由调用方决定展示方式。
function de(e) {
let t = typeof e == `string` ? e : ``;
return /^\s*上游熔断解除[::]/u.test(t)
? `服务连接已恢复正常`
: /^\s*上游熔断[::]/u.test(t)
? `服务连接暂时异常,请稍后重试`
: t;
}
// 列表缓存最多 32 项,每项不超过 200 条、存活 60 秒;身份切换或写操作会使其失效。
var fe = 32;
var pe = 200;
var me = 6e4;
var G = new Map();
var he = Symbol(`uninitialized`);
var ge = 0;
// 递增代次,阻止清空之前发出的旧请求在完成后重新写回缓存。
function _e() {
(ge++, G.clear());
}
function ve() {
return ge;
}
/** 按接口、命名空间与参数生成稳定键;对象键排序,身份隔离由缓存读写单独处理。 */
function ye(e, t, n = ``) {
return JSON.stringify([e, n, t], (e, t) =>
t && typeof t == `object` && !Array.isArray(t)
? Object.fromEntries(
Object.keys(t)
.sort()
.map((e) => [e, t[e]]),
)
: t,
);
}
// 缓存只保存可 JSON 序列化的数据;读写均复制,避免页面修改共享对象。
function be(e) {
return JSON.parse(JSON.stringify(e));
}
/** e 为当前身份,t 为缓存键;切换身份清空缓存,命中时移动到 Map 尾部维护 LRU。 */
function xe(e, t, n = Date.now()) {
he !== e && (_e(), (he = e));
for (let [e, t] of G) t.expires <= n && G.delete(e);
let r = G.get(t);
if (r) return (G.delete(t), G.set(t, r), be(r.data));
}
/** 仅接受身份和请求开始时代次均未变化的列表结果;过大的结果或序列化失败不缓存。 */
function Se(e, t, n, r, i = Date.now()) {
if (e === he && r === ge) {
if (!Array.isArray(n.list) || n.list.length > pe) {
G.delete(t);
return;
}
try {
let e = be(n);
(G.delete(t),
G.size >= fe && G.delete(G.keys().next().value),
G.set(t, {
data: e,
expires: i + me,
}));
} catch {
G.delete(t);
}
}
}
var Ce = 2147483647;
// 购买协议的整数范围与后端有符号 32 位整数一致;位置允许 0,商品和数量必须大于 0。
function K(e, t) {
let n =
typeof e == `number`
? e
: typeof e == `string` && /^\d+$/.test(e)
? Number(e)
: NaN;
if (!Number.isSafeInteger(n) || n < t || n > Ce)
throw Error(`购买信息无效,请重新确认后重试`);
return n;
}
function we(e) {
if (!e || typeof e != `object` || Array.isArray(e))
throw Error(`购买信息无效,请重新确认后重试`);
return e;
}
function Te(e) {
let t = we(e);
return {
product_id: K(t.product_id, 1),
qty: K(t.qty, 1),
};
}
/**
* 从实际写请求提取限购检查所需字段;未匹配的接口返回 null,不进入购买预检。
* 购物车位置去重,批量添加最多 1000 项;此处校验不替代后端的最终授权。
*/
function Ee(e, t, n) {
if (e === `POST` && t === `/product/settle`)
return {
mode: `single`,
...Te(n),
};
if (e === `POST` && t === `/cart`) {
let e = we(n);
if (e.products !== void 0) {
if (
!Array.isArray(e.products) ||
!e.products.length ||
e.products.length > 1e3
)
throw Error(`购买信息无效,请重新确认后重试`);
return {
mode: `cart_add`,
products: e.products.map(Te),
};
}
return {
mode: `cart_add`,
...Te(e),
};
}
if (e === `POST` && t === `/cart/settle`) {
let e = we(n);
if (!Array.isArray(e.positions) || !e.positions.length)
throw Error(`请选择要结算的商品`);
return {
mode: `cart_settle`,
positions: [...new Set(e.positions.map((e) => K(e, 0)))],
};
}
let r = e === `PUT` ? /^\/cart\/(\d+)(\/qty)?$/.exec(t) : null;
if (r) {
let e = we(n);
return {
mode: `cart_update`,
position: K(r[1], 0),
qty: K(e.qty, 1),
...(r[2]
? {}
: {
product_id: K(e.product_id, 1),
}),
};
}
return null;
}
// 预检必须明确允许/拒绝,拒绝时必须给出非空原因;缺失字段不能被当成放行。
function De(e) {
if (!e || typeof e != `object` || Array.isArray(e)) return !1;
let t = e;
return (
typeof t.allowed == `boolean` &&
typeof t.message == `string` &&
(t.allowed || !!t.message.trim())
);
}
/** 统一传递 HTTP/业务状态和可供安全验证等流程读取的数据,不丢失错误分类。 */
class ApiError extends Error {
status;
data;
constructor(status, message, data) {
super(message);
this.status = status;
this.data = data;
this.name = "ApiError";
}
}
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
const FILE_TRANSFER_TIMEOUT_MS = 120_000;
function requestTimeoutError() {
return new ApiError(408, "请求超时,请稍后重试");
}
/**
* 截止时间覆盖整个异步操作(含预检和 JSON/Blob 解码),而非只覆盖收到响应头之前。
* 超时会中止 fetch;finally 清理计时器,调用方仍须在 await 后检查取消状态。
*/
async function withRequestDeadline(
operation,
timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
controller = new AbortController(),
) {
const duration =
Number.isFinite(timeoutMs) && timeoutMs > 0 && timeoutMs <= 2_147_483_647
? timeoutMs
: DEFAULT_REQUEST_TIMEOUT_MS;
let timer;
const deadline = new Promise((_, reject) => {
timer = setTimeout(() => {
controller.abort();
reject(requestTimeoutError());
}, duration);
});
try {
// The deadline also covers response-body decoding and any purchase preflight.
return await Promise.race([
Promise.resolve().then(() => operation(controller.signal)),
deadline,
]);
} finally {
clearTimeout(timer);
}
}
function assertRequestActive(signal) {
if (signal.aborted) throw requestTimeoutError();
}
// 已分类的业务错误原样传播;浏览器底层网络异常统一为状态 0,避免泄露内部信息。
function normalizeRequestError(error) {
return error instanceof ApiError
? error
: new ApiError(0, "网络异常,请检查网络后重试");
}
/** 在 Nuxt 上下文中创建客户端;apiBase 只配置后端 origin,本模块统一追加接口前缀。 */
function useApi() {
const config = useRuntimeConfig();
const auth = useAuth();
// 过期请求的 401 只能清理它所属的会话,不能把随后重新登录的新账号退出。
function clearUnauthorizedSession(requestToken) {
if (
!requestToken ||
localStorage.getItem("jwt") !== requestToken ||
auth.token !== requestToken
)
return;
auth.clear();
if (window.location.pathname === "/login") return;
const redirect = window.location.pathname + window.location.search;
try {
void Promise.resolve(
navigateTo({
path: "/login",
query: redirect === "/" ? undefined : { redirect },
}),
).catch(() => {});
} catch {}
}
function responseObject(value) {
return value && typeof value === "object" && !Array.isArray(value)
? value
: {};
}
// HTTP 401 优先于业务信封;其他错误优先使用非 200 的业务状态,并脱敏错误消息。
function responseError(response, payload, requestToken, fallbackMessage) {
const envelope = responseObject(payload);
const status =
response.status === 401
? 401
: typeof envelope.status === "number" && envelope.status !== 200
? envelope.status
: response.status;
if (status === 401) clearUnauthorizedSession(requestToken);
return new ApiError(
status,
W(envelope.msg || envelope.message) || fallbackMessage,
envelope.data,
);
}
/** 无 status 的 data 数组仅在调用方明确开启兼容项时接受,其他格式异常均拒绝。 */
async function decodeResponse(
response,
requestToken,
fallbackMessage,
signal,
allowStatuslessDataArray = false,
) {
const payload = await response.json().catch(() => null);
assertRequestActive(signal);
const envelope = responseObject(payload);
if (response.ok && envelope.status === 200) return payload;
if (
response.ok &&
allowStatuslessDataArray &&
!("status" in envelope) &&
Array.isArray(envelope.data)
) {
return { status: 200, msg: "", data: envelope.data };
}
throw responseError(response, payload, requestToken, fallbackMessage);
}
// 数组参数编码为 key[];默认省略 null/空串,少数筛选接口可显式保留空串。
function apiUrl(path, query, keepEmpty = false) {
let url = `${String(config.public.apiBase ?? "")}/console/v1${path}`;
const search = new URLSearchParams();
for (const [key, value] of Object.entries(query ?? {})) {
if (value == null || (value === "" && !keepEmpty)) continue;
if (Array.isArray(value)) {
for (const item of value) {
if (item != null && item !== "")
search.append(`${key}[]`, String(item));
}
} else {
search.append(key, String(value));
}
}
if (search.size) url += `?${search}`;
return url;
}
/** JSON 请求主路径:固定本次身份和请求体 → 必要时限购预检 → 请求 → 验证信封。 */
async function request(method, path, options = {}) {
// Capture identity before the first await, including when logout clears it immediately.
const requestToken = auth.token;
const headers = { Accept: "application/json" };
if (requestToken) headers.Authorization = `Bearer ${requestToken}`;
if (options.body !== undefined)
headers["Content-Type"] = "application/json";
try {
const serializedBody =
options.body === undefined ? undefined : JSON.stringify(options.body);
return await withRequestDeadline(async (signal) => {
if (requestToken) {
let purchase;
try {
purchase = Ee(method, path, options.body);
} catch (error) {
throw new ApiError(
422,
error instanceof Error
? error.message
: "购买信息无效,请重新确认后重试",
);
}
if (purchase) {
// 等待预检期间身份或表单可能变化;二次比较确保真正提交的是已核验的内容。
const authToken = auth.token;
const permission = await request("POST", "/purchase_limit/check", {
body: purchase,
timeoutMs: options.timeoutMs ?? 15_000,
});
assertRequestActive(signal);
if (
localStorage.getItem("jwt") !== requestToken ||
auth.token !== authToken
) {
throw new ApiError(409, "登录状态已变化,请重新确认后重试");
}
if (JSON.stringify(options.body) !== serializedBody) {
throw new ApiError(409, "购买信息已变化,请重新确认后重试");
}
if (!De(permission))
throw new ApiError(502, "限购验证暂不可用,请稍后重试");
if (!permission.allowed)
throw new ApiError(422, permission.message);
}
}
assertRequestActive(signal);
const response = await fetch(
apiUrl(path, options.query, options.keepEmpty),
{
method,
headers,
body: serializedBody,
signal,
},
);
assertRequestActive(signal);
const envelope = await decodeResponse(
response,
requestToken,
response.ok
? "响应格式异常,请稍后重试"
: `请求失败(HTTP ${response.status})`,
signal,
options.allowStatuslessDataArray,
);
assertRequestActive(signal);
// 普通写操作清空列表缓存;只读报价和限购预检不应让正在展示的列表无故失效。
if (
method !== "GET" &&
path !== "/purchase_limit/check" &&
!/^\/product\/\d+\/config_option$/.test(path)
) {
_e();
}
return options.raw ? envelope : envelope.data;
}, options.timeoutMs);
} catch (error) {
throw normalizeRequestError(error);
}
}
/** 文件使用 multipart/form-data,由浏览器生成 boundary;不要手动设置 Content-Type。 */
async function upload(file) {
const body = new FormData();
body.append("file", file);
const headers = { Accept: "application/json" };
const requestToken = auth.token;
if (requestToken) headers.Authorization = `Bearer ${requestToken}`;
try {
return await withRequestDeadline(async (signal) => {
const response = await fetch(apiUrl("/upload"), {
method: "POST",
headers,
body,
signal,
});
assertRequestActive(signal);
const envelope = await decodeResponse(
response,
requestToken,
"上传失败",
signal,
);
assertRequestActive(signal);
return envelope.data;
}, FILE_TRANSFER_TIMEOUT_MS);
} catch (error) {
throw normalizeRequestError(error);
}
}
/** 下载仍校验 JSON 错误信封;成功后创建临时下载链接,并在点击后释放对象 URL。 */
async function download(path, data, filename = "export.xlsx", options = {}) {
const method = options.method ?? "GET";
const headers = { Accept: "*/*" };
const requestToken = auth.token;
if (requestToken) headers.Authorization = `Bearer ${requestToken}`;
if (method === "POST") headers["Content-Type"] = "application/json";
try {
await withRequestDeadline(async (signal) => {
const response = await fetch(
apiUrl(path, method === "GET" ? data : undefined),
{
method,
headers,
...(method === "POST" ? { body: JSON.stringify(data ?? {}) } : {}),
signal,
},
);
assertRequestActive(signal);
const isJson = /\bapplication\/(?:[\w.-]+\+)?json\b/i.test(
response.headers.get("content-type") || "",
);
if (!response.ok || isJson) {
const payload = await response
.clone()
.json()
.catch(() => null);
assertRequestActive(signal);
const envelope = responseObject(payload);
if (
!response.ok ||
(typeof envelope.status === "number" && envelope.status !== 200)
) {
throw responseError(
response,
payload,
requestToken,
`下载失败(HTTP ${response.status})`,
);
}
}
const disposition = response.headers.get("content-disposition") || "";
const match = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(disposition);
if (match?.[1]) {
try {
filename = decodeURIComponent(match[1]);
} catch {
filename = match[1];
}
}
const blob = await response.blob();
assertRequestActive(signal);
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
try {
link.href = objectUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
} finally {
link.remove();
URL.revokeObjectURL(objectUrl);
}
}, options.timeoutMs ?? FILE_TRANSFER_TIMEOUT_MS);
} catch (error) {
throw normalizeRequestError(error);
}
}
return {
identity: () => auth.token,
request,
upload,
download,
get: (path, query, keepEmpty = false, options = {}) =>
request("GET", path, {
...options,
query,
keepEmpty,
}),
getRaw: (path, query, options = {}) =>
request("GET", path, {
...options,
query,
raw: true,
}),
post: (path, body, raw = false, options = {}) =>
request("POST", path, {
...options,
body,
raw,
}),
put: (path, body, query) => request("PUT", path, { body, query }),
del: (path, query, body) => request("DELETE", path, { query, body }),
};
}
// 既有后端登录协议要求固定 AES-CBC 参数;这是传输字段兼容,不是安全密码存储。
// 修改必须与后端协商,HTTPS 仍不可省略,前端公开的固定参数不能视为秘密。
var ot = CryptoJS.enc.Utf8.parse(`idcsmart.finance`);
var st = CryptoJS.enc.Utf8.parse(`9311019310287172`);
function ct(e) {
return CryptoJS.AES.encrypt(e, ot, {
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
iv: st,
}).toString();
}
/** 按安全验证、限流、超时和服务异常分类登录错误,屏蔽 HTML/SQL/堆栈等内部内容。 */
function lt(e, t = `登录失败,请稍后重试`) {
let n = e && typeof e == `object` ? e : {},
r = typeof n.message == `string` ? n.message.trim() : ``,
i = n.data && typeof n.data == `object` ? n.data : {};
return i.need_security_verify || i.ip_exception_verify
? `本次登录需要额外的安全验证,请联系客服协助完成验证。`
: n.status === 429
? `登录尝试过于频繁,请稍后再试`
: n.status === 408 || /超时|timeout/i.test(r)
? `登录请求超时,请检查网络后重试`
: n.status === 0 ||
/网络异常|Failed to fetch|NetworkError|Load failed/i.test(r)
? `网络连接失败,请检查网络后重试`
: (n.status ?? 0) >= 500 ||
/页面错误|响应格式异常|Internal Server Error|Bad Gateway|Service Unavailable/i.test(
r,
)
? `登录服务暂时异常,请稍后重试;若持续失败,请联系客服。`
: !r ||
/<[^>]+>|SQLSTATE|\b(?:TypeError|ReferenceError|Exception)\b/.test(
r,
)
? t
: r;
}
// 这里只检查凭证形状,不验证签名和权限;真实性须由携带凭证的后端请求确认。
function ut(e) {
return (
typeof e == `string` && e.length > 0 && e.length <= 8192 && !/\s/.test(e)
);
}
/** 从接口成功/失败的常见包装中识别额外安全验证挑战,未找到时返回 null。 */
function dt(e) {
if (!e || typeof e != `object`) return null;
let t = e,
n = [t, t.data, t.error, t.response];
for (let e of n) {
if (!e || typeof e != `object`) continue;
let t = e,
n = t.data && typeof t.data == `object` ? t.data : t;
if (
n.need_security_verify === !0 ||
n.need_security_verify === 1 ||
n.need_security_verify === `1` ||
(Array.isArray(n.available_methods) && n.available_methods.length > 0)
)
return n;
}
return null;
}
// 收到挑战不能被当成业务已完成;将挑战附在异常 data 上交给安全验证对话框。
function ft(e) {
let t = dt(e);
if (t)
throw Object.assign(Error(`安全验证尚未完成,请重试`), {
data: t,
});
}
var pt = {
operate_password: `操作密码`,
email_code: `邮箱验证码`,
phone_code: `手机验证码`,
certification: `实名认证`,
};
/** 将后端允许的验证方式转成选择项;未知方式不展示,逐项 token 优先于公共 token。 */
function mt(e) {
return (
Array.isArray(e.available_methods) ? e.available_methods : []
).flatMap((t) => {
let n =
typeof t == `string`
? {
value: t,
}
: t && typeof t == `object`
? t
: {},
r = String(n.value ?? ``);
return pt[r]
? [
{
value: r,
label: pt[r],
tip: String(n.tip ?? ``),
account: String(n.account ?? ``),
phone_code: String(n.phone_code ?? ``),
security_verify_token: String(
n.security_verify_token ?? e.security_verify_token ?? ``,
),
},
]
: [];
});
}
/**
* JWT 持久化到 localStorage 是现有会话约定,仍需防范 XSS;不在日志中输出凭证。
* sessionVersion 隔离跨登录的异步结果,overviewPromise 合并同一会话的并发首页请求。
*/
const useAuth = defineStore("auth", () => {
const token = ref(localStorage.getItem("jwt"));
const overview = ref(null);
const loading = ref(false);
const legacyLoginPending = ref(false);
const isLoggedIn = computed(() => !!token.value);
let overviewPromise = null;
let sessionVersion = 0;
let legacySession = null;
// 接收登录、旧入口核验或跨标签同步的凭证;先清理旧缓存和未完成的旧平台登录。
function loginWithJwt(jwt) {
if (!ut(jwt))
throw Error("登录响应无效,请重新登录;若持续失败,请联系客服。");
resetSessionState();
token.value = jwt;
localStorage.setItem("jwt", jwt);
}
// 身份结果只归属于请求开始时的会话;也检查持久凭证,覆盖 storage 事件尚未处理的窗口。
function prepareLogin() {
const version = sessionVersion;
const persistedToken = localStorage.getItem("jwt");
const isCurrent = () =>
version === sessionVersion &&
persistedToken === localStorage.getItem("jwt");
return {
isCurrent,
commit(jwt) {
if (!isCurrent()) throw Error("登录状态已变化,请重新发起登录。");
loginWithJwt(jwt);
},
};
}
// 旧平台带来的凭证先暂存待验证,不立即写成已登录状态,避免路由守卫误放行。
function prepareLegacyLogin(jwt = localStorage.getItem("jwt")) {
clear();
legacySession = {
jwt: ut(jwt) ? jwt : null,
version: sessionVersion,
controller: null,
promise: null,
};
legacyLoginPending.value = true;
}
// 退出/切换身份会中止旧验证,并丢弃其结果。
function cancelLegacyLogin() {
legacySession?.controller?.abort();
legacySession = null;
legacyLoginPending.value = false;
}
/**
* 用 /index 实际核验旧平台 JWT;并发点击共享 Promise,失败后允许同一入口重试。
* no-store/omit/no-referrer 避免缓存、Cookie 和 Referer 携带额外身份信息。
*/
function verifyLegacyLogin() {
const session = legacySession;
if (!session?.jwt) {
return Promise.reject(
Error("缺少有效登录凭证,请返回原平台重新进入,或使用账号登录。"),
);
}
if (session.promise) return session.promise;
const loginAttempt = prepareLogin();
const apiBase = String(useRuntimeConfig().public.apiBase ?? "");
const controller = new AbortController();
session.controller = controller;
const isCurrentSession = () =>
legacySession === session && sessionVersion === session.version;
const promise = withRequestDeadline(
async (signal) => {
let response;
try {
response = await fetch(`${apiBase}/console/v1/index`, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${session.jwt}`,
},
credentials: "omit",
cache: "no-store",
referrerPolicy: "no-referrer",
signal,
});
} catch {
throw Error("网络连接失败或验证超时,请检查网络后重试。");
}
const envelope = await response.json().catch(() => null);
assertRequestActive(signal);
if (!isCurrentSession()) throw Error("登录状态已变化,请重新进入。");
if (response.status === 401 || envelope?.status === 401) {
throw Error("登录凭证已失效,请返回原平台重新进入,或使用账号登录。");
}
const account = envelope?.data?.account;
if (
!response.ok ||
envelope?.status !== 200 ||
!account ||
typeof account !== "object" ||
!Number.isSafeInteger(account.id) ||
account.id <= 0
)
throw Error("暂时无法验证登录身份,请稍后重试。");
loginAttempt.commit(session.jwt);
overview.value = account;
},
15_000,
controller,
)
.catch((error) => {
if (error instanceof ApiError && error.status === 408) {
throw Error("网络连接失败或验证超时,请检查网络后重试。");
}
throw error;
})
.finally(() => {
if (isCurrentSession()) {
session.controller = null;
session.promise = null;
}
});
session.promise = promise;
return promise;
}
/** 密码/手机验证码登录共用入口;安全验证结果由调用方传入,成功后才保存 JWT。 */
async function login(
account,
password,
captcha = "",
captchaToken = "",
type = "password",
code = "",
phoneCode = "86",
verification,
) {
if (type === "code" && account.includes("@"))
throw Error("验证码登录仅支持手机号");
const api = useApi();
const loginAttempt = prepareLogin();
loading.value = true;
try {
const result = await api.post(
"/login",
{
type,
account,
...(account.includes("@") ? {} : { phone_code: phoneCode }),
code,
password: type === "password" ? ct(password) : "",
remember_password: "0",
captcha,
token: captchaToken,
...(verification || {}),
},
false,
{ timeoutMs: 15_000 },
);
if (!loginAttempt.isCurrent()) throw Error("登录状态已变化,请重试");
ft(result);
if (!ut(result?.jwt))
throw Error("登录响应无效,请重新登录;若持续失败,请联系客服。");
loginAttempt.commit(result.jwt);
} finally {
loading.value = false;
}
}
// 在请求完成前共享 Promise;会话版本不一致时拒绝回填,防止旧账号资料覆盖新账号。
function loadOverview() {
if (!overviewPromise) {
const version = sessionVersion;
const promise = useApi()
.get("/index", undefined, false, { timeoutMs: 10_000 })
.then((result) => {
if (version !== sessionVersion) throw Error("登录状态已变化,请重试");
overview.value = result.account;
return result.account;
})
.finally(() => {
if (overviewPromise === promise) overviewPromise = null;
});
overviewPromise = promise;
}
return overviewPromise;
}
// 页面首次读取允许使用本会话已有资料;需要强制刷新余额等信息时调用 refreshOverview。
async function fetchOverview() {
return overview.value ?? loadOverview();
}
async function refreshOverview() {
return loadOverview();
}
// 本地退出立即生效,不等待远端吊销成功;请求已同步捕获旧 Bearer,不会误用新身份。
function logout() {
// request() captures the original Bearer synchronously; remote revocation never owns local logout.
const revocation = useApi().post("/logout");
clear();
void revocation.catch(() => {});
}
// 所有会话切换走同一清理入口,使缓存、旧平台验证和账户资料同时失效。
function resetSessionState() {
_e();
cancelLegacyLogin();
sessionVersion += 1;
overview.value = null;
overviewPromise = null;
}
function clear() {
resetSessionState();
token.value = null;
localStorage.removeItem("jwt");
}
return {
token,
overview,
loading,
isLoggedIn,
login,
prepareLogin,
fetchOverview,
refreshOverview,
logout,
clear,
legacyLoginPending,
prepareLegacyLogin,
verifyLegacyLogin,
cancelLegacyLogin,
};
});
export {
lt as loginErrorMessage,
ApiError,
ye as apiCacheKey,
ve as apiCacheVersion,
W as sanitizeMessage,
de as operationMessage,
mt as securityMethods,
useApi,
Se as writeApiCache,
ft as assertSecurityComplete,
ut as isValidJwt,
xe as readApiCache,
dt as securityChallenge,
useAuth,
_e as clearApiCache,
};