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:
1 parent
a4b13fa2bc
commit
c3f183cfba
6 files changed
+208
-95
No files matched your search
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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' })
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user