Преглед изворни кода

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 <cursoragent@cursor.com>
zlwu пре 3 месеци
родитељ
комит
c3f183cfba

+ 39 - 0
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<LoginResult> {
+  const body = await buildWebEncryptedLoginBody(username, password)
+  return api.post<LoginResult>({
+    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 }
+  })
+}

+ 5 - 4
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)

+ 97 - 0
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<T = unknown>(config: AxiosRequestConfig) {
+  return dbAxios.request<any, T>(config)
+}
+
+const db = {
+  get<T = unknown>(config: AxiosRequestConfig): Promise<T> {
+    return request<T>({ ...config, method: 'GET' })
+  },
+  post<T = unknown>(config: AxiosRequestConfig): Promise<T> {
+    return request<T>({ ...config, method: 'POST' })
+  },
+  put<T = unknown>(config: AxiosRequestConfig): Promise<T> {
+    return request<T>({ ...config, method: 'PUT' })
+  },
+  patch<T = unknown>(config: AxiosRequestConfig): Promise<T> {
+    return request<T>({ ...config, method: 'PATCH' })
+  },
+  del<T = unknown>(config: AxiosRequestConfig): Promise<T> {
+    return request<T>({ ...config, method: 'DELETE' })
+  }
+}
+
+export default db

+ 27 - 66
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<T = any>(config: AxiosRequestConfig): Promise<T> {
     return axiosInstance.request({ ...config, method: 'GET' })

+ 23 - 0
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 }
+}

+ 17 - 25
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({