useAuthRedirect.js 8.17 KB
/*
 * @Date: 2026-05-29 00:00:00
 * @Description: 授权跳转辅助逻辑
 */
import { useRoute } from 'vue-router';

// 授权返回后的首轮路由检查只需要跳过一次,避免刚拿到 openid 又被周期选择打断
const SKIP_CYCLE_CHECK_FOR_AUTH_KEY = 'skip_cycle_check_for_auth';
// 只记录“这一轮刚发起过授权”的短期状态,供授权返回后修正浏览器后退行为
const AUTH_RETURN_GUARD_KEY = 'data-table-auth-return-guard';
// 只保留很短时间,避免授权链路中断后残留旧标记
const AUTH_RETURN_GUARD_TTL = 10 * 60 * 1000;

const toStringQueryValue = (value) => (typeof value === 'string' ? value : '');

const getQueryValueFromString = (search, key) => {
  if (!search) return '';
  const params = new URLSearchParams(search.startsWith('?') ? search : `?${search}`);
  return params.get(key) || '';
};

const safeDecodeURIComponent = (value) => {
  try {
    return decodeURIComponent(value);
  } catch (error) {
    return value;
  }
};

/**
 * 消耗一次“跳过首次周期检查”的标记。
 * 命中后会立即清理,确保它只影响授权返回的这一轮路由。
 * @returns {boolean} 是否需要跳过一次周期检查
 */
export const consumeSkipCycleCheckForAuth = () => {
  const shouldSkip = sessionStorage.getItem(SKIP_CYCLE_CHECK_FOR_AUTH_KEY) === 'true';
  if (shouldSkip) {
    sessionStorage.removeItem(SKIP_CYCLE_CHECK_FOR_AUTH_KEY);
  }
  return shouldSkip;
};

const clearAuthReturnGuard = () => {
  sessionStorage.removeItem(AUTH_RETURN_GUARD_KEY);
};

const readAuthReturnGuard = () => {
  try {
    const state = JSON.parse(sessionStorage.getItem(AUTH_RETURN_GUARD_KEY) || '{}');
    if (!state?.createdAt || Date.now() - state.createdAt > AUTH_RETURN_GUARD_TTL) {
      clearAuthReturnGuard();
      return null;
    }
    return state;
  } catch (error) {
    clearAuthReturnGuard();
    return null;
  }
};

/**
 * 统一管理授权页与业务页之间共用的跳转能力。
 * 这里不保存“授权成功”长期标记,避免后端授权失效后前端还拿旧状态误判。
 * @param {import('vue-router').RouteLocationNormalizedLoaded} route 当前路由实例
 * @returns {{
 *   getQueryValue: (key: string) => string,
 *   getCurrentTargetHash: () => string,
 *   getAuthTargetHash: () => string,
 *   buildOpenIdAuthUrl: (code: string, targetHash?: string) => string,
 *   redirectToOpenIdAuth: (code: string, targetHash?: string) => void,
 *   savePendingAuthReturnGuard: (code: string, targetHash?: string) => void,
 *   consumePendingAuthReturnGuard: (code: string, targetHash?: string) => { historyLength: number } | null,
 *   installAuthBackHistoryGuard: () => void,
 *   markSkipCycleCheckForAuth: () => void,
 *   consumeSkipCycleCheckForAuth: () => boolean
 * }}
 */
