| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546 |
- /**
- * 聊天 & 认证相关 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 ''
- }
- }
- /** 后端 JWT 校验失败返回 403(无 401);401/403 均视为需重新登录 */
- function isAuthFailureStatus(statusCode) {
- return statusCode === 401 || statusCode === 403
- }
- /** 未授权 / 凭证无效:清 token 并回登录页(网络错误勿调用) */
- function clearSessionAndRedirectToLogin(toastTitle = '请先登录') {
- setToken('')
- uni.showToast({ title: toastTitle, icon: 'none' })
- // 让 Toast 有机会展示再切页
- setTimeout(() => {
- uni.reLaunch({ url: '/pages/login/index' })
- }, 80)
- }
- 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 (isAuthFailureStatus(res.statusCode)) {
- const skipRedirect = options && options.skipSessionRedirectOnAuthFailure === true
- if (!skipRedirect) {
- clearSessionAndRedirectToLogin()
- }
- const err = new Error('请先登录')
- err.authFailure = true
- reject(err)
- 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) => {
- if (!(options && options.skipRequestFailToast)) {
- 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 (isAuthFailureStatus(res.statusCode)) {
- clearSessionAndRedirectToLogin()
- reject(new Error('请先登录'))
- 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 (isAuthFailureStatus(res.statusCode)) {
- clearSessionAndRedirectToLogin()
- reject(new Error('请先登录'))
- return
- }
- 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<object>} 一般为 { 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, requestOptions = {}) {
- 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', ...requestOptions })
- } catch (e) {
- if (e && e.authFailure) throw e
- lastErr = e
- }
- }
- throw lastErr || new Error('getCurrentUserInfo failed')
- }
- /**
- * 个人身份二维码密文(展示端)
- * GET /identity-qr/ 返回 { token, expires_at },二维码内容即为 token,客户端勿解密
- */
- export function getIdentityQrPayload(token) {
- return request({
- token,
- url: '/identity-qr/',
- method: 'GET'
- })
- }
- /**
- * 将 /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 ||
- o.displayName ||
- o.real_name ||
- o.realName ||
- o.full_name ||
- o.fullName ||
- '',
- 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)
- }
- })
- }
- /**
- * 图形验证码(开放接口)
- * GET /utils/captcha → { captcha_id, image, expire_seconds }
- */
- export function getCaptcha() {
- return request({
- token: '',
- url: '/utils/captcha',
- method: 'GET'
- })
- }
- /**
- * 开放:校验图形码后发送短信(忘记密码)
- * POST /open/sms/send
- */
- export function sendOpenSms(mobile, captcha_id, captcha_code) {
- return request({
- token: '',
- url: '/open/sms/send',
- method: 'POST',
- data: { mobile, captcha_id, captcha_code }
- })
- }
- /**
- * 开放:短信校验后重置密码
- * POST /open/pwd/reset
- */
- export function resetPasswordOpen(mobile, sms_code, new_password) {
- return request({
- token: '',
- url: '/open/pwd/reset',
- method: 'POST',
- data: { mobile, sms_code, new_password }
- })
- }
- /**
- * 已登录修改密码:旧密码方式(需 Bearer)
- * POST /simple/me/change-password
- * 移动端「修改密码」页已改为短信验证码 + /open/pwd/reset,不再调用本方法
- */
- export function changePasswordLoggedIn(old_password, new_password) {
- return request({
- url: '/simple/me/change-password',
- method: 'POST',
- data: { old_password, new_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'
- }
|