api.js 14 KB

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