chore: initial baseline with P0-safety .gitignore
This commit is contained in:
@@ -0,0 +1,400 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端 API 调用层
|
||||
// =============================================================================
|
||||
// 说明:封装会话、消息、审批、下载等与后端 /api/h5/ 交互的 API 方法
|
||||
// 注意:OAuth2 相关 API 已迁移至 @/api/employee.ts
|
||||
// 1. 会话相关(当前会话、发送消息、轮询消息、敲桌子招手)
|
||||
// 2. 审批流程链接
|
||||
// 3. 软件下载列表
|
||||
// =============================================================================
|
||||
|
||||
import apiClient from '@/api'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 类型定义
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** 用户信息(兼容旧接口 /h5/user) */
|
||||
export interface UserInfo {
|
||||
/** 员工 ID */
|
||||
employee_id: string
|
||||
/** 员工姓名 */
|
||||
employee_name: string
|
||||
/** 部门名称 */
|
||||
department: string
|
||||
/** 岗位 */
|
||||
position: string
|
||||
/** 职级 */
|
||||
level: string
|
||||
/** 是否 VIP 员工 */
|
||||
is_vip: boolean
|
||||
/** 头像 URL */
|
||||
avatar_url: string
|
||||
}
|
||||
|
||||
/** 会话信息 */
|
||||
export interface ConversationInfo {
|
||||
/** 会话 ID */
|
||||
conversation_id: string
|
||||
/** 员工 ID */
|
||||
employee_id: string
|
||||
/** 员工姓名(会话发起人) */
|
||||
employee_name: string
|
||||
/** 会话状态:waiting(排队中) / serving(服务中) / closed(已结单) */
|
||||
status: 'waiting' | 'serving' | 'closed'
|
||||
/** 坐席 ID(未接入时为空) */
|
||||
agent_id: string
|
||||
/** 坐席名称(未接入时为空) */
|
||||
agent_name: string
|
||||
/** 创建时间 */
|
||||
created_at: string
|
||||
/** 更新时间 */
|
||||
updated_at: string
|
||||
/** AI 实质性回复计数(满3次可呼叫坐席) */
|
||||
ai_substantive_reply_count?: number
|
||||
/** 是否可以呼叫人工坐席(AI 回复 >= 3 次) */
|
||||
can_call_agent?: boolean
|
||||
/** 被邀请参与会话的人员列表(邀请功能 P0-09~P0-11) */
|
||||
participants?: ParticipantItem[]
|
||||
}
|
||||
|
||||
/** 消息类型 */
|
||||
export type MessageType = 'employee' | 'agent' | 'ai' | 'system'
|
||||
|
||||
/** 消息内容类型(text/image/file/voice/video/location 等) */
|
||||
export type MsgContentType = 'text' | 'image' | 'file' | 'voice' | 'video' | 'location'
|
||||
|
||||
/** 单条消息 */
|
||||
export interface Message {
|
||||
/** 消息 ID */
|
||||
message_id: string
|
||||
/** 会话 ID */
|
||||
conversation_id: string
|
||||
/** 消息类型:employee(员工) / agent(坐席) / ai(AI) / system(系统) */
|
||||
message_type: MessageType
|
||||
/** 消息内容类型:text/image/file 等 */
|
||||
msg_type?: MsgContentType
|
||||
/** 消息内容 */
|
||||
content: string
|
||||
/** 发送者名称 */
|
||||
sender_name: string
|
||||
/** 创建时间 */
|
||||
created_at: string
|
||||
/** 图片/文件 URL */
|
||||
media_url?: string
|
||||
/** 文件名 */
|
||||
file_name?: string
|
||||
/** 文件大小(字节) */
|
||||
file_size?: number
|
||||
/** 扩展数据(额外字段,如 pic_url 等) */
|
||||
extra_data?: Record<string, any>
|
||||
/** 引用回复:被回复的消息 ID */
|
||||
reply_to_id?: string
|
||||
/** 发送状态:sending(发送中) / sent(已发送) / failed(发送失败) - 用于乐观更新UI */
|
||||
status?: 'sending' | 'sent' | 'failed'
|
||||
}
|
||||
|
||||
/** 发送消息请求参数 */
|
||||
export interface SendMessageRequest {
|
||||
/** 消息内容 */
|
||||
content: string
|
||||
/** 消息内容类型:text/image/file(默认 text) */
|
||||
msg_type?: MsgContentType
|
||||
/** 图片/文件 URL(非文本消息必填) */
|
||||
media_url?: string
|
||||
/** 文件名 */
|
||||
file_name?: string
|
||||
/** 文件大小(字节) */
|
||||
file_size?: number
|
||||
}
|
||||
|
||||
/** 轮询消息请求参数 */
|
||||
export interface PollMessagesParams {
|
||||
/** 获取此 ID 之后的消息(增量轮询) */
|
||||
after_message_id?: string
|
||||
}
|
||||
|
||||
/** 招手请求参数 */
|
||||
export interface ShakeRequest {
|
||||
/** 员工 ID */
|
||||
employee_id: string
|
||||
/** 员工姓名 */
|
||||
employee_name: string
|
||||
}
|
||||
|
||||
/** 招手响应数据(与后端 h5.py shake 端点一致) */
|
||||
export interface ShakeResponse {
|
||||
/** 趣味话术内容 */
|
||||
funny_phrase: string
|
||||
/** 会话信息(用于判断是否已接入坐席) */
|
||||
conversation: {
|
||||
/** 会话状态:queued(排队中) / serving(服务中) / closed(已结单) */
|
||||
status: string
|
||||
}
|
||||
}
|
||||
|
||||
/** 审批流程链接 */
|
||||
export interface ApprovalLink {
|
||||
/** 链接 ID */
|
||||
id: string
|
||||
/** 链接标题 */
|
||||
title: string
|
||||
/** 链接地址 */
|
||||
url: string
|
||||
/** 分类名称 */
|
||||
category: string
|
||||
/** 图标(可选) */
|
||||
icon?: string
|
||||
}
|
||||
|
||||
/** 软件下载项 */
|
||||
export interface SoftwareDownload {
|
||||
/** 软件 ID */
|
||||
id: string
|
||||
/** 软件名称 */
|
||||
name: string
|
||||
/** 版本号 */
|
||||
version: string
|
||||
/** 下载地址 */
|
||||
download_url: string
|
||||
/** 分类名称 */
|
||||
category: string
|
||||
/** 支持平台标签(如 "Windows", "macOS") */
|
||||
platforms: string[]
|
||||
/** 图标(可选) */
|
||||
icon?: string
|
||||
}
|
||||
|
||||
/** 发送消息响应(含 AI 自动回复) */
|
||||
export interface SendMessageResponse {
|
||||
/** 用户发送的消息 */
|
||||
user_message: Message
|
||||
/** AI 自动回复消息 */
|
||||
ai_reply: Message
|
||||
/** 是否为引导类回复(打招呼/呼叫人工),不计入实质回复 */
|
||||
is_guidance: boolean
|
||||
/** 当前 AI 实质性回复计数 */
|
||||
ai_reply_count: number
|
||||
/** 是否可以呼叫人工坐席 */
|
||||
can_call_agent: boolean
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 邀请功能类型(P0-09~P0-11)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** 参与者信息(与后端 ParticipantInfo 对应) */
|
||||
export interface ParticipantItem {
|
||||
/** 企微员工UserID 或部门ID */
|
||||
id: string
|
||||
/** 姓名 或 部门名称 */
|
||||
name: string
|
||||
/** 部门(仅员工类型) */
|
||||
department?: string
|
||||
/** 类型 — employee(个人)或 department(部门) */
|
||||
type?: 'employee' | 'department'
|
||||
/** 头像URL(从企微通讯录获取,无头像时为空字符串) */
|
||||
avatar?: string
|
||||
/** 是否已加入(邀请后、点击加入前为 false) */
|
||||
joined?: boolean
|
||||
/** 加入时间(ISO 格式) */
|
||||
joined_at?: string
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 后端字段 → H5前端字段映射
|
||||
// -------------------------------------------------------------------------
|
||||
// 根因:后端 MessageResponse 使用 id / sender_type,
|
||||
// 但 H5 前端 Message 接口使用 message_id / message_type。
|
||||
// 字段名不一致导致 MessageBubble 无法识别消息类型(全 undefined),
|
||||
// Vue :key 也失效(key 全为 undefined),消息无法正常渲染。
|
||||
// 修复:在 API 层统一映射,保持 H5 组件代码不变。
|
||||
|
||||
/**
|
||||
* 将后端 MessageResponse 映射为 H5 前端 Message 格式
|
||||
* - id → message_id
|
||||
* - sender_type → message_type
|
||||
* - 其余字段直接透传
|
||||
*/
|
||||
function mapMessage(raw: any): Message {
|
||||
return {
|
||||
message_id: raw.id || raw.message_id || '',
|
||||
conversation_id: raw.conversation_id || '',
|
||||
message_type: (raw.sender_type || raw.message_type || 'text') as MessageType,
|
||||
msg_type: raw.msg_type,
|
||||
content: raw.content || '',
|
||||
sender_name: raw.sender_name || '',
|
||||
created_at: raw.created_at || '',
|
||||
media_url: raw.media_url,
|
||||
file_name: raw.file_name,
|
||||
file_size: raw.file_size,
|
||||
extra_data: raw.extra_data,
|
||||
reply_to_id: raw.reply_to_id,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量映射后端消息列表为 H5 前端 Message 格式
|
||||
*/
|
||||
function mapMessages(rawList: any[]): Message[] {
|
||||
return (rawList || []).map(mapMessage)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// API 方法
|
||||
// -------------------------------------------------------------------------
|
||||
// 注意:响应拦截器返回 response.data(即 {code, data, message} 包装对象)
|
||||
// API 函数通过 await + response.data 取出业务数据(与原始工作代码一致)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 获取当前用户信息(兼容旧接口)
|
||||
* 返回当前登录员工的详细信息(姓名、部门、岗位、VIP 状态等)
|
||||
* 注意:推荐使用 @/api/employee.ts 中的 getEmployeeInfo() 替代
|
||||
* @returns 用户信息对象
|
||||
*/
|
||||
export async function getUser(): Promise<UserInfo> {
|
||||
const response: any = await apiClient.get('/h5/user')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前会话
|
||||
* 返回当前员工正在进行的会话,如果无活跃会话则返回 null
|
||||
* @returns 会话信息或 null
|
||||
*/
|
||||
export async function getCurrentConversation(): Promise<ConversationInfo | null> {
|
||||
const response: any = await apiClient.get('/h5/conversations/current')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息(含 AI 自动回复)
|
||||
* 在当前会话中发送一条消息,后端自动生成 AI 回复
|
||||
* @param data 消息内容
|
||||
* @returns 包含用户消息和 AI 回复的响应
|
||||
*/
|
||||
export async function sendMessage(data: SendMessageRequest): Promise<SendMessageResponse> {
|
||||
// 图片/文件消息后端处理可能较慢(AI + Dify),增加超时到30秒
|
||||
// 修复截图发送超时Bug:apiClient默认10s不够
|
||||
const response: any = await apiClient.post('/h5/conversations/current/messages', data, {
|
||||
timeout: 30000,
|
||||
})
|
||||
// response = {code:0, data: {user_message:..., ai_reply:...}, message:"success"}
|
||||
// response.data = 业务数据 {user_message:..., ai_reply:..., ...}
|
||||
const raw = response.data
|
||||
// 修复字段映射:后端返回 id/sender_type,H5前端期望 message_id/message_type
|
||||
return {
|
||||
user_message: mapMessage(raw.user_message),
|
||||
ai_reply: raw.ai_reply ? mapMessage(raw.ai_reply) : raw.ai_reply,
|
||||
is_guidance: raw.is_guidance,
|
||||
ai_reply_count: raw.ai_reply_count,
|
||||
can_call_agent: raw.can_call_agent,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询消息
|
||||
* 获取当前会话中指定消息 ID 之后的新消息(增量轮询)
|
||||
* @param params 轮询参数(after_message_id 用于增量获取)
|
||||
* @returns 新消息列表
|
||||
*/
|
||||
export async function pollMessages(params?: PollMessagesParams): Promise<Message[]> {
|
||||
const response: any = await apiClient.get('/h5/conversations/current/messages/poll', { params })
|
||||
// response.data = { items: [...], has_more: bool }
|
||||
const data = response.data
|
||||
const rawItems = data?.items || data || []
|
||||
// 修复字段映射:后端返回 id/sender_type,H5前端期望 message_id/message_type
|
||||
return mapMessages(rawItems)
|
||||
}
|
||||
|
||||
/**
|
||||
* 摇人 — 一键呼叫 IT 坐席
|
||||
* 触发转人工流程,返回趣味话术和会话状态
|
||||
* @param data 摇人请求参数(employee_id 必填,employee_name 可选)
|
||||
* @returns 摇人响应(包含趣味话术和会话信息)
|
||||
*/
|
||||
export async function shake(data: ShakeRequest): Promise<ShakeResponse> {
|
||||
const response: any = await apiClient.post('/h5/conversations/current/shake', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取审批流程链接列表
|
||||
* 返回所有可用的审批流程链接,按分类分组
|
||||
* @returns 审批流程链接数组
|
||||
*/
|
||||
export async function getApprovalLinks(): Promise<ApprovalLink[]> {
|
||||
const response: any = await apiClient.get('/h5/approval-links')
|
||||
// response.data = { items: [...] } 或 [...]
|
||||
const data = response.data
|
||||
return (data?.items || data || []) as ApprovalLink[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取软件下载列表
|
||||
* 返回所有可下载的软件列表,按分类分组
|
||||
* @returns 软件下载数组
|
||||
*/
|
||||
export async function getSoftwareDownloads(): Promise<SoftwareDownload[]> {
|
||||
const response: any = await apiClient.get('/h5/software-downloads')
|
||||
// response.data = { items: [...] } 或 [...]
|
||||
const data = response.data
|
||||
return (data?.items || data || []) as SoftwareDownload[]
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 邀请功能 API(P0-09~P0-11)
|
||||
// -------------------------------------------------------------------------
|
||||
// 注意:H5 专用端点使用 /h5/ 前缀,认证通过 Bearer Token 自动获取 employee_id
|
||||
// 后端 _get_current_employee 依赖会从 Token 中提取 employee_id,无需前端传递
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 被邀请人加入会话
|
||||
* 通过企微卡片链接点击后调用,后端从 Token 认证获取 employee_id
|
||||
*
|
||||
* @param conversationId - 会话ID
|
||||
* @returns 更新后的会话信息
|
||||
*/
|
||||
export async function joinConversation(
|
||||
conversationId: string
|
||||
): Promise<any> {
|
||||
const response: any = await apiClient.post(
|
||||
`/h5/conversations/${conversationId}/join`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 参与者主动退出会话
|
||||
* 后端从 Token 认证获取 employee_id,无需前端传递
|
||||
*
|
||||
* @param conversationId - 会话ID
|
||||
* @returns 更新后的会话信息
|
||||
*/
|
||||
export async function leaveAsParticipant(
|
||||
conversationId: string
|
||||
): Promise<any> {
|
||||
const response: any = await apiClient.post(
|
||||
`/h5/conversations/${conversationId}/leave-participant`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话参与者列表
|
||||
* 返回指定会话的所有被邀请参与者信息
|
||||
*
|
||||
* @param conversationId - 会话ID
|
||||
* @returns 参与者列表
|
||||
*/
|
||||
export async function getParticipants(
|
||||
conversationId: string
|
||||
): Promise<ParticipantItem[]> {
|
||||
const response: any = await apiClient.get(
|
||||
`/h5/conversations/${conversationId}/participants`
|
||||
)
|
||||
const data = response.data
|
||||
return data?.participants || []
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端员工API
|
||||
// =============================================================================
|
||||
// 说明:封装员工认证和身份信息相关的 API 方法
|
||||
// 1. OAuth2 授权回调(code 换取 token + 用户信息)
|
||||
// 2. 获取当前员工详细信息
|
||||
// 3. 获取 OAuth2 授权 URL
|
||||
// 4. Mock 登录(测试阶段,跳过 OAuth2)
|
||||
// =============================================================================
|
||||
|
||||
import apiClient from '@/api'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 类型定义
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** OAuth2 回调请求参数 */
|
||||
export interface OAuthCallbackRequest {
|
||||
/** 企微 OAuth2 授权码 */
|
||||
code: string
|
||||
/** 企微 OAuth2 state 参数(可选) */
|
||||
state?: string
|
||||
}
|
||||
|
||||
/** OAuth2 回调返回数据 */
|
||||
export interface OAuthCallbackResponse {
|
||||
/** 员工 ID */
|
||||
employee_id: string
|
||||
/** 员工姓名 */
|
||||
employee_name: string
|
||||
/** 访问令牌 */
|
||||
token: string
|
||||
/** 部门名称 */
|
||||
department: string
|
||||
/** 岗位 */
|
||||
position: string
|
||||
/** 头像 URL */
|
||||
avatar: string
|
||||
}
|
||||
|
||||
/** 员工详细信息 */
|
||||
export interface EmployeeInfo {
|
||||
/** 员工 ID */
|
||||
employee_id: string
|
||||
/** 员工姓名 */
|
||||
employee_name: string
|
||||
/** 部门名称 */
|
||||
department: string
|
||||
/** 岗位 */
|
||||
position: string
|
||||
/** 手机号 */
|
||||
mobile: string
|
||||
/** 邮箱 */
|
||||
email: string
|
||||
/** 头像 URL */
|
||||
avatar: string
|
||||
/** 是否 VIP 员工 */
|
||||
is_vip: boolean
|
||||
}
|
||||
|
||||
/** OAuth2 授权URL响应 */
|
||||
export interface OAuthAuthorizeResponse {
|
||||
/** 企微OAuth2授权URL */
|
||||
authorize_url: string
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// API 方法
|
||||
// -------------------------------------------------------------------------
|
||||
// 注意:响应拦截器返回 response.data(即 {code, data, message} 包装对象)
|
||||
// API 函数通过 await + response.data 取出业务数据(与原始工作代码一致)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* OAuth2 授权回调
|
||||
* 将企微 OAuth2 授权码传给后端,换取员工身份和访问令牌
|
||||
* 成功后保存 token 和基本信息到 localStorage
|
||||
*
|
||||
* @param data 包含 code 和可选 state 的请求参数
|
||||
* @returns 员工身份信息(employee_id, employee_name, token 等)
|
||||
* @throws 授权失败时抛出异常
|
||||
*/
|
||||
export async function oauthCallback(data: OAuthCallbackRequest): Promise<OAuthCallbackResponse> {
|
||||
const response: any = await apiClient.post('/h5/oauth/callback', data)
|
||||
// response = {code:0, data: {token:"...", ...}, message:"success"}(拦截器返回值)
|
||||
// response.data = 业务数据 {token:"...", employee_id:"...", ...}
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock 登录(测试阶段,跳过 OAuth2)
|
||||
* 直接通过员工 ID 获取真实的 Bearer Token
|
||||
* 仅当后端 MOCK_LOGIN_ENABLED=true 时可用
|
||||
*
|
||||
* @param data 包含 employee_id 和 employee_name 的请求参数
|
||||
* @returns 员工身份信息(employee_id, employee_name, token 等)
|
||||
* @throws 登录失败时抛出异常
|
||||
*/
|
||||
export async function mockLogin(data: { employee_id: string; employee_name?: string }): Promise<OAuthCallbackResponse> {
|
||||
const response: any = await apiClient.post('/h5/mock-login', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前员工详细信息
|
||||
* 返回当前登录员工的详细信息(姓名、部门、岗位、手机号、邮箱等)
|
||||
* 需要携带有效的 Bearer Token
|
||||
* @returns 员工详细信息对象
|
||||
*/
|
||||
export async function getEmployeeInfo(): Promise<EmployeeInfo> {
|
||||
const response: any = await apiClient.get('/h5/me')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取企微 OAuth2 授权 URL
|
||||
* 返回完整的授权链接,前端跳转到该链接进行静默授权
|
||||
* @returns 包含 authorize_url 的响应对象
|
||||
*/
|
||||
export async function getOAuthAuthorizeUrl(): Promise<OAuthAuthorizeResponse> {
|
||||
// 传入 redirect_uri 确保后端构造正确的回调地址(而非默认的 /h5/)
|
||||
const redirectUri = window.location.origin + '/itdesk/'
|
||||
const response: any = await apiClient.get('/h5/oauth/authorize', {
|
||||
params: { redirect_uri: redirectUri },
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端 Axios 实例与拦截器
|
||||
// =============================================================================
|
||||
// 说明:创建 Axios 实例,配置:
|
||||
// 1. 请求基础 URL
|
||||
// 2. 请求拦截器(添加 Bearer Token 认证头)
|
||||
// 3. 响应拦截器(统一错误处理 + 401 自动重新授权)
|
||||
// =============================================================================
|
||||
|
||||
import axios from 'axios'
|
||||
import type { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
|
||||
// Vant 轻提示
|
||||
import { showToast } from 'vant'
|
||||
// Bug #2 修复:从独立回调模块导入,替代 dynamic import('@/stores/employee'),
|
||||
// 打破 api/index.ts → stores/employee.ts 的循环依赖
|
||||
import { triggerAuthExpired } from '@/utils/authCallback'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 创建 Axios 实例
|
||||
// --------------------------------------------------------------------------
|
||||
const apiClient: AxiosInstance = axios.create({
|
||||
// 基础 URL:所有请求会自动加上这个前缀
|
||||
baseURL: '/api',
|
||||
// 请求超时时间(20秒,原10秒)
|
||||
// 原因:图片/文件上传、AI消息处理等场景后端处理需要更多时间
|
||||
// 修复截图发送超时Bug
|
||||
timeout: 20000,
|
||||
// 默认请求头
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 请求拦截器
|
||||
// --------------------------------------------------------------------------
|
||||
// 在每个请求发送前执行,用于添加 Bearer Token 认证头
|
||||
apiClient.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
// 从 localStorage 获取 token,添加到 Authorization 头
|
||||
// 替换旧的 X-Employee-Id 明文头,使用 Bearer Token 进行安全认证
|
||||
const token = localStorage.getItem('h5_token')
|
||||
if (token) {
|
||||
config.headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
// 兼容过渡:如果同时存在 employee_id 且无 token,则仍然发送 X-Employee-Id
|
||||
// 这确保了在 token 过期但 localStorage 中仍有旧数据的降级场景
|
||||
if (!token) {
|
||||
const employeeId = localStorage.getItem('employee_id')
|
||||
if (employeeId) {
|
||||
config.headers['X-Employee-Id'] = employeeId
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
// 请求配置错误时直接返回
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 响应拦截器
|
||||
// --------------------------------------------------------------------------
|
||||
// 在每个响应返回后执行,用于统一处理错误
|
||||
// 特殊处理 401:自动清除 token 并重新走 OAuth2 授权流程
|
||||
apiClient.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
// 从响应中提取业务数据
|
||||
const res = response.data
|
||||
|
||||
// 统一响应格式:{code: 0, data: {}, message: "success"}
|
||||
if (res.code !== 0) {
|
||||
// 特殊处理:业务码 1002 = 未授权(token 过期/无效)
|
||||
// 后端 _get_current_employee 在 Redis 查不到 token 时返回此码
|
||||
if (res.code === 1002) {
|
||||
handleAuthExpired('biz1002')
|
||||
return Promise.reject(new Error(res.message || '未授权'))
|
||||
}
|
||||
|
||||
// 普通业务错误:显示轻提示
|
||||
showToast(res.message || '请求失败')
|
||||
return Promise.reject(new Error(res.message || '请求失败'))
|
||||
}
|
||||
|
||||
// 业务成功:返回 response.data(即 {code, data, message} 包装对象)
|
||||
// API 函数通过 response.data 取出业务数据(与原始工作代码一致)
|
||||
return response.data
|
||||
},
|
||||
async (error) => {
|
||||
// 网络错误或服务器错误
|
||||
let message = '网络异常,请稍后重试'
|
||||
|
||||
if (error.response) {
|
||||
switch (error.response.status) {
|
||||
case 401:
|
||||
// HTTP 401:Token 过期或无效(FastAPI 直接返回的 HTTP 状态码)
|
||||
await handleAuthExpired('http401')
|
||||
break
|
||||
case 403:
|
||||
message = '拒绝访问'
|
||||
break
|
||||
case 404:
|
||||
message = '请求的资源不存在'
|
||||
break
|
||||
case 500:
|
||||
message = '服务器内部错误'
|
||||
break
|
||||
default:
|
||||
message = `请求失败 (${error.response.status})`
|
||||
}
|
||||
} else if (error.code === 'ECONNABORTED') {
|
||||
message = '请求超时,请稍后重试'
|
||||
}
|
||||
|
||||
// 显示轻提示(401 时不显示通用提示,因为会自动跳转授权)
|
||||
if (!error.response || error.response.status !== 401) {
|
||||
showToast(message)
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 辅助:处理认证过期/未授权(复用逻辑)
|
||||
// --------------------------------------------------------------------------
|
||||
// 场景1: HTTP 401(Axios error 拦截器)
|
||||
// 场景2: 业务码 1002 "未授权"(success 拦截器,后端 Redis token 过期时返回)
|
||||
// 防循环机制:通过 localStorage 计数器限制 OAuth2 重定向次数
|
||||
|
||||
/** OAuth2 重定向计数器 key(与 employee store 保持一致) */
|
||||
const OAUTH_REDIRECT_COUNT_KEY = 'oauth_redirect_count'
|
||||
/** 最大允许重定向次数 */
|
||||
const OAUTH_MAX_REDIRECT_COUNT = 3
|
||||
|
||||
// Bug #3 修复:401 去重锁,防止并发请求同时触发多次 OAuth2 重定向
|
||||
// 当第一个 401 处理完成后,后续并发的 401 等待同一个 Promise 即可
|
||||
let _authExpiredPromise: Promise<void> | null = null
|
||||
|
||||
async function handleAuthExpired(source: 'http401' | 'biz1002'): Promise<void> {
|
||||
const label = source === 'http401' ? 'HTTP 401' : '业务码 1002'
|
||||
|
||||
// Bug #3 修复:如果已有 401 正在处理中,复用同一个 Promise,避免多次重定向
|
||||
if (_authExpiredPromise) {
|
||||
console.warn(`[API] ${label} 未授权 — 已有处理进行中,等待完成`)
|
||||
return _authExpiredPromise
|
||||
}
|
||||
|
||||
console.warn(`[API] ${label} 未授权,清除凭证并跳转登录`)
|
||||
|
||||
// 创建处理 Promise 并缓存(去重用)
|
||||
_authExpiredPromise = (async () => {
|
||||
try {
|
||||
// 清除本地 token
|
||||
localStorage.removeItem('h5_token')
|
||||
localStorage.removeItem('employee_id')
|
||||
localStorage.removeItem('employee_name')
|
||||
|
||||
// 判断是 mock 模式还是生产模式
|
||||
const corpId = import.meta.env.VITE_WECOM_CORP_ID || ''
|
||||
if (!corpId) {
|
||||
// Mock 模式:跳转登录页
|
||||
showToast('登录已过期,请重新登录')
|
||||
// 避免重复跳转(当前已经在登录页时不再跳转)
|
||||
if (window.location.pathname !== '/itdesk/login') {
|
||||
window.location.href = '/itdesk/login'
|
||||
}
|
||||
} else {
|
||||
// 防循环检测:超过最大重定向次数时停止跳转
|
||||
const currentCount = parseInt(localStorage.getItem(OAUTH_REDIRECT_COUNT_KEY) || '0', 10)
|
||||
if (currentCount >= OAUTH_MAX_REDIRECT_COUNT) {
|
||||
console.error('[API] OAuth2 重定向次数超限,疑似无限循环,停止重定向')
|
||||
showToast('登录状态异常,请刷新页面重试')
|
||||
return
|
||||
}
|
||||
|
||||
console.warn(`[API] OAuth2 重定向计数: ${currentCount}/${OAUTH_MAX_REDIRECT_COUNT}`)
|
||||
|
||||
// Bug #2 修复:通过回调注册中心触发 store 的 handleUnauthorized,
|
||||
// 替代原来的 dynamic import('@/stores/employee'),消除循环依赖风险
|
||||
const triggered = await triggerAuthExpired()
|
||||
if (!triggered) {
|
||||
console.warn('[API] 认证过期处理器未注册,降级为刷新页面')
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[API] 401 处理失败,刷新页面:', e)
|
||||
window.location.reload()
|
||||
} finally {
|
||||
// Bug #3 修复:处理完成后清除锁,允许未来的 401 重新触发
|
||||
_authExpiredPromise = null
|
||||
}
|
||||
})()
|
||||
|
||||
return _authExpiredPromise
|
||||
}
|
||||
|
||||
// 导出 Axios 实例
|
||||
export default apiClient
|
||||
@@ -0,0 +1,150 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端消息 API
|
||||
// =============================================================================
|
||||
// 说明:封装消息相关的 API 调用
|
||||
// 包括:消息撤回、删除、标记已读、图片上传、文件上传
|
||||
// =============================================================================
|
||||
|
||||
import apiClient from './index'
|
||||
import type { AxiosResponse } from 'axios'
|
||||
import type { Message } from './conversation'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 类型定义
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/** 消息列表响应 */
|
||||
export interface MessageListData {
|
||||
items: Message[]
|
||||
has_more: boolean
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// API 函数
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 撤回消息(2分钟内)
|
||||
*
|
||||
* @param messageId - 消息ID
|
||||
* @returns 撤回结果
|
||||
*/
|
||||
export async function recallMessage(messageId: string): Promise<any> {
|
||||
const response: AxiosResponse = await apiClient.post(
|
||||
`/messages/${messageId}/recall`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除消息
|
||||
*
|
||||
* @param messageId - 消息ID
|
||||
* @returns 删除结果
|
||||
*/
|
||||
export async function deleteMessage(messageId: string): Promise<any> {
|
||||
const response: AxiosResponse = await apiClient.delete(
|
||||
`/messages/${messageId}`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记会话已读
|
||||
*
|
||||
* @param conversationId - 会话ID
|
||||
* @returns 标记结果
|
||||
*/
|
||||
export async function markConversationRead(conversationId: string): Promise<any> {
|
||||
const response: AxiosResponse = await apiClient.post(
|
||||
`/conversations/${conversationId}/mark-read`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询新消息(H5用户端)
|
||||
*
|
||||
* @param afterMessageId - 上次轮询的最后一消息ID
|
||||
* @returns 新消息列表
|
||||
*/
|
||||
export async function pollMessages(afterMessageId?: string): Promise<Message[]> {
|
||||
const params: Record<string, string> = {}
|
||||
if (afterMessageId) {
|
||||
params.after_message_id = afterMessageId
|
||||
}
|
||||
const response: AxiosResponse = await apiClient.get(
|
||||
'/h5/conversations/current/messages/poll',
|
||||
{ params }
|
||||
)
|
||||
const data = response.data.data
|
||||
const items = data?.items || []
|
||||
// 映射后端字段到前端字段
|
||||
return items.map((item: any) => ({
|
||||
message_id: item.id || item.message_id || '',
|
||||
conversation_id: item.conversation_id || '',
|
||||
message_type: item.sender_type || 'text',
|
||||
msg_type: item.msg_type,
|
||||
content: item.content || '',
|
||||
sender_name: item.sender_name || '',
|
||||
created_at: item.created_at || '',
|
||||
media_url: item.media_url,
|
||||
file_name: item.file_name,
|
||||
file_size: item.file_size,
|
||||
extra_data: item.extra_data,
|
||||
reply_to_id: item.reply_to_id,
|
||||
status: item.status,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传图片
|
||||
*
|
||||
* @param file - 图片文件
|
||||
* @returns 上传结果(包含 url, filename, file_size)
|
||||
*/
|
||||
export async function uploadImage(file: File): Promise<{
|
||||
url: string
|
||||
filename: string
|
||||
file_size: number
|
||||
}> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const response: AxiosResponse = await apiClient.post(
|
||||
'/messages/image',
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
}
|
||||
)
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
*
|
||||
* @param file - 文件
|
||||
* @returns 上传结果(包含 url, filename, file_size)
|
||||
*/
|
||||
export async function uploadMessageFile(file: File): Promise<{
|
||||
url: string
|
||||
filename: string
|
||||
file_size: number
|
||||
}> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const response: AxiosResponse = await apiClient.post(
|
||||
'/messages/file',
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
}
|
||||
)
|
||||
return response.data.data
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端排查模板类型定义
|
||||
// =============================================================================
|
||||
// 说明:与坐席端 troubleshooting.ts 共享的类型定义
|
||||
// H5 端暂不直接调用排查模板 API(数据通过 WebSocket 从坐席端推送),
|
||||
// 但需要 FlowchartNode 等类型来渲染交互式排查流程
|
||||
// =============================================================================
|
||||
|
||||
/** 排查步骤路径节点 */
|
||||
export interface PathStep {
|
||||
/** 步骤标题 */
|
||||
label: string
|
||||
/** 步骤状态: done / current / pending */
|
||||
status: 'done' | 'current' | 'pending'
|
||||
}
|
||||
|
||||
/** 决策树递归节点 */
|
||||
export interface FlowchartNode {
|
||||
/** 节点唯一标识 */
|
||||
id: string
|
||||
/** 节点类型: step(步骤/操作)/ decision(判断/问答) */
|
||||
type: 'step' | 'decision'
|
||||
/** 节点标签文字 */
|
||||
label: string
|
||||
/** 节点状态: done / current / pending */
|
||||
status?: 'done' | 'current' | 'pending'
|
||||
/** 子节点列表(step 类型) */
|
||||
children?: FlowchartNode[]
|
||||
/** "是" 分支(decision 类型) */
|
||||
yes_branch?: FlowchartNode
|
||||
/** "否" 分支(decision 类型) */
|
||||
no_branch?: FlowchartNode
|
||||
}
|
||||
|
||||
/** 排查模板摘要(WebSocket 推送时使用) */
|
||||
export interface TroubleshootingTemplateSummary {
|
||||
/** 模板唯一标识 */
|
||||
id: string
|
||||
/** 模板名称 */
|
||||
name: string
|
||||
/** 分类 */
|
||||
category: string
|
||||
/** 排障步骤路径 */
|
||||
path_steps: PathStep[]
|
||||
/** 流程图定义 */
|
||||
flowchart: FlowchartNode
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端文件上传 API
|
||||
// =============================================================================
|
||||
// 说明:封装文件/图片上传接口
|
||||
// 1. 上传文件到后端 /api/upload(multipart/form-data)
|
||||
// 2. 返回文件 URL、文件名、文件大小等信息
|
||||
// =============================================================================
|
||||
|
||||
import apiClient from '@/api'
|
||||
|
||||
/** 上传响应数据 */
|
||||
export interface UploadResponse {
|
||||
/** 文件访问 URL(相对路径,如 /media/2026/06/10/xxx.png) */
|
||||
url: string
|
||||
/** 服务器存储的文件名 */
|
||||
filename: string
|
||||
/** 文件大小(字节) */
|
||||
file_size: number
|
||||
/** 消息类型(image 或 file,根据扩展名判断) */
|
||||
msg_type: 'image' | 'file'
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件到服务器
|
||||
*
|
||||
* 做什么:将文件/图片上传到后端 /api/upload 端点
|
||||
* 流程:
|
||||
* 1. 创建 FormData,将文件添加到 file 字段
|
||||
* 2. 如果传入的是 Blob(如粘贴的图片),自动生成文件名
|
||||
* 3. 发送 multipart/form-data 请求
|
||||
* 4. 返回上传结果(URL + 文件信息)
|
||||
*
|
||||
* @param file - 要上传的文件(File 或 Blob 对象)
|
||||
* @param blobNamePrefix - Blob 文件名前缀(默认 'paste',截图场景传 'screenshot')
|
||||
* @returns 上传响应(含文件 URL、文件名等)
|
||||
*/
|
||||
export async function uploadFile(file: File | Blob, blobNamePrefix: string = 'paste'): Promise<UploadResponse> {
|
||||
// 构建 FormData
|
||||
const formData = new FormData()
|
||||
|
||||
// 如果是 Blob 而非 File,需要生成文件名(File 自带 name 属性)
|
||||
if (file instanceof Blob && !(file instanceof File)) {
|
||||
// 粘贴的图片默认为 PNG 格式,使用传入的前缀区分来源
|
||||
const fileName = `${blobNamePrefix}_${Date.now()}.png`
|
||||
formData.append('file', file, fileName)
|
||||
} else {
|
||||
formData.append('file', file as File)
|
||||
}
|
||||
|
||||
// 发送上传请求(60 秒超时,大文件上传可能较慢)
|
||||
// 注意:必须显式删除 Content-Type,让浏览器自动生成带 boundary 的 multipart/form-data
|
||||
// 原因:apiClient 实例默认设置了 'Content-Type': 'application/json'
|
||||
// 如果不覆盖,Axios 会保留 application/json,后端无法解析 FormData 中的 file 字段
|
||||
const response: any = await apiClient.post('/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': undefined,
|
||||
},
|
||||
timeout: 60000,
|
||||
})
|
||||
|
||||
// 响应拦截器已确保 code === 0
|
||||
// response = {code:0, data: {url:"...",...}, message:"success"}(拦截器返回值)
|
||||
// response.data = 业务数据 {url:"...", filename:"...", ...}
|
||||
return response.data as UploadResponse
|
||||
}
|
||||
Reference in New Issue
Block a user