auth.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. import {
  2. DEMO_ACCOUNT,
  3. DEMO_PASSWORD,
  4. getAuthCredentials,
  5. logout,
  6. saveCurrentUser,
  7. saveLoginSession,
  8. savePermissionData,
  9. saveRememberedCredentials,
  10. } from "./storage";
  11. import { clearAttendanceCache } from "@/api/attendance";
  12. const SERVER_KEY = "apiInfo";
  13. const DEFAULT_SERVER = {
  14. mode: "demo",
  15. protocol: "http://",
  16. hostname: "",
  17. port: "",
  18. };
  19. export function getServerConfig() {
  20. const saved = uni.getStorageSync(SERVER_KEY) || {};
  21. return {
  22. ...DEFAULT_SERVER,
  23. ...saved,
  24. protocol: saved.protocol || saved.protocal || DEFAULT_SERVER.protocol,
  25. };
  26. }
  27. export function saveServerConfig(config) {
  28. const normalized = {
  29. mode: config.mode === "server" ? "server" : "demo",
  30. protocol: config.protocol === "https://" ? "https://" : "http://",
  31. hostname: String(config.hostname || "")
  32. .trim()
  33. .replace(/^https?:\/\//, "")
  34. .replace(/\/$/, ""),
  35. port: String(config.port || "").trim(),
  36. };
  37. uni.setStorageSync(SERVER_KEY, normalized);
  38. return normalized;
  39. }
  40. // H5 dev(pnpm dev:h5)走 Vite 代理避开浏览器 CORS;其他场景仍用配置里的真实地址
  41. const useDevProxy =
  42. typeof window !== "undefined" &&
  43. typeof process !== "undefined" &&
  44. process.env?.NODE_ENV !== "production";
  45. export function getApiBaseUrl(config = getServerConfig()) {
  46. if (config.mode !== "server" || !config.hostname) return "";
  47. if (useDevProxy) return "/api";
  48. const port = config.port ? `:${config.port}` : "";
  49. return `${config.protocol}${config.hostname}${port}/api`;
  50. }
  51. export function request({
  52. url,
  53. method = "GET",
  54. data,
  55. token,
  56. sessionId,
  57. timeout = 15000,
  58. }) {
  59. return new Promise((resolve, reject) => {
  60. // 未显式传 token 时自动从登录态读取,登录后所有请求自动带上凭证
  61. let effectiveToken = token;
  62. let effectiveSessionId = sessionId;
  63. if (!effectiveToken) {
  64. const stored = getAuthCredentials();
  65. effectiveToken = stored.token;
  66. if (!effectiveSessionId) effectiveSessionId = stored.sessionId;
  67. }
  68. const header = effectiveToken
  69. ? {
  70. "content-type": "application/json",
  71. "zoomwin-token": effectiveToken,
  72. Authorization: effectiveToken,
  73. "zoomwin-sid": effectiveSessionId || "",
  74. }
  75. : { "content-type": "application/json", platform: "wxapp" };
  76. uni.request({
  77. url,
  78. method,
  79. data,
  80. header,
  81. timeout,
  82. success: (response) => {
  83. if (response.statusCode === 401) {
  84. // 服务端认为凭证过期:清本地会话 + 清考勤缓存 + 跳登录页
  85. logout();
  86. clearAttendanceCache();
  87. uni.showToast({
  88. title: "登录已过期,请重新登录",
  89. icon: "none",
  90. duration: 1500,
  91. });
  92. setTimeout(() => uni.reLaunch({ url: "/pages/login/index" }), 1200);
  93. reject(new Error("身份验证已过期,请重新登录"));
  94. return;
  95. }
  96. const body = response.data || {};
  97. if (response.statusCode < 200 || response.statusCode >= 300) {
  98. reject(
  99. new Error(
  100. body.message || `服务器响应异常(${response.statusCode})`,
  101. ),
  102. );
  103. return;
  104. }
  105. if (body.code !== undefined && Number(body.code) !== 0) {
  106. reject(new Error(body.message || "请求失败"));
  107. return;
  108. }
  109. resolve(body);
  110. },
  111. fail: (error) => reject(new Error(error.errMsg || "无法连接服务器")),
  112. });
  113. });
  114. }
  115. /**
  116. * multipart/form-data 上传封装,对应 /main/file/uploadFile 等需要走 uni.uploadFile 的接口。
  117. * 与 request() 的差异:
  118. * - 不写 content-type(uni-app 会自动设 multipart/form-data + boundary)
  119. * - 走 uni.uploadFile,文件路径用 filePath,表单字段名用 name(默认 multiPartFile)
  120. * - H5 端响应体可能是 JSON 字符串,需要 typeof === 'string' 时 JSON.parse
  121. * 401、code !== 0 等失败语义与 request() 完全一致,调用方可统一处理。
  122. */
  123. export function uploadFile({
  124. url,
  125. filePath,
  126. name = "multiPartFile",
  127. formData,
  128. timeout = 30000,
  129. }) {
  130. return new Promise((resolve, reject) => {
  131. if (!filePath) {
  132. reject(new Error("缺少文件路径"));
  133. return;
  134. }
  135. const { token, sessionId } = getAuthCredentials();
  136. const header = token
  137. ? {
  138. "zoomwin-token": token,
  139. Authorization: token,
  140. "zoomwin-sid": sessionId || "",
  141. }
  142. : { platform: "wxapp" };
  143. uni.uploadFile({
  144. url,
  145. filePath,
  146. name,
  147. formData,
  148. header,
  149. timeout,
  150. success: (response) => {
  151. if (response.statusCode === 401) {
  152. logout();
  153. clearAttendanceCache();
  154. uni.showToast({
  155. title: "登录已过期,请重新登录",
  156. icon: "none",
  157. duration: 1500,
  158. });
  159. setTimeout(() => uni.reLaunch({ url: "/pages/login/index" }), 1200);
  160. reject(new Error("身份验证已过期,请重新登录"));
  161. return;
  162. }
  163. let body = response.data;
  164. if (typeof body === "string") {
  165. try {
  166. body = JSON.parse(body);
  167. } catch {
  168. // 不是 JSON:保持原字符串,下方按非 2xx/message 处理
  169. }
  170. }
  171. const payload = body && typeof body === "object" ? body : {};
  172. if (
  173. response.statusCode < 200 ||
  174. response.statusCode >= 300
  175. ) {
  176. reject(
  177. new Error(
  178. payload.message || `服务器响应异常(${response.statusCode})`,
  179. ),
  180. );
  181. return;
  182. }
  183. if (payload.code !== undefined && Number(payload.code) !== 0) {
  184. reject(new Error(payload.message || "请求失败"));
  185. return;
  186. }
  187. resolve(payload);
  188. },
  189. fail: (error) => reject(new Error(error.errMsg || "文件上传失败")),
  190. });
  191. });
  192. }
  193. export async function testServerConnection(config) {
  194. const normalized = {
  195. mode: "server",
  196. protocol: config.protocol === "https://" ? "https://" : "http://",
  197. hostname: String(config.hostname || "")
  198. .trim()
  199. .replace(/^https?:\/\//, "")
  200. .replace(/\/$/, ""),
  201. port: String(config.port || "").trim(),
  202. };
  203. const baseUrl = getApiBaseUrl(normalized);
  204. if (!baseUrl) throw new Error("请填写服务器地址");
  205. await request({
  206. url: `${baseUrl}/main/connection/getConnectionTest`,
  207. timeout: 5000,
  208. });
  209. return true;
  210. }
  211. function collectAuthorities(tree) {
  212. const authorities = [];
  213. const walk = (list) => {
  214. for (const item of Array.isArray(list) ? list : []) {
  215. if (item && item.menuType === 2) authorities.push(item);
  216. if (item?.children?.length) walk(item.children);
  217. }
  218. };
  219. walk(tree);
  220. return authorities;
  221. }
  222. async function loadPermissionTree(baseUrl) {
  223. const response = await request({
  224. url: `${baseUrl}/system/resources/getResourcesTreePDA`,
  225. });
  226. const tree = Array.isArray(response.data) ? response.data : [];
  227. savePermissionData(tree, collectAuthorities(tree));
  228. return tree;
  229. }
  230. // 拉取当前登录人的扩展资料(部门、岗位、角色等),登录后调用一次写入本地
  231. export async function fetchCurrentUser(baseUrl) {
  232. if (!baseUrl) throw new Error("缺少 baseUrl");
  233. const response = await request({
  234. url: `${baseUrl}/system/account/getLoginUser`,
  235. });
  236. return response.data || null;
  237. }
  238. // 拉取员工完整档案(含 deptName / postId / secondLinkName / secondLinkPhone),
  239. // 与 getLoginUser 合并补全部门岗位与紧急联系人
  240. // profile 响应把用户数据放在 user 子对象里,内部摊平到顶层
  241. // 岗位名称不在 profile 直接返回,而是 postId(逗号分隔的 id 串),
  242. // 内部再调 /hr/position/page 用 id 匹配出 positionName 后回填到返回对象的 position 字段
  243. export async function fetchUserDetail(baseUrl, userId) {
  244. if (!baseUrl) throw new Error("缺少 baseUrl");
  245. if (!userId && userId !== 0) throw new Error("缺少用户ID");
  246. const response = await request({
  247. url: `${baseUrl}/main/users/${userId}/profile`,
  248. });
  249. const data = response.data || null;
  250. if (!data) return null;
  251. // 嵌套 user 包装时,把 user 的字段提到顶层(deptName / secondLinkName 等),
  252. // 同时保留顶层其他字段(postId 等);mapCurrentUser 不必关心嵌套结构
  253. const profile = data.user && typeof data.user === "object"
  254. ? { ...data, ...data.user }
  255. : data;
  256. try {
  257. const names = await resolvePostIds(baseUrl, profile.postId);
  258. if (names) profile.position = names;
  259. } catch (error) {
  260. // 岗位解析失败不影响其他字段
  261. }
  262. return profile;
  263. }
  264. // 把 profile.postId("1111,2222")解析成岗位名称字符串,用 / 拼接多个岗位
  265. async function resolvePostIds(baseUrl, postIdString) {
  266. if (!postIdString) return "";
  267. const ids = String(postIdString)
  268. .split(",")
  269. .map((s) => s.trim())
  270. .filter(Boolean);
  271. if (!ids.length) return "";
  272. const response = await request({
  273. url: `${baseUrl}/hr/position/page`,
  274. method: "POST",
  275. data: { pageNum: 1, size: 1000, status: 1 },
  276. });
  277. const pageData = response.data || {};
  278. const records = Array.isArray(pageData.list)
  279. ? pageData.list
  280. : Array.isArray(pageData)
  281. ? pageData
  282. : [];
  283. const names = ids
  284. .map((id) => {
  285. const match = records.find((r) => String(r.id) === id);
  286. return match ? String(match.positionName || match.name || "") : "";
  287. })
  288. .filter(Boolean);
  289. return names.join("、");
  290. }
  291. export async function performLogin({ account, password, rememberPassword }) {
  292. const config = getServerConfig();
  293. const loginName = String(account || "").trim();
  294. const loginPwd = String(password || "");
  295. if (config.mode !== "server") {
  296. await new Promise((resolve) => setTimeout(resolve, 450));
  297. if (
  298. loginName.toUpperCase() !== DEMO_ACCOUNT ||
  299. loginPwd !== DEMO_PASSWORD
  300. ) {
  301. throw new Error("工号或密码错误");
  302. }
  303. const data = {
  304. token: `demo-token-${Date.now()}`,
  305. sessionId: "demo-session",
  306. userId: DEMO_ACCOUNT,
  307. loginName: DEMO_ACCOUNT,
  308. userName: "陈晓雨",
  309. };
  310. saveLoginSession(data, loginName);
  311. savePermissionData([], []);
  312. saveRememberedCredentials({
  313. account: loginName,
  314. password: loginPwd,
  315. rememberPassword,
  316. });
  317. return data;
  318. }
  319. const baseUrl = getApiBaseUrl(config);
  320. if (!baseUrl) throw new Error("请先配置服务器地址");
  321. const response = await request({
  322. url: `${baseUrl}/main/user/login`,
  323. method: "POST",
  324. data: { loginName, loginPwd },
  325. });
  326. const data = response.data || {};
  327. if (!data.token) throw new Error("登录响应中缺少 Token");
  328. saveLoginSession(data, loginName);
  329. saveRememberedCredentials({
  330. account: loginName,
  331. password: loginPwd,
  332. rememberPassword,
  333. });
  334. try {
  335. await loadPermissionTree(baseUrl);
  336. } catch (error) {
  337. savePermissionData([], []);
  338. uni.showToast({ title: error.message || "权限加载失败", icon: "none" });
  339. }
  340. try {
  341. // 部门/岗位在 getLoginUser 里没有,单独调 getById 拿,再合并
  342. const [basic, detail] = await Promise.allSettled([
  343. fetchCurrentUser(baseUrl),
  344. fetchUserDetail(baseUrl, data.userId),
  345. ]);
  346. const basicData = basic.status === "fulfilled" ? basic.value : null;
  347. const detailData = detail.status === "fulfilled" ? detail.value : null;
  348. // getById 字段覆盖基础信息(deptName / postName / joinDate 等)
  349. const merged = detailData
  350. ? { ...(basicData || {}), ...detailData }
  351. : basicData;
  352. if (merged) saveCurrentUser(merged);
  353. if (detail.status === "rejected") {
  354. uni.showToast({
  355. title: detail.reason?.message || "员工档案加载失败",
  356. icon: "none",
  357. });
  358. }
  359. } catch (error) {
  360. uni.showToast({ title: error.message || "用户信息加载失败", icon: "none" });
  361. }
  362. return data;
  363. }
  364. export async function getCompanyBranding(config = getServerConfig()) {
  365. if (config.mode !== "server") return null;
  366. const baseUrl = getApiBaseUrl(config);
  367. if (!baseUrl) return null;
  368. const response = await request({
  369. url: `${baseUrl}/pda/mes/us/indexName`,
  370. timeout: 5000,
  371. });
  372. return response.data || null;
  373. }
  374. // 服务器模式下退出登录:通知后端作废 token,再由页面清理本地 storage 并跳登录页
  375. // 演示模式或未配置服务器时直接返回(不抛错),由调用方继续清理本地
  376. export async function serverLogout() {
  377. if (getServerConfig().mode !== "server") return;
  378. const baseUrl = getApiBaseUrl();
  379. if (!baseUrl) return;
  380. try {
  381. await request({
  382. url: `${baseUrl}/main/user/logout`,
  383. method: "POST",
  384. timeout: 5000,
  385. });
  386. } catch (error) {
  387. // 服务器登出失败(网络/401/token 过期等)不阻塞本地清理
  388. console.warn("[auth] server logout failed:", error?.message || error);
  389. }
  390. }