Files
x-server-mobile/frontend/src/services/api.ts
T

405 lines
12 KiB
TypeScript
Raw Normal View History

import axios from 'axios'
import { Toast } from 'antd-mobile'
// 声明飞书 JSAPI 类型
declare global {
interface Window {
tt?: any
h5sdk?: {
ready: (callback: () => void) => void
}
}
}
// API 响应类型
export interface ApiResponse<T = any> {
code: number
message?: string
data: T
}
// 分页响应类型
export interface PaginationResponse<T> {
list: T[]
total: number
page: number
page_size: number
}
// 登录响应类型
export interface LoginResponse {
token: string
user: {
id: number
name: string
email?: string
phone?: string
avatar?: string
company_id: number
status: number
}
}
const API_BASE = '/api/v1'
// 标记是否正在刷新 token
let isRefreshing = false
let failedQueue: Array<{
resolve: (value: any) => void
reject: (reason?: any) => void
}> = []
// 处理队列中的请求
const processQueue = (error: any, token: string | null = null) => {
failedQueue.forEach(({ resolve, reject }) => {
if (error) {
reject(error)
} else {
resolve(token)
}
})
failedQueue = []
}
// 飞书免登获取新 token
const refreshFeishuToken = async (): Promise<string> => {
console.log('🔄 尝试飞书免登刷新 token...')
// 检查是否在飞书环境中
const ua = navigator.userAgent
const isInFeishu = ua.includes('Lark') || ua.includes('feishu') || ua.includes('lark')
if (!isInFeishu) {
throw new Error('NOT_IN_FEISHU')
}
// 等待 tt 对象
if (!window.tt) {
// 如果有 h5sdk,等待 ready
if (window.h5sdk && typeof window.h5sdk.ready === 'function') {
await new Promise<void>((resolve) => {
;(window.h5sdk as any).ready(resolve)
})
} else {
// 轮询等待 tt 对象
await new Promise<void>((resolve, reject) => {
let attempts = 0
const maxAttempts = 50
const checkInterval = setInterval(() => {
attempts++
if (window.tt) {
clearInterval(checkInterval)
resolve()
} else if (attempts >= maxAttempts) {
clearInterval(checkInterval)
reject(new Error('TT_OBJECT_TIMEOUT'))
}
}, 100)
})
}
}
// 获取 company 参数(从当前 URL 或 localStorage)
const urlParams = new URLSearchParams(window.location.search)
let companyParam = urlParams.get('company')
if (!companyParam) {
// 尝试从用户信息中获取 company_id
const userStorage = localStorage.getItem('user-storage')
if (userStorage) {
try {
const user = JSON.parse(userStorage)
companyParam = user.state?.user?.company_id?.toString()
} catch (e) {
console.error('解析用户信息失败:', e)
}
}
}
// 获取 appId
let appId = urlParams.get('app_id')
if (!appId && companyParam) {
try {
const response = await fetch(`/api/v1/auth/feishu-config?company=${companyParam}`)
const result = await response.json()
if (result.code === 0 && result.data.app_id) {
appId = result.data.app_id
}
} catch (e) {
console.error('获取 appId 失败:', e)
}
}
if (!appId) {
throw new Error('NO_APP_ID')
}
// 调用飞书免登
return new Promise((resolve, reject) => {
const redirectUri = `${window.location.origin}/login/callback`
window.tt.requestAuthCode({
appId: appId,
redirectUri: redirectUri,
success: async (res: any) => {
try {
// 用新授权码登录
const loginRes = await fetch('/api/v1/auth/feishu-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
auth_code: res.code,
company: companyParam || undefined,
}),
})
const loginResult = await loginRes.json()
if (loginResult.code === 0 && loginResult.data.token) {
const newToken = loginResult.data.token
// 更新本地存储
localStorage.setItem('token', newToken)
// 更新用户 store
if (loginResult.data.user) {
const { useUserStore } = await import('@/stores/user')
const { login } = useUserStore.getState()
login(loginResult.data.user, newToken)
}
console.log('✅ Token 刷新成功')
resolve(newToken)
} else {
reject(new Error(loginResult.message || '登录失败'))
}
} catch (e) {
reject(e)
}
},
fail: (error: any) => {
reject(new Error(error.errMsg || '免登失败'))
},
})
})
}
// 标记是否正在处理登出(防止重复跳转)
let isLoggingOut = false
// 处理 401 未授权错误
const handleUnauthorized = async (): Promise<void> => {
// 如果正在刷新 token,将请求加入队列
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject })
})
}
isRefreshing = true
try {
// 尝试飞书免登刷新 token
const newToken = await refreshFeishuToken()
isRefreshing = false
processQueue(null, newToken)
return
} catch (error: any) {
isRefreshing = false
processQueue(error, null)
// 飞书免登失败,执行原有跳转逻辑
console.error('飞书免登刷新失败:', error.message)
// 如果是非飞书环境或免登失败,跳转到登录页
if (isLoggingOut) return
isLoggingOut = true
const currentUrl = window.location.pathname + window.location.search
console.log('🚨 跳转到登录页, currentUrl:', currentUrl)
sessionStorage.setItem('redirect_after_login', currentUrl)
// 清除本地存储
localStorage.removeItem('token')
localStorage.removeItem('user-storage')
Toast.show({
content: '请先登录',
duration: 1500,
})
setTimeout(() => {
isLoggingOut = false
const redirectUrl = '/login?redirect=' + encodeURIComponent(currentUrl)
window.location.href = redirectUrl
}, 500)
throw error
}
}
// 创建 axios 实例
const api = axios.create({
baseURL: API_BASE,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
})
2026-02-25 18:21:05 +08:00
// 请求拦截器:添加 token 和语言
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
2026-02-25 18:21:05 +08:00
// 添加语言头
const lang = localStorage.getItem('i18nextLng') || 'zh'
config.headers['Accept-Language'] = lang
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器:处理错误
api.interceptors.response.use(
(response) => {
const { data } = response
if (data.code === 0) {
return data.data
}
return Promise.reject(new Error(data.message || '请求失败'))
},
async (error) => {
const originalRequest = error.config
// 处理 401 未授权错误
if (error.response?.status === 401 || error.response?.data?.code === 401) {
// 防止死循环:如果已经重试过一次,不再重试
if (originalRequest._retry) {
console.error('⚠️ 请求重试后仍然 401,可能需要重新登录')
// 清除 token 并跳转登录页
localStorage.removeItem('token')
window.location.href = '/login'
return Promise.reject(new Error('登录已过期'))
}
try {
// 尝试飞书免登刷新 token
await handleUnauthorized()
// token 刷新成功,重试原始请求
originalRequest._retry = true
// 更新请求头中的 token
originalRequest.headers.Authorization = `Bearer ${localStorage.getItem('token')}`
console.log('🔄 使用新 token 重试请求:', originalRequest.url)
return api.request(originalRequest)
} catch (e) {
// handleUnauthorized 内部已经处理了跳转到登录页
return Promise.reject(new Error('登录已过期'))
}
}
// 处理其他错误
const message = error.response?.data?.message || error.message || '网络错误'
return Promise.reject(new Error(message))
}
)
// ========== 认证 API ==========
export const authApi = {
login: (username: string, password: string) =>
api.post<any, LoginResponse>('/auth/login', { username, password }),
// 飞书免登(支持 app_id、tenant_key 或 company)
feishuLogin: (authCode: string, company?: string) =>
api.post<any, LoginResponse>('/auth/feishu-login', {
auth_code: authCode,
company: company,
}),
// 生成二维码登录会话
qrcodeLogin: (company?: string) =>
api.post<any, { session_id: string; qrcode_url: string }>('/auth/qrcode-login', {
company: company,
}),
// 轮询二维码登录状态
qrcodeLoginStatus: (sessionId: string) =>
api.get<any, { status: string; token?: string; user?: any }>('/auth/qrcode-login-status', { params: { session_id: sessionId } }),
}
// ========== 用户 API ==========
export const userApi = {
getInfo: () => api.get<any, any>('/user/info'),
getMenu: () => api.get('/user/menu'),
getCompanies: () => api.get('/user/companies'),
switchCompany: (companyId: number) =>
api.post('/user/switch-company', { company_id: companyId }),
// 获取用户列表
getUsers: (params: { keyword?: string; page?: number; page_size?: number }) =>
api.get<any, PaginationResponse<any>>('/user/list', { params }),
// 修改密码
changePassword: (data: { old_password: string; new_password: string; confirm_password: string }) =>
api.post('/user/change-password', data),
}
// ========== 出差申请 API ==========
export const businessTripApi = {
getList: (params: { type?: string; page?: number; page_size?: number }) =>
api.get<any, PaginationResponse<any>>('/business-trip/list', { params }),
getDetail: (id: number) => api.get<any, any>(`/business-trip/detail/${id}`),
// 获取默认审批人和抄送人
getDefaultApprovers: () => api.get<any, { approvers: Array<{ id: number; name: string; avatar?: string }>; cc_users: Array<{ id: number; name: string; avatar?: string }> }>('/business-trip/default-approvers'),
create: (data: any) => api.post<any, { id: number }>('/business-trip/create', data),
update: (id: number, data: any) =>
api.post(`/business-trip/update/${id}`, data),
submit: (id: number, approvers: number[], ccUsers: number[] = []) =>
api.post(`/business-trip/submit/${id}`, {
approvers,
cc_users: ccUsers,
}),
cancel: (id: number) => api.post(`/business-trip/cancel/${id}`),
deleteDraft: (id: number) => api.delete(`/business-trip/${id}`),
approve: (id: number, action: 'approve' | 'reject', comment?: string) =>
api.post(`/business-trip/approve/${id}`, { action, comment }),
}
// ========== 审批 API ==========
export const approvalApi = {
getList: (params: { type?: string; page?: number; page_size?: number }) => {
console.log('🚀 API Request approval/list:', params)
return api.get<any, PaginationResponse<any>>('/approval/list', { params })
.then((res: any) => {
console.log('✅ API Response approval/list:', {
requestParams: params,
responseData: res,
listCount: res?.list?.length,
listIds: res?.list?.map((item: any) => ({ id: item.id, title: item.title, status: item.status }))
})
return res
})
.catch(err => {
console.error('❌ API Error approval/list:', err)
throw err
})
},
getDetail: (id: number) => api.get<any, any>(`/approval/detail/${id}`),
approve: (id: number, comment?: string) =>
api.post(`/approval/approve/${id}`, { comment }),
reject: (id: number, comment?: string) =>
api.post(`/approval/reject/${id}`, { comment }),
cancel: (id: number) => api.post(`/approval/cancel/${id}`),
}
export default api