From c3f183cfbaef776a8ba2b630341ee443c59d4d0c Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Mon, 3 Aug 2026 22:33:46 +0800 Subject: [PATCH] feat(mobile-h5): adapt portal RSA login and split db HTTP client Wire login to /web/auth/login with JSEncrypt, unwrap portal envelopes, and add a direct-JSON db client for weighbridge APIs. Co-authored-by: Cursor --- mobile-h5/src/api/authApi.ts | 39 ++++++++++++ mobile-h5/src/types/store.ts | 9 +-- mobile-h5/src/utils/http/db.ts | 97 +++++++++++++++++++++++++++++ mobile-h5/src/utils/http/index.ts | 93 ++++++++------------------- mobile-h5/src/utils/loginRsa.ts | 23 +++++++ mobile-h5/src/views/login/index.vue | 42 +++++-------- 6 files changed, 208 insertions(+), 95 deletions(-) create mode 100644 mobile-h5/src/api/authApi.ts create mode 100644 mobile-h5/src/utils/http/db.ts create mode 100644 mobile-h5/src/utils/loginRsa.ts diff --git a/mobile-h5/src/api/authApi.ts b/mobile-h5/src/api/authApi.ts new file mode 100644 index 000000000..db9153831 --- /dev/null +++ b/mobile-h5/src/api/authApi.ts @@ -0,0 +1,39 @@ +import api from '@/utils/http' +import { buildWebEncryptedLoginBody } from '@/utils/loginRsa' + +export interface PortalUserInfo { + id: string + username: string + realName?: string + phone?: string + role?: string + avatarUrl?: string | null +} + +export interface LoginResult { + token: string + refreshToken: string + expiresIn: number + userInfo: PortalUserInfo +} + +/** 门户 RSA 登录 */ +export async function fetchLogin(username: string, password: string): Promise { + const body = await buildWebEncryptedLoginBody(username, password) + return api.post({ + url: '/web/auth/login', + data: body + }) +} + +/** 刷新 Token */ +export async function fetchRefreshToken(refreshToken: string) { + return api.post<{ + token: string + refreshToken: string + expiresIn: number + }>({ + url: '/web/auth/refresh-token', + data: { refreshToken } + }) +} diff --git a/mobile-h5/src/types/store.ts b/mobile-h5/src/types/store.ts index 76e4bd584..e7a0aef3a 100644 --- a/mobile-h5/src/types/store.ts +++ b/mobile-h5/src/types/store.ts @@ -2,11 +2,12 @@ import { MenuThemeEnum, SystemThemeEnum } from '@/enums/appEnum' // 用户信息 export interface UserInfo { - id: number - name?: string // 改为可选,与 API 返回类型一致 + id: string + name?: string username: string - avatar?: string // 改为可选,与 API 返回类型一致 - email?: string // 改为可选,与 API 返回类型一致 + avatar?: string + email?: string + role?: string } // 系统主题样式(light | dark) diff --git a/mobile-h5/src/utils/http/db.ts b/mobile-h5/src/utils/http/db.ts new file mode 100644 index 000000000..f10edb1d5 --- /dev/null +++ b/mobile-h5/src/utils/http/db.ts @@ -0,0 +1,97 @@ +/** + * 地磅(db)模块 HTTP 客户端 + * 协议:直接 JSON 响应(非门户 code/data 信封) + */ +import axios, { type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios' +import { showToast } from 'vant' +import { useUserStore } from '@/store/modules/user' + +type RetryableConfig = InternalAxiosRequestConfig & { + _retry?: boolean + showErrorMessage?: boolean +} + +/** 地磅集合根路径须带尾斜杠,否则 307 会丢 Authorization */ +const DB_COLLECTION_ROOT = /^\/(plans|inventory|weighing)$/ + +function normalizeDbUrl(url?: string): string | undefined { + if (!url || url.endsWith('/')) return url + return DB_COLLECTION_ROOT.test(url) ? `${url}/` : url +} + +const getBaseURL = () => { + if (import.meta.env.DEV) return '/api' + return import.meta.env.VITE_API_URL || '/api' +} + +const dbAxios = axios.create({ + baseURL: getBaseURL(), + timeout: 60000, + headers: { 'Content-Type': 'application/json' } +}) + +dbAxios.interceptors.request.use((config: InternalAxiosRequestConfig) => { + if (config.url) { + config.url = normalizeDbUrl(config.url) + } + const store = useUserStore() + const token = store.accessToken || sessionStorage.getItem('accessToken') || '' + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + +dbAxios.interceptors.response.use( + (res) => res.data, + async (err) => { + const status = err.response?.status + const config = err.config as RetryableConfig | undefined + + if (status === 401) { + const userStore = useUserStore() + userStore.logOut() + showToast({ type: 'fail', message: '登录已过期,请重新登录' }) + setTimeout(() => { + window.location.hash = '#/login' + }, 1500) + return Promise.reject(err) + } + + if (config?.showErrorMessage !== false) { + const detail = err.response?.data?.detail + const message = + typeof detail === 'string' + ? detail + : Array.isArray(detail) + ? detail.map((x: { msg?: string }) => x.msg).filter(Boolean).join(';') + : err.response?.data?.message || err.response?.data?.msg + showToast({ type: 'fail', message: message || err.message || '网络异常' }) + } + return Promise.reject(err) + } +) + +function request(config: AxiosRequestConfig) { + return dbAxios.request(config) +} + +const db = { + get(config: AxiosRequestConfig): Promise { + return request({ ...config, method: 'GET' }) + }, + post(config: AxiosRequestConfig): Promise { + return request({ ...config, method: 'POST' }) + }, + put(config: AxiosRequestConfig): Promise { + return request({ ...config, method: 'PUT' }) + }, + patch(config: AxiosRequestConfig): Promise { + return request({ ...config, method: 'PATCH' }) + }, + del(config: AxiosRequestConfig): Promise { + return request({ ...config, method: 'DELETE' }) + } +} + +export default db diff --git a/mobile-h5/src/utils/http/index.ts b/mobile-h5/src/utils/http/index.ts index b909efe9f..dfa3d77f1 100755 --- a/mobile-h5/src/utils/http/index.ts +++ b/mobile-h5/src/utils/http/index.ts @@ -10,93 +10,58 @@ const getBaseURL = () => { } const axiosInstance = axios.create({ - timeout: 15000, + timeout: 30000, baseURL: getBaseURL(), withCredentials: true, - transformRequest: [ - (data) => { - if (data && typeof data === 'object' && !(data instanceof FormData)) { - return JSON.stringify(data) - } - return data - } - ], validateStatus: (status) => status >= 200 && status < 300, headers: { - get: { 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8' }, post: { 'Content-Type': 'application/json;charset=utf-8' } - }, - transformResponse: [ - (data, headers) => { - // 如果 data 是 undefined 或 null,直接返回 - if (data === undefined || data === null) { - return data - } - - const contentType = headers['content-type'] - if (contentType && contentType.includes('application/json')) { - try { - // 如果 data 是字符串,尝试解析 - if (typeof data === 'string') { - return JSON.parse(data) - } - // 如果已经是对象,直接返回 - return data - } catch (error) { - console.error('JSON parse error:', error, 'data:', data) - return data - } - } - return data - } - ] + } }) -// 请求拦截器 axiosInstance.interceptors.request.use( (config: InternalAxiosRequestConfig) => { const userStore = useUserStore() if (userStore.accessToken) { config.headers.Authorization = `Bearer ${userStore.accessToken}` } - - // 对于有 body 的请求(POST, PUT, PATCH),确保 Content-Type 正确 + const method = config.method?.toUpperCase() if (method === 'POST' || method === 'PUT' || method === 'PATCH') { if (config.data && typeof config.data === 'object' && !(config.data instanceof FormData)) { - // 如果 data 是对象,确保 Content-Type 是 application/json config.headers['Content-Type'] = 'application/json;charset=utf-8' } } - + return config }, - (error) => { - return Promise.reject(error) - } + (error) => Promise.reject(error) ) -// 响应拦截器 +function redirectToLogin() { + setTimeout(() => { + window.location.hash = '#/login' + }, 1500) +} + axiosInstance.interceptors.response.use( (response: AxiosResponse) => { - // 如果 response.data 是 undefined,直接返回 - if (!response.data) { + if (response.data === undefined || response.data === null) { return response.data } - - // 如果响应数据没有 code 字段,说明后端直接返回数据,直接返回 + + // 无 code 字段:直接返回(兼容直出 JSON) if (typeof response.data !== 'object' || !('code' in response.data)) { return response.data } - - const { code, message } = response.data - // 成功响应 + const { code, message, data } = response.data + if (code === 200 || code === 0) { - return response.data + // 门户信封:解包 data + return data } - // 401 未授权,清除 token 并跳转登录 if (code === 401) { const userStore = useUserStore() userStore.logOut() @@ -104,13 +69,10 @@ axiosInstance.interceptors.response.use( type: 'fail', message: message || '登录已过期,请重新登录' }) - setTimeout(() => { - window.location.href = '/login' - }, 1500) + redirectToLogin() return Promise.reject(new Error(message || 'Unauthorized')) } - // 其他错误 showToast({ type: 'fail', message: message || '请求失败' @@ -125,16 +87,16 @@ axiosInstance.interceptors.response.use( userStore.logOut() showToast({ type: 'fail', - message: '登录已过期,请重新登录' + message: data?.message || data?.detail || '登录已过期,请重新登录' }) - setTimeout(() => { - window.location.href = '/login' - }, 1500) + redirectToLogin() } else { - showToast({ - type: 'fail', - message: data?.message || `请求失败: ${status}` - }) + const detail = data?.detail + const msg = + typeof detail === 'string' + ? detail + : data?.message || `请求失败: ${status}` + showToast({ type: 'fail', message: msg }) } } else if (error.request) { showToast({ @@ -151,7 +113,6 @@ axiosInstance.interceptors.response.use( } ) -// API 方法封装 const api = { get(config: AxiosRequestConfig): Promise { return axiosInstance.request({ ...config, method: 'GET' }) diff --git a/mobile-h5/src/utils/loginRsa.ts b/mobile-h5/src/utils/loginRsa.ts new file mode 100644 index 000000000..5b3919143 --- /dev/null +++ b/mobile-h5/src/utils/loginRsa.ts @@ -0,0 +1,23 @@ +import JSEncrypt from 'jsencrypt' +import api from '@/utils/http' + +/** + * Web 端登录:拉取 RSA 公钥后加密 { username, password, ts },与后端 PKCS#1 v1.5 解密一致。 + */ +export async function buildWebEncryptedLoginBody(username: string, password: string) { + const { publicKeyPem } = await api.get<{ publicKeyPem: string }>({ + url: '/web/auth/login-rsa-public-key' + }) + const crypt = new JSEncrypt() + crypt.setPublicKey(publicKeyPem) + const payload = JSON.stringify({ + username, + password, + ts: Date.now() + }) + const encryptedLogin = crypt.encrypt(payload) + if (!encryptedLogin) { + throw new Error('登录加密失败,请重试') + } + return { encryptedLogin } +} diff --git a/mobile-h5/src/views/login/index.vue b/mobile-h5/src/views/login/index.vue index e1e0248f6..6f5385192 100644 --- a/mobile-h5/src/views/login/index.vue +++ b/mobile-h5/src/views/login/index.vue @@ -46,8 +46,7 @@ import { reactive, ref } from 'vue' import { useRouter } from 'vue-router' import { showToast } from 'vant' import { useUserStore } from '@/store/modules/user' -import { UserService } from '@/api/usersApi' -import { ApiStatus } from '@/utils/http/status' +import { fetchLogin } from '@/api/authApi' const router = useRouter() const userStore = useUserStore() @@ -70,29 +69,22 @@ const handleSubmit = async () => { loading.value = true try { - const res = await UserService.login(formData.username, formData.password) - if (res.code === ApiStatus.success && res.data) { - userStore.setLoginStatus(true) - // 使用 setToken 方法保存 token - if (res.data.accessToken) { - userStore.setToken(res.data.accessToken, res.data.refreshToken) - } - // 设置用户信息 - if (res.data.user) { - userStore.setUserInfo(res.data.user) - } - - showToast({ - type: 'success', - message: '登录成功' - }) - router.push('/dashboard') - } else { - showToast({ - type: 'fail', - message: res.message || '登录失败' - }) - } + const data = await fetchLogin(formData.username, formData.password) + userStore.setLoginStatus(true) + userStore.setToken(data.token, data.refreshToken) + userStore.setUserInfo({ + id: data.userInfo.id, + username: data.userInfo.username, + name: data.userInfo.realName || data.userInfo.username, + role: data.userInfo.role, + avatar: data.userInfo.avatarUrl || undefined + }) + + showToast({ + type: 'success', + message: '登录成功' + }) + router.push('/dashboard') } catch (error: any) { console.error('Login error:', error) showToast({