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>
This commit is contained in:
zlwuandCursor committed 2026-08-03 22:33:46 +08:00
1 parent a4b13fa2bc
commit c3f183cfba
6 files changed
+208 -95

No files matched your search

+39
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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({