api.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. /**
  2. * 聊天 & 认证相关 API,与桌面版接口一致
  3. * 为保证真机 / 模拟器环境可直接访问后端,这里统一使用完整线上域名
  4. */
  5. const BASE_URL = 'https://api.hnyunzhu.com/api/v1'
  6. const TOKEN_KEY = 'token'
  7. export function getToken() {
  8. try {
  9. return uni.getStorageSync(TOKEN_KEY) || ''
  10. } catch (e) {
  11. return ''
  12. }
  13. }
  14. export function setToken(token) {
  15. const v = token || ''
  16. try {
  17. uni.setStorageSync(TOKEN_KEY, v)
  18. } catch (e) {}
  19. if (!v) {
  20. // 与 useWebSocket / chatStore 动态引用,避免 api ↔ composables 循环依赖
  21. import('../composables/useWebSocket.js')
  22. .then((m) => {
  23. if (typeof m.disconnectWebSocket === 'function') m.disconnectWebSocket()
  24. })
  25. .catch(() => {})
  26. import('../store/chat.js')
  27. .then((m) => {
  28. if (m.chatStore && typeof m.chatStore.reset === 'function') m.chatStore.reset()
  29. })
  30. .catch(() => {})
  31. }
  32. }
  33. /**
  34. * 统一 request:带 Authorization,返回 Promise
  35. */
  36. function request(options) {
  37. const token = options && options.token !== undefined ? options.token : getToken()
  38. const url = (options.url && options.url.startsWith('http')) ? options.url : BASE_URL + (options.url || '')
  39. return new Promise((resolve, reject) => {
  40. uni.request({
  41. url,
  42. method: options.method || 'GET',
  43. data: options.data,
  44. header: {
  45. 'Content-Type': 'application/json',
  46. Authorization: token ? `Bearer ${token}` : '',
  47. ...options.header
  48. },
  49. success: (res) => {
  50. if (res.statusCode === 401) {
  51. setToken('')
  52. uni.showToast({ title: '请先登录', icon: 'none' })
  53. reject(new Error('请先登录'))
  54. return
  55. }
  56. if (res.statusCode >= 400) {
  57. const d = res.data
  58. const msg = (d && (d.message || d.msg || d.detail || d.error)) || res.errMsg || `请求失败(${res.statusCode})`
  59. reject(new Error(typeof msg === 'string' ? msg : JSON.stringify(msg)))
  60. return
  61. }
  62. resolve(res.data)
  63. },
  64. fail: (err) => {
  65. uni.showToast({ title: err.errMsg || '网络错误', icon: 'none' })
  66. reject(err)
  67. }
  68. })
  69. })
  70. }
  71. /**
  72. * 获取聊天历史
  73. * GET /messages/history/{otherUserId}?skip=0&limit=50
  74. */
  75. export function getMessages(token, otherUserId, params = {}) {
  76. const skip = params.skip != null ? params.skip : 0
  77. const limit = params.limit != null ? params.limit : 50
  78. const url = `${BASE_URL}/messages/history/${otherUserId}?skip=${skip}&limit=${limit}`
  79. return new Promise((resolve, reject) => {
  80. uni.request({
  81. url,
  82. method: 'GET',
  83. header: {
  84. Authorization: token ? `Bearer ${token}` : ''
  85. },
  86. success: (res) => {
  87. if (res.statusCode === 401) {
  88. setToken('')
  89. reject(new Error('Unauthorized'))
  90. return
  91. }
  92. if (res.statusCode >= 400) {
  93. reject(new Error((res.data && res.data.message) || '请求失败'))
  94. return
  95. }
  96. resolve(res.data)
  97. },
  98. fail: (err) => reject(err)
  99. })
  100. })
  101. }
  102. /**
  103. * 发送消息(对接文档 Message_Integration_Guide.md 3.2)
  104. * POST /messages/ Body: { receiver_id, type, content_type, title?, content?, action_url?, action_text? }
  105. * 用户发私信:receiver_id 为对方用户 ID(数字),type: 'MESSAGE',建议带 title(如「私信」)
  106. * @param {object} [extras] - 可选,USER_NOTIFICATION 等可传 { action_url, action_text }
  107. */
  108. export function sendMessage(token, receiverId, content, contentType = 'TEXT', title, extras) {
  109. const id = receiverId != null ? String(receiverId).trim() : ''
  110. const num = id === '' ? NaN : Number(id)
  111. const defaultTitle = contentType === 'USER_NOTIFICATION' ? '通知' : '私信'
  112. const body = {
  113. receiver_id: (num !== num || !Number.isInteger(num)) ? (id || receiverId) : num,
  114. type: 'MESSAGE',
  115. content_type: contentType,
  116. title: title != null && title !== '' ? String(title) : defaultTitle,
  117. content: content != null ? String(content) : ''
  118. }
  119. if (extras && typeof extras === 'object') {
  120. if (extras.action_url != null && extras.action_url !== '') body.action_url = String(extras.action_url)
  121. if (extras.action_text != null && extras.action_text !== '') body.action_text = String(extras.action_text)
  122. }
  123. return request({
  124. token,
  125. url: '/messages/',
  126. method: 'POST',
  127. data: body
  128. })
  129. }
  130. /**
  131. * 上传文件
  132. * POST /messages/upload FormData: file
  133. * filePath: uni.chooseImage/chooseVideo/chooseFile 返回的 tempFilePath
  134. * 返回 { url, key, filename, content_type, size }
  135. */
  136. export function uploadFile(token, filePath, fileName, onProgress) {
  137. return new Promise((resolve, reject) => {
  138. const url = BASE_URL + '/messages/upload'
  139. const uploadTask = uni.uploadFile({
  140. url,
  141. filePath,
  142. name: 'file',
  143. header: {
  144. Authorization: token ? `Bearer ${token}` : ''
  145. },
  146. success: (res) => {
  147. if (res.statusCode >= 400) {
  148. reject(new Error('上传失败'))
  149. return
  150. }
  151. try {
  152. const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
  153. resolve(data)
  154. } catch (e) {
  155. reject(e)
  156. }
  157. },
  158. fail: reject
  159. })
  160. if (onProgress && typeof uploadTask.onProgressUpdate === 'function') {
  161. uploadTask.onProgressUpdate((e) => {
  162. if (e.totalBytesSent && e.totalBytesExpectedToSend) {
  163. onProgress(e.totalBytesSent / e.totalBytesExpectedToSend)
  164. }
  165. })
  166. }
  167. })
  168. }
  169. /**
  170. * 获取会话列表
  171. * GET /messages/conversations
  172. */
  173. export function getContacts(token) {
  174. return request({
  175. token,
  176. url: '/messages/conversations',
  177. method: 'GET'
  178. })
  179. }
  180. /**
  181. * 未读消息总数(用于底部 Tab 角标)
  182. * GET /messages/unread-count — 响应为数字
  183. */
  184. export function getUnreadCount(token) {
  185. return request({
  186. token,
  187. url: '/messages/unread-count',
  188. method: 'GET'
  189. })
  190. }
  191. /**
  192. * 按会话标记当前用户在该会话内全部已读
  193. * PUT /messages/history/{other_user_id}/read-all
  194. */
  195. export function markHistoryReadAll(token, otherUserId) {
  196. const id = encodeURIComponent(String(otherUserId))
  197. return request({
  198. token,
  199. url: `/messages/history/${id}/read-all`,
  200. method: 'PUT'
  201. })
  202. }
  203. /**
  204. * 全局消息检索(后端 GET /messages/search;移动端搜索页当前用 chatStore 本地检索,可后续切换)
  205. * @param {string} token
  206. * @param {string} q - 关键词
  207. * @param {{ limit?: number }} [params]
  208. * @returns {Promise<object>} 一般为 { items: [...] },具体字段以后端为准
  209. */
  210. export function searchMessages(token, q, params = {}) {
  211. const limit = params.limit != null ? params.limit : 20
  212. const query = `q=${encodeURIComponent(String(q || '').trim())}&limit=${Number(limit) || 20}`
  213. return request({
  214. token,
  215. url: `/messages/search?${query}`,
  216. method: 'GET'
  217. })
  218. }
  219. /**
  220. * 获取通知消息回调链接
  221. * GET /messages/{messageId}/callback-url
  222. */
  223. export function getMessageCallbackUrl(token, messageId) {
  224. return request({
  225. token,
  226. url: `/messages/${messageId}/callback-url`,
  227. method: 'GET'
  228. })
  229. }
  230. /**
  231. * 登录:密码登录
  232. * POST /auth/login/json Body: { mobile, password, remember_me: true }
  233. */
  234. export function login(mobile, password) {
  235. return request({
  236. // 登录不带 token
  237. token: '',
  238. url: '/auth/login/json',
  239. method: 'POST',
  240. data: { mobile, password, remember_me: true }
  241. })
  242. }
  243. /**
  244. * 登录:验证码登录
  245. * POST /auth/sms/login Body: { mobile, code, platform: 'pc' }
  246. * 为了与桌面端保持一致,默认 platform 使用 'pc'
  247. */
  248. export function loginBySms(mobile, code, platform = 'pc') {
  249. return request({
  250. token: '',
  251. url: '/auth/sms/login',
  252. method: 'POST',
  253. data: { mobile, code, platform }
  254. })
  255. }
  256. /**
  257. * 发送短信验证码
  258. * POST /auth/sms/send-code Body: { mobile, platform: 'pc' }
  259. * 桌面端为 { mobile, platform: 'pc' },这里沿用相同约定
  260. */
  261. export function sendSmsCode(mobile, platform = 'pc') {
  262. return request({
  263. token: '',
  264. url: '/auth/sms/send-code',
  265. method: 'POST',
  266. data: { mobile, platform }
  267. })
  268. }
  269. /**
  270. * 获取当前用户信息(桌面端会尝试多个路径,这里也做同样 fallback)
  271. */
  272. export async function getCurrentUserInfo(token) {
  273. const candidates = ['/users/me', '/auth/me', '/user/info', '/auth/user']
  274. let lastErr = null
  275. for (const path of candidates) {
  276. try {
  277. return await request({ token, url: path, method: 'GET' })
  278. } catch (e) {
  279. lastErr = e
  280. }
  281. }
  282. throw lastErr || new Error('getCurrentUserInfo failed')
  283. }
  284. /**
  285. * 将 /users/me 或登录返回的用户对象规范为本地 current_user 结构(头像、姓名、账号名/英文名、企业等)
  286. */
  287. export function normalizeUserPayload(raw) {
  288. if (!raw || typeof raw !== 'object') return null
  289. const o =
  290. raw.user != null && typeof raw.user === 'object'
  291. ? raw.user
  292. : raw.data != null && typeof raw.data === 'object'
  293. ? raw.data
  294. : raw
  295. const id = o.id ?? o.user_id
  296. const accountName =
  297. o.alias ??
  298. o.english_name ??
  299. o.englishName ??
  300. o.username ??
  301. o.account_name ??
  302. o.account ??
  303. ''
  304. const org =
  305. o.org_name || o.orgName || o.organization_name || o.company_name || ''
  306. return {
  307. name: o.name || o.nickname || o.display_name || '',
  308. id: id != null ? String(id) : '',
  309. avatar: o.avatar || o.avatar_url || o.avatarUrl || '',
  310. alias: accountName,
  311. org_name: org,
  312. orgName: org
  313. }
  314. }
  315. /**
  316. * 解析 JWT 获取 userId(与桌面端 getUserIdFromToken 类似)
  317. */
  318. export function getUserIdFromToken(accessToken) {
  319. try {
  320. if (!accessToken) return ''
  321. const parts = String(accessToken).split('.')
  322. if (parts.length < 2) return ''
  323. const payload = parts[1]
  324. // base64url -> base64
  325. const b64 = payload.replace(/-/g, '+').replace(/_/g, '/')
  326. // 补齐 padding
  327. const pad = b64.length % 4 === 0 ? '' : '='.repeat(4 - (b64.length % 4))
  328. const json = decodeURIComponent(
  329. atob(b64 + pad)
  330. .split('')
  331. .map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
  332. .join('')
  333. )
  334. const obj = JSON.parse(json)
  335. return String(obj.user_id ?? obj.userId ?? obj.sub ?? obj.id ?? '')
  336. } catch (e) {
  337. return ''
  338. }
  339. }
  340. /**
  341. * 可选:联系人搜索(用于补全当前用户名)
  342. * 路径以桌面端 api.ts 为准;若后端不同请调整
  343. */
  344. export function searchContacts(token, keyword = '', limit = 100) {
  345. return request({
  346. token,
  347. url: `/contacts/search?keyword=${encodeURIComponent(keyword)}&limit=${limit}`,
  348. method: 'GET'
  349. })
  350. }
  351. /**
  352. * 用户搜索:GET /api/v1/users/search?q=关键词&limit=20
  353. * 搜索字段:手机号、姓名、英文名
  354. */
  355. export function searchUsers(token, q = '', limit = 20) {
  356. return request({
  357. token,
  358. url: `/users/search?q=${encodeURIComponent(q || '')}&limit=${limit}`,
  359. method: 'GET'
  360. })
  361. }
  362. /**
  363. * 用户列表分页检索:GET /api/v1/users/?skip=0&limit=20&keyword=xxx
  364. */
  365. export function listUsers(token, skip = 0, limit = 20, keyword = '') {
  366. return request({
  367. token,
  368. url: `/users/?skip=${skip}&limit=${limit}&keyword=${encodeURIComponent(keyword || '')}`,
  369. method: 'GET'
  370. })
  371. }
  372. /**
  373. * 应用中心(快捷导航)- 获取快捷导航应用列表
  374. * GET /api/v1/simple/me/launchpad-apps
  375. */
  376. export function getLaunchpadApps() {
  377. return request({
  378. url: '/simple/me/launchpad-apps',
  379. method: 'GET'
  380. })
  381. }
  382. /**
  383. * 应用中心(快捷导航)- 点击应用:SSO 登录获取 redirect_url
  384. * POST /api/v1/simple/sso-login
  385. */
  386. export function ssoLogin(appId, username = '', password = '') {
  387. return request({
  388. method: 'POST',
  389. url: '/simple/sso-login',
  390. data: {
  391. app_id: String(appId || ''),
  392. username: username == null ? '' : String(username),
  393. password: password == null ? '' : String(password)
  394. }
  395. })
  396. }
  397. /**
  398. * 与后端约定一致:TEXT / IMAGE / VIDEO / FILE / USER_NOTIFICATION
  399. */
  400. export function normalizeMessageContentType(ct) {
  401. if (ct == null || ct === '') return 'TEXT'
  402. const upper = String(ct).trim().toUpperCase()
  403. if (['TEXT', 'IMAGE', 'VIDEO', 'FILE', 'USER_NOTIFICATION'].includes(upper)) return upper
  404. const lower = String(ct).toLowerCase()
  405. if (lower === 'image') return 'IMAGE'
  406. if (lower === 'video') return 'VIDEO'
  407. if (lower === 'file') return 'FILE'
  408. if (lower === 'text') return 'TEXT'
  409. return 'TEXT'
  410. }
  411. /**
  412. * 根据文件名/类型得到 content_type
  413. */
  414. export function getContentType(file) {
  415. if (!file) return 'FILE'
  416. const name = (file.name || file.path || '').toLowerCase()
  417. const type = (file.type || '').toLowerCase()
  418. if (type.startsWith('image/') || /\.(jpg|jpeg|png|gif|webp|bmp)(\?|$)/i.test(name)) return 'IMAGE'
  419. if (type.startsWith('video/') || /\.(mp4|mov|avi|webm|mkv)(\?|$)/i.test(name)) return 'VIDEO'
  420. return 'FILE'
  421. }