/** * 聊天 & 认证相关 API,与桌面版接口一致 * 为保证真机 / 模拟器环境可直接访问后端,这里统一使用完整线上域名 */ const BASE_URL = 'https://api.hnyunzhu.com/api/v1' const TOKEN_KEY = 'token' export function getToken() { try { return uni.getStorageSync(TOKEN_KEY) || '' } catch (e) { return '' } } export function setToken(token) { const v = token || '' try { uni.setStorageSync(TOKEN_KEY, v) } catch (e) {} if (!v) { // 与 useWebSocket / chatStore 动态引用,避免 api ↔ composables 循环依赖 import('../composables/useWebSocket.js') .then((m) => { if (typeof m.disconnectWebSocket === 'function') m.disconnectWebSocket() }) .catch(() => {}) import('../store/chat.js') .then((m) => { if (m.chatStore && typeof m.chatStore.reset === 'function') m.chatStore.reset() }) .catch(() => {}) } } /** * 统一 request:带 Authorization,返回 Promise */ function request(options) { const token = options && options.token !== undefined ? options.token : getToken() const url = (options.url && options.url.startsWith('http')) ? options.url : BASE_URL + (options.url || '') return new Promise((resolve, reject) => { uni.request({ url, method: options.method || 'GET', data: options.data, header: { 'Content-Type': 'application/json', Authorization: token ? `Bearer ${token}` : '', ...options.header }, success: (res) => { if (res.statusCode === 401) { setToken('') uni.showToast({ title: '请先登录', icon: 'none' }) reject(new Error('请先登录')) return } if (res.statusCode >= 400) { const d = res.data const msg = (d && (d.message || d.msg || d.detail || d.error)) || res.errMsg || `请求失败(${res.statusCode})` reject(new Error(typeof msg === 'string' ? msg : JSON.stringify(msg))) return } resolve(res.data) }, fail: (err) => { uni.showToast({ title: err.errMsg || '网络错误', icon: 'none' }) reject(err) } }) }) } /** * 获取聊天历史 * GET /messages/history/{otherUserId}?skip=0&limit=50 */ export function getMessages(token, otherUserId, params = {}) { const skip = params.skip != null ? params.skip : 0 const limit = params.limit != null ? params.limit : 50 const url = `${BASE_URL}/messages/history/${otherUserId}?skip=${skip}&limit=${limit}` return new Promise((resolve, reject) => { uni.request({ url, method: 'GET', header: { Authorization: token ? `Bearer ${token}` : '' }, success: (res) => { if (res.statusCode === 401) { setToken('') reject(new Error('Unauthorized')) return } if (res.statusCode >= 400) { reject(new Error((res.data && res.data.message) || '请求失败')) return } resolve(res.data) }, fail: (err) => reject(err) }) }) } /** * 发送消息(对接文档 Message_Integration_Guide.md 3.2) * POST /messages/ Body: { receiver_id, type, content_type, title?, content?, action_url?, action_text? } * 用户发私信:receiver_id 为对方用户 ID(数字),type: 'MESSAGE',建议带 title(如「私信」) * @param {object} [extras] - 可选,USER_NOTIFICATION 等可传 { action_url, action_text } */ export function sendMessage(token, receiverId, content, contentType = 'TEXT', title, extras) { const id = receiverId != null ? String(receiverId).trim() : '' const num = id === '' ? NaN : Number(id) const defaultTitle = contentType === 'USER_NOTIFICATION' ? '通知' : '私信' const body = { receiver_id: (num !== num || !Number.isInteger(num)) ? (id || receiverId) : num, type: 'MESSAGE', content_type: contentType, title: title != null && title !== '' ? String(title) : defaultTitle, content: content != null ? String(content) : '' } if (extras && typeof extras === 'object') { if (extras.action_url != null && extras.action_url !== '') body.action_url = String(extras.action_url) if (extras.action_text != null && extras.action_text !== '') body.action_text = String(extras.action_text) } return request({ token, url: '/messages/', method: 'POST', data: body }) } /** * 上传文件 * POST /messages/upload FormData: file * filePath: uni.chooseImage/chooseVideo/chooseFile 返回的 tempFilePath * 返回 { url, key, filename, content_type, size } */ export function uploadFile(token, filePath, fileName, onProgress) { return new Promise((resolve, reject) => { const url = BASE_URL + '/messages/upload' const uploadTask = uni.uploadFile({ url, filePath, name: 'file', header: { Authorization: token ? `Bearer ${token}` : '' }, success: (res) => { if (res.statusCode >= 400) { reject(new Error('上传失败')) return } try { const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data resolve(data) } catch (e) { reject(e) } }, fail: reject }) if (onProgress && typeof uploadTask.onProgressUpdate === 'function') { uploadTask.onProgressUpdate((e) => { if (e.totalBytesSent && e.totalBytesExpectedToSend) { onProgress(e.totalBytesSent / e.totalBytesExpectedToSend) } }) } }) } /** * 获取会话列表 * GET /messages/conversations */ export function getContacts(token) { return request({ token, url: '/messages/conversations', method: 'GET' }) } /** * 未读消息总数(用于底部 Tab 角标) * GET /messages/unread-count — 响应为数字 */ export function getUnreadCount(token) { return request({ token, url: '/messages/unread-count', method: 'GET' }) } /** * 按会话标记当前用户在该会话内全部已读 * PUT /messages/history/{other_user_id}/read-all */ export function markHistoryReadAll(token, otherUserId) { const id = encodeURIComponent(String(otherUserId)) return request({ token, url: `/messages/history/${id}/read-all`, method: 'PUT' }) } /** * 全局消息检索(后端 GET /messages/search;移动端搜索页当前用 chatStore 本地检索,可后续切换) * @param {string} token * @param {string} q - 关键词 * @param {{ limit?: number }} [params] * @returns {Promise} 一般为 { items: [...] },具体字段以后端为准 */ export function searchMessages(token, q, params = {}) { const limit = params.limit != null ? params.limit : 20 const query = `q=${encodeURIComponent(String(q || '').trim())}&limit=${Number(limit) || 20}` return request({ token, url: `/messages/search?${query}`, method: 'GET' }) } /** * 获取通知消息回调链接 * GET /messages/{messageId}/callback-url */ export function getMessageCallbackUrl(token, messageId) { return request({ token, url: `/messages/${messageId}/callback-url`, method: 'GET' }) } /** * 登录:密码登录 * POST /auth/login/json Body: { mobile, password, remember_me: true } */ export function login(mobile, password) { return request({ // 登录不带 token token: '', url: '/auth/login/json', method: 'POST', data: { mobile, password, remember_me: true } }) } /** * 登录:验证码登录 * POST /auth/sms/login Body: { mobile, code, platform: 'pc' } * 为了与桌面端保持一致,默认 platform 使用 'pc' */ export function loginBySms(mobile, code, platform = 'pc') { return request({ token: '', url: '/auth/sms/login', method: 'POST', data: { mobile, code, platform } }) } /** * 发送短信验证码 * POST /auth/sms/send-code Body: { mobile, platform: 'pc' } * 桌面端为 { mobile, platform: 'pc' },这里沿用相同约定 */ export function sendSmsCode(mobile, platform = 'pc') { return request({ token: '', url: '/auth/sms/send-code', method: 'POST', data: { mobile, platform } }) } /** * 获取当前用户信息(桌面端会尝试多个路径,这里也做同样 fallback) */ export async function getCurrentUserInfo(token) { const candidates = ['/users/me', '/auth/me', '/user/info', '/auth/user'] let lastErr = null for (const path of candidates) { try { return await request({ token, url: path, method: 'GET' }) } catch (e) { lastErr = e } } throw lastErr || new Error('getCurrentUserInfo failed') } /** * 将 /users/me 或登录返回的用户对象规范为本地 current_user 结构(头像、姓名、账号名/英文名、企业等) */ export function normalizeUserPayload(raw) { if (!raw || typeof raw !== 'object') return null const o = raw.user != null && typeof raw.user === 'object' ? raw.user : raw.data != null && typeof raw.data === 'object' ? raw.data : raw const id = o.id ?? o.user_id const accountName = o.alias ?? o.english_name ?? o.englishName ?? o.username ?? o.account_name ?? o.account ?? '' const org = o.org_name || o.orgName || o.organization_name || o.company_name || '' return { name: o.name || o.nickname || o.display_name || '', id: id != null ? String(id) : '', avatar: o.avatar || o.avatar_url || o.avatarUrl || '', alias: accountName, org_name: org, orgName: org } } /** * 解析 JWT 获取 userId(与桌面端 getUserIdFromToken 类似) */ export function getUserIdFromToken(accessToken) { try { if (!accessToken) return '' const parts = String(accessToken).split('.') if (parts.length < 2) return '' const payload = parts[1] // base64url -> base64 const b64 = payload.replace(/-/g, '+').replace(/_/g, '/') // 补齐 padding const pad = b64.length % 4 === 0 ? '' : '='.repeat(4 - (b64.length % 4)) const json = decodeURIComponent( atob(b64 + pad) .split('') .map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) .join('') ) const obj = JSON.parse(json) return String(obj.user_id ?? obj.userId ?? obj.sub ?? obj.id ?? '') } catch (e) { return '' } } /** * 可选:联系人搜索(用于补全当前用户名) * 路径以桌面端 api.ts 为准;若后端不同请调整 */ export function searchContacts(token, keyword = '', limit = 100) { return request({ token, url: `/contacts/search?keyword=${encodeURIComponent(keyword)}&limit=${limit}`, method: 'GET' }) } /** * 用户搜索:GET /api/v1/users/search?q=关键词&limit=20 * 搜索字段:手机号、姓名、英文名 */ export function searchUsers(token, q = '', limit = 20) { return request({ token, url: `/users/search?q=${encodeURIComponent(q || '')}&limit=${limit}`, method: 'GET' }) } /** * 用户列表分页检索:GET /api/v1/users/?skip=0&limit=20&keyword=xxx */ export function listUsers(token, skip = 0, limit = 20, keyword = '') { return request({ token, url: `/users/?skip=${skip}&limit=${limit}&keyword=${encodeURIComponent(keyword || '')}`, method: 'GET' }) } /** * 应用中心(快捷导航)- 获取快捷导航应用列表 * GET /api/v1/simple/me/launchpad-apps */ export function getLaunchpadApps() { return request({ url: '/simple/me/launchpad-apps', method: 'GET' }) } /** * 应用中心(快捷导航)- 点击应用:SSO 登录获取 redirect_url * POST /api/v1/simple/sso-login */ export function ssoLogin(appId, username = '', password = '') { return request({ method: 'POST', url: '/simple/sso-login', data: { app_id: String(appId || ''), username: username == null ? '' : String(username), password: password == null ? '' : String(password) } }) } /** * 与后端约定一致:TEXT / IMAGE / VIDEO / FILE / USER_NOTIFICATION */ export function normalizeMessageContentType(ct) { if (ct == null || ct === '') return 'TEXT' const upper = String(ct).trim().toUpperCase() if (['TEXT', 'IMAGE', 'VIDEO', 'FILE', 'USER_NOTIFICATION'].includes(upper)) return upper const lower = String(ct).toLowerCase() if (lower === 'image') return 'IMAGE' if (lower === 'video') return 'VIDEO' if (lower === 'file') return 'FILE' if (lower === 'text') return 'TEXT' return 'TEXT' } /** * 根据文件名/类型得到 content_type */ export function getContentType(file) { if (!file) return 'FILE' const name = (file.name || file.path || '').toLowerCase() const type = (file.type || '').toLowerCase() if (type.startsWith('image/') || /\.(jpg|jpeg|png|gif|webp|bmp)(\?|$)/i.test(name)) return 'IMAGE' if (type.startsWith('video/') || /\.(mp4|mov|avi|webm|mkv)(\?|$)/i.test(name)) return 'VIDEO' return 'FILE' }