From b8d0379467166de99442eaa7390e3fe20a6484b4 Mon Sep 17 00:00:00 2001 From: zjf <1799650030@qq.com> Date: Fri, 8 May 2026 22:24:46 +0800 Subject: [PATCH] zzd --- package.json | 1 + src/App.vue | 8 ++ src/env.d.ts | 5 + src/pages.json | 7 ++ src/pages/tabbar/index.vue | 5 +- src/pages/tabbar/my.vue | 2 +- src/pages/user/zzdSmsVerify.vue | 106 ++++++++++++++++++ src/sheep/api/user.ts | 19 +++- src/sheep/utils/app.ts | 3 + src/sheep/utils/dingAuthCode.ts | 188 ++++++++++++++++++++++++++++++++ src/sheep/utils/loadScript.ts | 10 ++ src/sheep/utils/request.ts | 76 +++++++++++-- 12 files changed, 417 insertions(+), 13 deletions(-) create mode 100644 src/pages/user/zzdSmsVerify.vue create mode 100644 src/sheep/utils/dingAuthCode.ts create mode 100644 src/sheep/utils/loadScript.ts diff --git a/package.json b/package.json index c9270c2..9b3772a 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@zebra-ui/swiper": "^2.2.8", "crypto-js": "^4.2.0", "dayjs": "^1.11.13", + "gdt-jsapi": "^1.9.55-beta.14", "luch-request": "^3.1.1", "pinia": "^2.2.6", "qrcode": "^1.5.4", diff --git a/src/App.vue b/src/App.vue index 2511212..9b24feb 100644 --- a/src/App.vue +++ b/src/App.vue @@ -164,6 +164,14 @@ const setTabbar = (launchParam?: string, auth?: string) => { } export default { + async onLaunch() { + try { + const { tryZzdEntryLogin } = await import("@/sheep/utils/request") + await tryZzdEntryLogin() + } catch (e) { + console.warn("[App.onLaunch] tryZzdEntryLogin", e) + } + }, methods: { async _initTabbar() { const launchParam = getLaunchParam() diff --git a/src/env.d.ts b/src/env.d.ts index cffc168..d3e9c77 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -1,5 +1,10 @@ /// +declare module "gdt-jsapi" { + const dd: { getAuthCode: (opts: { corpId?: string }) => Promise } + export default dd +} + declare module "*.vue" { import { DefineComponent } from "vue" // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types diff --git a/src/pages.json b/src/pages.json index fcbd993..5b7b364 100644 --- a/src/pages.json +++ b/src/pages.json @@ -383,6 +383,13 @@ "navigationBarTitleText": "用户身份", "disableScroll": true } + }, + { + "path": "zzdSmsVerify", + "style": { + "navigationBarTitleText": "手机号核验", + "disableScroll": true + } } ] }, diff --git a/src/pages/tabbar/index.vue b/src/pages/tabbar/index.vue index 7f7d1ef..7f33c2d 100644 --- a/src/pages/tabbar/index.vue +++ b/src/pages/tabbar/index.vue @@ -69,8 +69,11 @@ const jumpByParam = async () => { const appInfo = await getAppInfo() // 环境判断 const launchParam = getLaunchParam() // 获取启动参数 - // 浙政钉环境 + // 浙政钉环境(诗画不走此分支) if (appInfo == APPENV.ZZD) { + const { tryZzdEntryLogin } = await import("@/sheep/utils/request") + const zzdR = await tryZzdEntryLogin() + if (zzdR !== "ok") return switch (launchParam) { case "canteen": // 食堂 uni.setStorageSync("launchParam", launchParam) diff --git a/src/pages/tabbar/my.vue b/src/pages/tabbar/my.vue index 8c2c3b5..9141a88 100644 --- a/src/pages/tabbar/my.vue +++ b/src/pages/tabbar/my.vue @@ -242,7 +242,7 @@ const login = { if (isEmpty(tokenStorage) || isEmpty(userStorage)) { try { uni.showLoading({ title: "登录中...", mask: true }) - const res = await userApi.auth(token) + const res = await userApi.auth({ token }) if (res.code == 0) { storage.saveToken(res.data) diff --git a/src/pages/user/zzdSmsVerify.vue b/src/pages/user/zzdSmsVerify.vue new file mode 100644 index 0000000..6865182 --- /dev/null +++ b/src/pages/user/zzdSmsVerify.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/src/sheep/api/user.ts b/src/sheep/api/user.ts index e1d8de8..4cfb042 100644 --- a/src/sheep/api/user.ts +++ b/src/sheep/api/user.ts @@ -12,12 +12,25 @@ export default { method: "GET", }), - // 浙政钉授权登录 - auth: (token: string): Promise => + // 浙政钉授权登录(token:旧 uabaseToken;authCode:开放平台 H5 getAuthCode) + auth: (payload: { token?: string; authCode?: string }): Promise => request({ url: "/thirdparty/thirdPartyOAth2/zzdsso", method: "POST", - data: { token }, + data: payload, + }), + + zzdLoginSms: (mobilePhone: string): Promise => + request({ + url: `/thirdparty/thirdPartyOAth2/loginSms?mobilePhone=${encodeURIComponent(mobilePhone.trim())}`, + method: "POST", + }), + + zzdSsoConfirmPhone: (data: { bindSessionId: string; mobilePhone: string; smsCode: string }): Promise => + request({ + url: "/thirdparty/thirdPartyOAth2/zzdsso/confirmPhone", + method: "POST", + data, }), // 诗画授权登录 diff --git a/src/sheep/utils/app.ts b/src/sheep/utils/app.ts index 051bf54..739bd6b 100644 --- a/src/sheep/utils/app.ts +++ b/src/sheep/utils/app.ts @@ -12,6 +12,9 @@ export const getLaunchParam = (): string => { // 检测app信息判断运行环境 export const getAppInfo = async (): Promise => { + if (import.meta.env.VITE_DEV_FORCE_ZZD === "true") { + return APPENV.ZZD + } try { const res = await (window as any).CP2.getAppInfo() // 浙政钉环境 diff --git a/src/sheep/utils/dingAuthCode.ts b/src/sheep/utils/dingAuthCode.ts new file mode 100644 index 0000000..1deffbe --- /dev/null +++ b/src/sheep/utils/dingAuthCode.ts @@ -0,0 +1,188 @@ +import { loadScript } from "./loadScript" + +const JSAPI_LOAD_TIMEOUT_MS = 45000 +/** 仅在不注入 dd 的环境(如普通浏览器)按需拉取;浙政钉内通常自带 window.dd */ +const DEFAULT_OPEN_JS_URL = "https://g.alicdn.com/dingding/dingtalk-jsapi/3.0.12/dingtalk.open.js" +/** 微应用需 dd.config 完成后 ready 才会回调;超时则不再盲调 requestAuthCode */ +const DD_READY_TIMEOUT_MS = 30000 + +let ddErrorCallbackRegistered = false + +declare global { + interface Window { + dd?: { + env?: { platform?: string } + /** 政务钉钉 / 专有钉钉:JSAPI 鉴权(ticket 来自开放平台 get_jsapi_token) */ + authConfig?: (opts: { ticket: string; jsApiList: string[] }) => Promise + config?: (opts: { + agentId: string + corpId: string + timeStamp: string + nonceStr: string + signature: string + type?: number + jsApiList: string[] + }) => void + /** 专有钉钉 / 浙政钉文档:获取登录授权码(Promise),移动端返回 code,PC 返回 auth_code */ + getAuthCode?: (opts: { corpId?: string }) => Promise<{ + code?: string + auth_code?: string + expiresln?: number + expires_in?: number + }> + ready: (cb: () => void) => void + /** 鉴权/配置失败时回调,便于排查未触发取码的原因 */ + error?: (cb: (err: unknown) => void) => void + runtime: { + permission: { + requestAuthCode: (opts: { + corpId: string + onSuccess: (info: { code: string }) => void + onFail?: (err: unknown) => void + }) => void + } + } + } + } +} + +function registerDdErrorOnce() { + const dd = window.dd + if (!dd || ddErrorCallbackRegistered || typeof dd.error !== "function") return + ddErrorCallbackRegistered = true + try { + dd.error((err: unknown) => { + console.error("[dd.error]", typeof err === "string" ? err : err) + }) + } catch { + /* ignore */ + } +} + +function injectWaitMs(): number { + const v = import.meta.env.VITE_DD_INJECT_WAIT_MS + if (v === undefined || v === "") return 5000 + const n = parseInt(String(v), 10) + return Number.isFinite(n) && n >= 0 ? n : 5000 +} + +/** 部分 IRS/扫码场景下容器晚于首屏注入 dd,短轮询避免误判 */ +function waitForNativeDd(maxMs: number): Promise { + if (maxMs <= 0) return Promise.resolve(!!window.dd) + if (window.dd) return Promise.resolve(true) + return new Promise((resolve) => { + const t0 = Date.now() + const id = setInterval(() => { + if (window.dd) { + clearInterval(id) + resolve(true) + } else if (Date.now() - t0 >= maxMs) { + clearInterval(id) + resolve(false) + } + }, 80) + }) +} + +/** + * 浙政钉 / 钉钉容器内需在 dd.ready 之后再调 getAuthCode / requestAuthCode,否则 Android 上常见无回调。 + */ +export function ensureDdReady(): Promise { + return new Promise((resolve, reject) => { + const dd = window.dd + if (!dd) { + reject(new Error("window.dd 不存在")) + return + } + if (typeof dd.ready !== "function") { + resolve() + return + } + let settled = false + const to = setTimeout(() => { + if (settled) return + settled = true + const msg = `dd.ready 超时(${DD_READY_TIMEOUT_MS / 1000}s)。完整单点需先 dd.authConfig;或调试时跳过 ready 直接 getAuthCode。` + reject(new Error(msg)) + }, DD_READY_TIMEOUT_MS) + try { + dd.ready(() => { + if (settled) return + settled = true + clearTimeout(to) + resolve() + }) + } catch (e) { + clearTimeout(to) + if (!settled) { + settled = true + reject(e instanceof Error ? e : new Error(String(e))) + } + } + }) +} + +/** + * 1) 优先用容器自带的 `window.dd`(浙政钉内嵌常见)。 + * 2) 若首屏没有 dd:先等待 `VITE_DD_INJECT_WAIT_MS`(默认 5s)以兼容延迟注入。 + * 3) 仍没有时:默认自动拉 g.alicdn open.js(IRS 测试、扫码开系统浏览器等);纯内嵌且绝不拉脚本可设 `VITE_DD_NO_OPEN_JS_FALLBACK=true`。 + * 4) 显式 `VITE_DD_LOAD_OPEN_JS=true` 时跳过等待立即拉脚本(本地 Chrome 调试可配合 .env.development)。 + */ +export async function loadDingTalkOpenJsapi() { + if (window.dd) { + registerDdErrorOnce() + return + } + const forceScript = import.meta.env.VITE_DD_LOAD_OPEN_JS === "true" + const noFallback = import.meta.env.VITE_DD_NO_OPEN_JS_FALLBACK === "true" + if (!forceScript) { + const waitMs = injectWaitMs() + if (waitMs > 0) { + const ok = await waitForNativeDd(waitMs) + if (ok) { + registerDdErrorOnce() + return + } + } + if (noFallback) { + const msg = + "等待后仍无 window.dd。请确认链接在浙政钉内置 WebView 打开;或去掉 VITE_DD_NO_OPEN_JS_FALLBACK、或设 VITE_DD_LOAD_OPEN_JS=true" + throw new Error(msg) + } + } + const scriptUrl = (import.meta.env.VITE_DD_JSAPI_URL || "").trim() || DEFAULT_OPEN_JS_URL + await Promise.race([ + loadScript(scriptUrl), + new Promise((_, rej) => + setTimeout( + () => rej(new Error(`钉钉 JSAPI 脚本加载超时(${JSAPI_LOAD_TIMEOUT_MS / 1000}s)`)), + JSAPI_LOAD_TIMEOUT_MS, + ), + ), + ]) + if (!window.dd) { + throw new Error("脚本已执行但 window.dd 仍不存在") + } + registerDdErrorOnce() +} + +/** gdt-jsapi 容器返回:code / auth_code / authCode(与 zzdhome `src/App.vue` 中 `authCodeFromGetAuthResult` 一致) */ +export function authCodeFromGdtResult(res: unknown): string | null { + if (!res || typeof res !== "object") return null + const o = res as Record + const c = o.code ?? o.auth_code ?? o.authCode + if (typeof c === "string" && c.trim()) return c.trim() + return null +} + +/** + * 与 zzdhome `App.vue` 的 `runZzdLogin` 一致:`loadDingTalkOpenJsapi` 后使用 `gdt-jsapi` 的 `getAuthCode`,不再单独用 window.dd 包一层 Promise。 + */ +export async function requestZzdAuthCodeViaGdt(corpId: string): Promise { + const dd = (await import("gdt-jsapi")).default + await loadDingTalkOpenJsapi() + const res = await dd.getAuthCode({ corpId: (corpId || "").trim() }) + const code = authCodeFromGdtResult(res) + if (!code) throw new Error("getAuthCode 未返回有效 code(请核对 gdt 返回字段)") + return code +} diff --git a/src/sheep/utils/loadScript.ts b/src/sheep/utils/loadScript.ts new file mode 100644 index 0000000..a2c20c4 --- /dev/null +++ b/src/sheep/utils/loadScript.ts @@ -0,0 +1,10 @@ +export function loadScript(src: string): Promise { + return new Promise((resolve, reject) => { + const el = document.createElement("script") + el.src = src + el.async = true + el.onload = () => resolve() + el.onerror = () => reject(new Error(`脚本加载失败: ${src}`)) + document.head.appendChild(el) + }) +} diff --git a/src/sheep/utils/request.ts b/src/sheep/utils/request.ts index 63e1bf2..3a32012 100644 --- a/src/sheep/utils/request.ts +++ b/src/sheep/utils/request.ts @@ -219,39 +219,99 @@ const autoLogin = async (): Promise => { return false } -// 浙政钉登录 -async function loginByZzd(): Promise { +function isZzdNeedSmsVerify(data: unknown): data is { needSmsVerify: true; bindSessionId: string; displayName?: string } { + if (!data || typeof data !== "object") return false + const o = data as Record + return o.needSmsVerify === true && typeof o.bindSessionId === "string" +} + +function goZzdSmsVerify(data: { bindSessionId: string; displayName?: string }) { + const name = typeof data.displayName === "string" ? data.displayName : "" + const q = `bindSessionId=${encodeURIComponent(data.bindSessionId)}&displayName=${encodeURIComponent(name)}` + uni.reLaunch({ url: `/pages/user/zzdSmsVerify?${q}` }) +} + +/** + * 浙政钉专用:校验本地 token → 无效则 getAuthCode→zzdsso → 需绑手机则去短信页 → 否则换 token。 + * @returns ok 已可带 token 请求;sms 已跳转核验页;failed 未登录 + */ +async function zzdLoginCore(): Promise<"ok" | "sms" | "failed"> { try { uni.showLoading({ title: "登录中...", mask: true }) - const token = new URL(window.location.href).searchParams.get("uabaseToken") + const tokenStorage = uni.getStorageSync(TOKEN_KEY) + if (!isEmpty(tokenStorage) && !isTokenExpires(tokenStorage) && getTokenValue(tokenStorage)) { + uni.hideLoading() + return "ok" + } + + if (typeof window !== "undefined") { + try { + const { requestZzdAuthCodeViaGdt } = await import("./dingAuthCode") + const corpId = (import.meta.env.VITE_ZZD_CORP_ID || "").trim() + const authCode = await requestZzdAuthCodeViaGdt(corpId) + const res: any = await userApi.auth({ authCode }) + if (res?.code == 0) { + if (isZzdNeedSmsVerify(res.data)) { + goZzdSmsVerify(res.data) + return "sms" + } + uni.setStorageSync(TOKEN_KEY, res.data) + const { data: userData } = await userApi.userInfo() + uni.setStorageSync(USER_INFO_KEY, userData) + return "ok" + } + if (res?.msg) { + uni.showToast({ title: res.msg, icon: "none", duration: 4000 }) + } + } catch (e) { + console.warn("[loginByZzd] authCode 登录未成功,尝试 uabaseToken", e) + } + } + + const token = + typeof window !== "undefined" ? new URL(window.location.href).searchParams.get("uabaseToken") : null if (!token) { uni.showToast({ title: "用户凭证已失效,请重新进入智慧后勤应用", icon: "none", duration: 6000 }) - return false + return "failed" } - const res = await userApi.auth(token) + const res = await userApi.auth({ token }) if (res?.code == 0) { uni.setStorageSync(TOKEN_KEY, res.data) const { data: userData } = await userApi.userInfo() uni.setStorageSync(USER_INFO_KEY, userData) - return true + return "ok" } else { uni.removeStorageSync(TOKEN_KEY) uni.removeStorageSync(USER_INFO_KEY) if (res.msg == "token已失效") { uni.showToast({ title: "用户凭证已失效,请退出并重新进入智慧后勤应用", icon: "none", duration: 6000 }) } - return false + return "failed" } } catch (error: any) { uni.showToast({ title: error, icon: "none" }) - return false + return "failed" } finally { uni.hideLoading() } } +async function loginByZzd(): Promise { + const r = await zzdLoginCore() + return r === "ok" +} + +/** + * 从门户/常用入口进入 H5 时主动跑浙政钉单点。非浙政钉环境立即返回 ok,不调用 zzdsso、不碰诗画逻辑。 + */ +export async function tryZzdEntryLogin(): Promise<"ok" | "sms" | "failed"> { + const appInfo = await getAppInfo() + if (appInfo !== APPENV.ZZD) return "ok" + return await zzdLoginCore() +} + // 诗画登录 async function loginBySh(): Promise { try {