export function useAuthRedirect(route = useRoute()) {
  const getQueryValue = (key) => {
    const routeValue = toStringQueryValue(route.query[key]);
    if (routeValue) return routeValue;

    // 兼容两类入口:
    // 1. hash 路由标准参数:#/path?code=xxx
    // 2. 老入口或外部重定向参数:index.html?code=xxx#/path
    const searchValue = getQueryValueFromString(location.search, key);
    if (searchValue) return searchValue;

    const hash = location.hash || '';
    const hashQueryIndex = hash.indexOf('?');
    if (hashQueryIndex >= 0) {
      const hashSearch = hash.slice(hashQueryIndex + 1);
      return getQueryValueFromString(hashSearch, key);
    }

    return '';
  };

  // 当前表单页在 hash 模式下的完整路由,授权成功后要回到这里
  const getCurrentTargetHash = () => `${location.search}${location.hash || `#${route.fullPath}`}`;

  // 优先读 target,新链路;兼容 href,避免老链接直接失效
  const getAuthTargetHash = () => {
    const rawTarget = getQueryValue('target') || getQueryValue('href');
    return rawTarget ? safeDecodeURIComponent(rawTarget) : '';
  };

  /**
   * 统一拼接 openid 授权链接。
   * 新链路会在业务页里直接 replace 到这个地址,避免先进入站内 auth 页留下额外历史记录。
   * @param {string} code 表单 code
   * @param {string} targetHash 授权成功后需要回到的 hash 路由
   * @returns {string} 可直接跳转的授权地址
   */
  const buildOpenIdAuthUrl = (code, targetHash = getCurrentTargetHash()) => {
    const rawUrl = encodeURIComponent(`${location.origin}${location.pathname}${targetHash}`);
    const shortUrl = `/srv/?f=custom_form&a=openid&res=${rawUrl}&form_code=${code}`;

    // 开发环境继续复用旧的 openid 注入方式,避免影响本地调试体验
    if (import.meta.env.DEV) {
      return `${shortUrl}&openid=${import.meta.env.VITE_OPENID}`;
    }

    return shortUrl;
  };

  /**
   * 直接跳到后端 openid 授权地址。
   * 使用 location.replace 是为了把“当前未授权页”替换掉,避免浏览器后退时再落回授权中转页。
   * @param {string} code 表单 code
   * @param {string} targetHash 授权成功后需要回到的 hash 路由
   */
  const redirectToOpenIdAuth = (code, targetHash = getCurrentTargetHash()) => {
    window.location.replace(buildOpenIdAuthUrl(code, targetHash));
  };

  /**
   * 记录一次待返回的授权流程。
   * 这里只保存当前目标页和发起前的历史长度,返回后会立刻消费掉,不作为长期登录态判断。
   * @param {string} code 表单 code
   * @param {string} targetHash 授权成功后需要回到的 hash 路由
   */
  const savePendingAuthReturnGuard = (code, targetHash = getCurrentTargetHash()) => {
    sessionStorage.setItem(
      AUTH_RETURN_GUARD_KEY,
      JSON.stringify({
        code,
        targetHash,
        historyLength: window.history.length,
        createdAt: Date.now(),
      }),
    );
  };

  /**
   * 消费一次授权返回标记。
   * 只有当前页面和发起授权时记录的目标页一致,才认为这是同一轮授权返回。
   * @param {string} code 表单 code
   * @param {string} targetHash 当前页面 hash
   * @returns {{ historyLength: number } | null} 命中的返回信息
   */
  const consumePendingAuthReturnGuard = (code, targetHash = getCurrentTargetHash()) => {
    const state = readAuthReturnGuard();
    if (!state) return null;

    const matched = state.code === code && state.targetHash === targetHash;
    clearAuthReturnGuard();

    if (!matched) {
      return null;
    }

    return {
      historyLength: Number(state.historyLength) || 0,
    };
  };

  /**
   * 在授权成功回到表单后,临时插入一个“同地址跳板”。
   * 这样用户第一次点浏览器后退时,会先回到这个跳板,然后我们再一次性跳过授权地址回到真正的上一页。
   */
  const installAuthBackHistoryGuard = () => {
    const guardKey = `auth-back-guard:${Date.now()}`;
    const currentState = window.history.state || {};

    // 当前记录标成“跳板底座”
    window.history.replaceState(
      {
        ...currentState,
        __authBackGuardBase: guardKey,
      },
      '',
      location.href,
    );

    // 再压入一个相同地址的“顶层占位”,让第一次后退先落到上面的底座状态
    window.history.pushState(
      {
        ...currentState,
        __authBackGuardTop: guardKey,
      },
      '',
      location.href,
    );

    const handlePopState = (event) => {
      if (event.state?.__authBackGuardBase !== guardKey) {
        return;
      }

      window.removeEventListener('popstate', handlePopState);
      // 当前位置已经从顶层占位退回到底座了,再退两层即可跨过授权地址回到真实上一页
      window.history.go(-2);
    };

    window.addEventListener('popstate', handlePopState);
  };

  /**
   * 标记这次授权流程已经进入外部跳转阶段。
   * 这个标记只用于让路由守卫在授权返回时跳过一次周期检查。
   */
  const markSkipCycleCheckForAuth = () => {
    sessionStorage.setItem(SKIP_CYCLE_CHECK_FOR_AUTH_KEY, 'true');
  };

  return {
    getQueryValue,
    getCurrentTargetHash,
    getAuthTargetHash,
    buildOpenIdAuthUrl,
    redirectToOpenIdAuth,
    savePendingAuthReturnGuard,
    consumePendingAuthReturnGuard,
    installAuthBackHistoryGuard,
    markSkipCycleCheckForAuth,
    consumeSkipCycleCheckForAuth,
  };
}