diff --git a/.env b/.env index 272088f..4d20a67 100644 --- a/.env +++ b/.env @@ -1,3 +1,7 @@ +# H5:true 时在页面加载 vConsole(浙政钉内可看 Network/Log);稳定上线后改为 false +#VITE_VCONSOLE=true +VITE_VCONSOLE=false + #是否是TOC应用 VITE_IS_TOC=true @@ -16,18 +20,21 @@ VITE_PWD_ENC_KEY='pigxpigxpigxpigx' # OAUTH2 APP模式客户端信息 VITE_OAUTH2_APP_CLIENT='mini:mini' +# 浙政钉 H5:getAuthCode 用 corpId(与 zzdhome 一致,浙政钉内打开业务 H5 时必填) +# VITE_ZZD_CORP_ID= + # --------------------------- 开发环境 api地址 ---------------------------------- # VITE_APP_DEV_SERVICE = https://stgl.pj.gov.cn/api # 域名线上 #VITE_APP_DEV_SERVICE = http://59.202.170.152:81/api # 信创线上 -VITE_APP_DEV_SERVICE = https://dzc-test-1.fy.takin.cc # 邓-本地 -# VITE_APP_DEV_SERVICE = http://127.0.0.1:9999 # 邓-本地 +; VITE_APP_DEV_SERVICE = https://dzc-test-1.fy.takin.cc # 邓-本地 +VITE_APP_DEV_SERVICE = http://127.0.0.1:9999 # 邓-本地 # VITE_APP_DEV_SERVICE = http://123.157.250.162:8089/api # 测试线上 # --------------------------- 开发环境 图片访问基础地址 --------------------------- # VITE_IMAGE_DEV_PATH = https://stgl.pj.gov.cn/api # 域名线上 #VITE_IMAGE_DEV_PATH = http://59.202.170.152:81/api # 信创线上 -VITE_IMAGE_DEV_PATH = https://dzc-test-1.fy.takin.cc # 邓-本地 -#VITE_IMAGE_DEV_PATH = http://127.0.0.1:9999 +#VITE_IMAGE_DEV_PATH = https://dzc-test-1.fy.takin.cc # 邓-本地 +VITE_IMAGE_DEV_PATH = http://127.0.0.1:9999 # VITE_IMAGE_DEV_PATH = http://123.157.250.162:8089/api # 测试线上 # --------------------------- 生产环境 api地址 ---------------------------------- diff --git a/index.html b/index.html index 2f148ee..2ef6934 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,6 @@ - - diff --git a/package.json b/package.json index c9270c2..b01a1ac 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "type-check": "vue-tsc --noEmit" }, "dependencies": { + "@babel/runtime": "^7.26.0", "@dcloudio/uni-app": "3.0.0-4020920240930001", "@dcloudio/uni-app-harmony": "3.0.0-4020920240930001", "@dcloudio/uni-app-plus": "3.0.0-4020920240930001", @@ -62,11 +63,13 @@ "@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", "qs": "^6.13.1", "radash": "^12.1.0", + "vconsole": "^3.15.1", "vue": "^3.3.11", "vue-i18n": "^9.1.9", "vue-waterfall-plugin-next": "^2.6.5", @@ -90,4 +93,4 @@ "vite": "5.2.8", "vue-tsc": "^1.0.24" } -} \ No newline at end of file +} diff --git a/src/App.vue b/src/App.vue index 2511212..78ba379 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,5 +1,5 @@ + + + + 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..f2db66f 100644 --- a/src/sheep/utils/app.ts +++ b/src/sheep/utils/app.ts @@ -5,13 +5,31 @@ import { APPENV } from "../constant" // export default () => import.meta.env.VITE_DEV_ENV === "dev" export const isDevEnv = (): boolean => process.env.NODE_ENV === "development" +/** + * 读取当前页 URL 查询参数(H5)。兼容 hash 路由:`/h5/#/?launchParam=canteen` 中参数在 hash 段,不在 location.search。 + */ +export const getUrlQueryParam = (name: string): string => { + if (typeof window === "undefined") return "" + const u = new URL(window.location.href) + const fromSearch = u.searchParams.get(name) + if (fromSearch) return fromSearch + const hash = window.location.hash || "" + const qi = hash.indexOf("?") + if (qi < 0) return "" + const fromHash = new URLSearchParams(hash.slice(qi + 1)).get(name) + return fromHash || "" +} + // 获取启动参数 export const getLaunchParam = (): string => { - return new URL(window.location.href).searchParams.get("launchParam") || uni.getStorageSync("launchParam") || "" + return getUrlQueryParam("launchParam") || uni.getStorageSync("launchParam") || "" } // 检测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..118742d --- /dev/null +++ b/src/sheep/utils/dingAuthCode.ts @@ -0,0 +1,188 @@ +import gdtJsapi from "gdt-jsapi" +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 { + await loadDingTalkOpenJsapi() + const res = await gdtJsapi.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..a8fdb28 100644 --- a/src/sheep/utils/request.ts +++ b/src/sheep/utils/request.ts @@ -1,7 +1,7 @@ import Request from "luch-request" import { router, constant } from "@@/index" import { isEmpty } from "radash" -import { getAppInfo, isDevEnv } from "@@/utils/app" +import { getAppInfo, getUrlQueryParam, isDevEnv } from "@@/utils/app" import { userApi } from "../api" import showVerifyDeviceImpModel from "../components/verifyDeviceImpModel/verifyDeviceImpModel" import { APPENV } from "../constant" @@ -219,39 +219,109 @@ 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 + const need = + o.needSmsVerify === true || o.needSmsVerify === "true" || o.needSmsVerify === 1 || o.needSmsVerify === "1" + const sid = o.bindSessionId + const sidStr = typeof sid === "string" ? sid : sid != null ? String(sid) : "" + return need && sidStr.length > 0 +} + +function goZzdSmsVerify(data: { bindSessionId: string; displayName?: string }) { + const sid = typeof data.bindSessionId === "string" ? data.bindSessionId : String(data.bindSessionId || "") + const name = typeof data.displayName === "string" ? data.displayName : "" + const q = `bindSessionId=${encodeURIComponent(sid)}&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 as { bindSessionId: string; displayName?: string }) + return "sms" + } + const d = res.data + if (!d || typeof d !== "object" || (!d.accessToken && !d.refreshToken)) { + uni.showToast({ title: "登录返回数据异常,请重试", icon: "none", duration: 4000 }) + return "failed" + } + 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 }) + } + return "failed" + } catch (e) { + console.warn("[loginByZzd] authCode 登录未成功,尝试 uabaseToken", e) + } + } + + const token = typeof window !== "undefined" ? getUrlQueryParam("uabaseToken") || null : 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 { @@ -281,8 +351,8 @@ async function loginBySh(): Promise { return false } - // 如果是设备未验证,保存响应数据 - if (response.data == -1) { + // 设备未信任:后端 R.failed(-1, msg) 业务码在 code,data 为 null + if (response.code == -1) { uni.setStorageSync(TOKEN_KEY, { data: -1 }) } diff --git a/vite.config.ts b/vite.config.ts index 4ef4276..d94d052 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,7 +4,7 @@ import { defineConfig, loadEnv } from "vite" import uni from "@dcloudio/vite-plugin-uni" import Unocss from "unocss/vite" -import { useRouter } from "./src/sheep" +import { useRouter } from "./src/sheep/plugs/use-router/vite-router-plug" export default defineConfig(({ mode }) => { const { NODE_ENV, VITE_APP_DEV_SERVICE, VITE_APP_PROD_SERVICE, VITE_IMAGE_DEV_PATH } = loadEnv(mode, process.cwd(), "")