import { IM_WS, livekitPublicUrl, callRoomOf } from './config.js' import * as api from './api.js' import { imClient, ImTypeu } from './im.js' import { canMenu, visibleOf } from './perms.js' import { PERSONAL_APPS, ERP_MODULES } from './catalog.js' import { asList, convType, dmId, fmtListTime } from './format.js' const KEY_TOKEN = 'fysx.token' const KEY_PHONE = 'fysx.phone' const KEY_PWD = 'fysx.lastPwd' function personFrom(raw, online = false) { const name = String((raw && (raw.realName || raw.name || raw.username)) || '同事') const photoId = (raw && (raw.idPhotoFileId || raw.idPhoto)) || '' return { id: String((raw && raw.id) || ''), name, username: String((raw && (raw.username || raw.phone)) || ''), avatar: api.photoUrl(raw), idPhotoFileId: photoId ? String(photoId) : '', title: String((raw && (raw.role || raw.title)) || '员工'), department: String((raw && (raw.dept || raw.department)) || ''), departmentId: String((raw && (raw.deptId || raw.departmentId)) || ''), phone: String((raw && (raw.phone || raw.username)) || ''), employeeStatus: String((raw && (raw.employeeStatus || raw.statusLabel)) || ''), jobId: String((raw && (raw.jobNo || raw.jobId || raw.empNo || raw.employeeNo)) || ''), isSuper: !!(raw && (raw.isSuper === true || raw.superAdmin === true)), online } } function kindFromTypeu(typeu, fallback = 'text') { if (typeu === ImTypeu.image) return 'image' if (typeu === ImTypeu.file) return 'file' if (typeu === ImTypeu.card) return 'card' if (typeu === ImTypeu.recall) return 'recall' return fallback || 'text' } export function previewOf(msg) { if (!msg) return '' if (msg.recalled) return '[撤回]' if (msg.kind === 'image') return '[图片]' if (msg.kind === 'voice') return msg.text || '[语音]' if (msg.kind === 'file') return `[文件] ${msg.fileName || ''}`.trim() if (msg.kind === 'card') return msg.title || msg.text || '[卡片]' return String(msg.text || '[消息]') } function mapMsg(raw, meId, typeu) { const p = raw && raw.payload && typeof raw.payload === 'object' ? raw.payload : {} const from = String((raw && (raw.from || raw.fromId || raw.senderId)) || p.from || '') const kind = kindFromTypeu(typeu, String((raw && (raw.kind || raw.type)) || p.kind || 'text')) const recalled = kind === 'recall' || !!(raw && raw.recalled) const text = String((raw && (raw.text || raw.content || raw.body)) || p.text || p.content || '') const name = String((raw && (raw.name || raw.fileName)) || p.name || '') const src = (raw && (raw.src || raw.url || raw.fileUrl)) || p.src || p.url || '' const fid = (raw && (raw.fileId || raw.file_id)) || p.fileId || '' return { id: String((raw && (raw.id || raw.fp || raw.msgId)) || ''), fromId: from, text: recalled ? (from === meId ? '你撤回了一条消息' : '对方撤回了一条消息') : text, kind: recalled ? 'text' : kind, time: Number((raw && (raw.time || raw.sm || raw.createdAt)) || Date.now()) || Date.now(), mine: from === meId, recalled, fileName: name, fileUrl: api.absUrl(src), fileId: String(fid || ''), title: String((raw && (raw.title || raw.summary)) || p.title || ''), no: String((raw && (raw.no || raw.requestNo)) || p.no || ''), taskId: String((raw && (raw.taskId || raw.workId)) || p.taskId || ''), href: String((raw && raw.href) || p.href || ''), duration: Number((raw && raw.duration) || p.duration) || 0, quote: p.quote || raw.quote || null, read: !!(raw && (raw.read === true || raw.readAt || raw.isRead)), readReceipt: (raw && raw.readReceipt) || p.readReceipt || null } } function toConv(s, me, people) { const type = convType(s) const memberIds = ((s.memberIds || s.members || [])).map((e) => String(e)) let name = String(s.title || s.name || '会话') let avatar = api.absUrl(s.avatar || '') const others = memberIds.filter((id) => id !== (me && me.id)) let peer = String(s.peerId || (others[0] || '')) if (!peer || peer === (me && me.id)) { const sid = String(s.id || '') const dm = sid.match(/^dm:([^:]+):(.+)$/) if (dm) peer = dm[1] === String(me && me.id) ? dm[2] : dm[1] } if (type === 'direct' && people[peer]) { name = people[peer].name avatar = people[peer].avatar } else if (type === 'work') { name = '工作通知' } else if (type === 'files') { name = '文件传输助手' } return { id: String(s.id), type, name, avatar, peerId: peer, memberIds, lastMessage: String(s.preview || s.lastMessage || ''), lastTime: fmtListTime(s.time || s.updatedAt), unread: Number(s.unread || s.unreadCount || 0) || 0, pinned: s.pinned === true || s.isPinned === true, muted: s.muted === true || s.isMuted === true, messages: [] } } export const store = { phase: 'boot', token: '', lastPassword: '', me: null, people: {}, depts: [], conversations: [], menus: {}, overview: {}, announcements: [], shortcuts: [], pendingItems: [], todos: [], attendance: [], attendRules: { workStart: '09:00', workEnd: '18:00', locations: [] }, todoCount: 0, liveStatus: 'offline', call: null, pendingForward: null, activeSid: '', _photoAt: 0, smsRequired: false, smsPaused: false, fixedSms: '', error: '', busy: false, _imBound: false, listeners: [], on(fn) { const wrap = () => { try { fn() } catch (_) {} } this.listeners.push(wrap) return () => { this.listeners = this.listeners.filter((x) => x !== wrap) } }, emit() { this.listeners.slice().forEach((fn) => { try { fn() } catch (_) {} }) }, can(menuId, action = 'view') { return canMenu(this.menus, menuId, action, !!(this.me && this.me.isSuper)) }, personalApps() { return visibleOf(PERSONAL_APPS, this.menus, !!(this.me && this.me.isSuper)) }, erpModules() { return visibleOf(ERP_MODULES, this.menus, !!(this.me && this.me.isSuper)) }, person(id) { if (this.people[id]) return this.people[id] return Object.values(this.people).find((p) => p.username === id || p.phone === id) || null }, findConv(id) { return this.conversations.find((c) => c.id === id) || null }, unreadTotal() { return this.conversations.reduce((s, c) => s + (c.unread || 0), 0) }, async boot() { try { const opt = await api.loginOptions() this.smsRequired = opt && opt.smsLogin === true this.smsPaused = opt && opt.notifyPaused === true if (opt && opt.fixedLoginCode != null) this.fixedSms = String(opt.fixedLoginCode) } catch (_) { this.smsRequired = false } const saved = uni.getStorageSync(KEY_TOKEN) if (!saved) { this.phase = 'login' this.emit() return } try { await this.enter(saved) } catch (_) { uni.removeStorageSync(KEY_TOKEN) this.phase = 'login' this.emit() } }, async login({ phone, password, sms }) { this.busy = true this.error = '' this.emit() try { const data = await api.login({ username: String(phone || '').trim(), password, ...(String(sms || '').trim() ? { smsCode: String(sms).trim() } : {}) }) const tk = String((data && data.token) || '') const user = (data && data.user) || {} if (!tk) throw new Error('登录未返回凭证') this.lastPassword = password uni.setStorageSync(KEY_TOKEN, tk) uni.setStorageSync(KEY_PHONE, String(phone || '').trim()) uni.setStorageSync(KEY_PWD, password) if (user.forcePasswordChange === true) { this.token = tk this.me = personFrom(user) this.phase = 'forcePassword' return } await this.enter(tk) } catch (e) { this.error = e.message || String(e) this.phase = 'login' } finally { this.busy = false this.emit() } }, async changePassword(next, confirm) { this.busy = true this.error = '' this.emit() try { await api.changePassword({ oldPassword: this.lastPassword || uni.getStorageSync(KEY_PWD), newPassword: next, confirmPassword: confirm }, this.token) await this.enter(this.token) } catch (e) { this.error = e.message || String(e) } finally { this.busy = false this.emit() } }, async enter(tk) { this.token = tk const boot = await api.bootstrap(tk) this.me = personFrom(boot.me || {}) if (!this.me.id && boot.me) this.me.id = String(boot.me.userId || boot.me.username || '') const online = new Set((boot.online || []).map((e) => String(e))) this.people = {} ;(boot.people || []).forEach((row) => { if (!row) return const p = personFrom(row, online.has(String(row.id))) this.people[p.id] = p }) this.depts = boot.depts || [] this.todoCount = Number(boot.todoCount || 0) || 0 this.conversations = (boot.sessions || []).map((s) => toConv(s, this.me, this.people)) this._sort() if (!this._imBound) { this._imBound = true imClient.on('status', (s) => { this.liveStatus = String(s) this.emit() }) imClient.on('kick', () => this.logout()) imClient.on('data', (pack) => this._onPacket(pack)) } imClient.connect({ userId: this.me.id, token: tk, url: IM_WS }) await this.refreshPerms() this.phase = 'app' this.refreshPeoplePhotos() this.refreshWorkbench() this.refreshAttendance() this.emit() }, async refreshPerms() { try { const raw = await api.oaGet('/auth/my-perms', this.token) this.menus = (raw && raw.menus) || {} } catch (_) { this.menus = {} } }, /** * IM bootstrap 的 people 通常没有证件照票。 * 证件照在 OA:GET /api/org/users → avatar=/api/org/photo/{id}?ticket= */ async refreshPeoplePhotos() { if (this._photoAt && Date.now() - this._photoAt < 60000) return this._photoAt = Date.now() try { const rows = asList(await api.oaGet('/org/users', this.token)) const online = new Set( Object.values(this.people).filter((p) => p.online).map((p) => p.id) ) rows.forEach((row) => { if (!row) return const next = personFrom(row, online.has(String(row.id))) if (!next.id) return const prev = this.people[next.id] this.people[next.id] = prev ? { ...prev, ...next, online: prev.online || next.online, avatar: next.avatar || prev.avatar } : next }) try { const meRaw = await api.oaGet('/auth/me', this.token) if (meRaw && typeof meRaw === 'object') { const mine = personFrom(meRaw, true) if (this.me) { this.me = { ...this.me, ...mine, id: this.me.id || mine.id, avatar: mine.avatar || this.me.avatar } } else { this.me = mine } if (this.me.id) { this.people[this.me.id] = { ...(this.people[this.me.id] || {}), ...this.me, online: true } } } } catch (_) {} this.conversations.forEach((c) => { if (c.type !== 'direct' || !c.peerId) return const p = this.people[c.peerId] if (!p) return if (p.avatar) c.avatar = p.avatar if (p.name) c.name = p.name }) this.emit() } catch (_) { this._photoAt = 0 } }, _sort() { this.conversations.sort((a, b) => { if (a.pinned !== b.pinned) return a.pinned ? -1 : 1 return String(b.lastTime).localeCompare(String(a.lastTime)) }) }, _onPacket(pack) { if (!pack) return const typeu = pack.typeu const data = pack.data && typeof pack.data === 'object' ? pack.data : {} if (typeu === ImTypeu.recall) { const conv = this.findConv(String(data.sessionId || '')) if (!conv) return conv.messages.forEach((m) => { if (m.id === String(data.msgId || '')) { m.recalled = true m.text = '对方撤回了一条消息' } }) this.emit() return } if (typeu === ImTypeu.call) { this._onCallSignal(String(pack.from || data.from || ''), data) return } if (typeu === ImTypeu.read) { const sid = String(data.sessionId || pack.to || '') const conv = this.findConv(sid) || this.findConv(this.dmIdOf(pack.from)) if (conv) { ;(conv.messages || []).forEach((m) => { if (m.mine && !m.recalled) m.read = true }) } this.emit() return } if (typeu === ImTypeu.group) { this.emit() return } const sid = String(data.sessionId || pack.to || '') let conv = this.findConv(sid) if (!conv && sid) { conv = { id: sid, type: sid.startsWith('g:') ? 'group' : 'direct', name: '会话', messages: [], unread: 0, lastMessage: '', lastTime: '', pinned: false, muted: false, avatar: '', peerId: '', memberIds: [] } this.conversations.unshift(conv) } if (!conv) return const id = String(pack.fp || data.msgId || Date.now()) if (conv.messages.some((m) => m.id && m.id === id)) { this.emit() return } const msg = mapMsg({ ...data, from: pack.from || data.from, id, fp: pack.fp, time: pack.sm || Date.now() }, this.me && this.me.id, pack.typeu) if (msg.mine) { const dup = conv.messages.find((m) => m.mine && !m.recalled && m.kind === msg.kind && m.text === msg.text && Math.abs(Number(m.time) - msg.time) < 4000) if (dup) { if (!dup.id) dup.id = msg.id this.emit() return } } conv.messages.push(msg) conv.lastMessage = previewOf(msg) conv.lastTime = fmtListTime(msg.time) const viewing = this.activeSid && this.activeSid === conv.id if (!msg.mine) { if (viewing) { conv.unread = 0 this.sendRead(conv) } else { conv.unread += 1 } } this._sort() this.emit() }, dmIdOf(peerId) { if (!this.me || !peerId) return '' return dmId(this.me.id, peerId) }, sendRead(conv) { if (!conv || conv.type !== 'direct' || !this.me) return const peer = this.sendTarget(conv) if (!peer || peer === this.me.id) return imClient.sendData({ to: peer, typeu: ImTypeu.read, qos: false, payload: { sessionId: conv.id } }) try { api.markRead(conv.id, this.token) } catch (_) {} }, setActiveChat(sid) { this.activeSid = sid ? String(sid) : '' }, async openChat(conv) { if (!conv) return this.activeSid = conv.id try { const rows = await api.messages(conv.id, this.token) const incoming = asList(rows && rows.messages ? rows.messages : rows).map((m) => mapMsg(m, this.me && this.me.id, m.typeu)) const seen = new Set(incoming.map((m) => m.id).filter(Boolean)) const extras = (conv.messages || []).filter((m) => m.mine && m.id && !seen.has(m.id) && Date.now() - Number(m.time) < 120000) conv.messages = incoming.concat(extras) conv.unread = 0 await api.markRead(conv.id, this.token) if (conv.type === 'direct') this.sendRead(conv) } catch (_) {} this.emit() }, openDirect(peer) { const id = dmId(this.me.id, peer.id) let conv = this.findConv(id) if (!conv) { conv = { id, type: 'direct', name: peer.name, avatar: peer.avatar, peerId: peer.id, messages: [], unread: 0, lastMessage: '', lastTime: '', pinned: false, muted: false, memberIds: [this.me.id, peer.id] } this.conversations.unshift(conv) } return conv }, sendTarget(conv) { if (!conv) return '' if (conv.type === 'group' || conv.type === 'work' || conv.type === 'files') return String(conv.id) const me = this.me ? String(this.me.id) : '' let peer = String(conv.peerId || '') if (!peer || peer === me || peer.startsWith('dm:') || peer.startsWith('g:')) { const id = String(conv.id || '') const m = id.match(/^dm:([^:]+):(.+)$/) if (m) peer = m[1] === me ? m[2] : m[1] } if ((!peer || peer === me) && conv.memberIds && conv.memberIds.length) { peer = conv.memberIds.map(String).find((id) => id && id !== me) || peer } return String(peer || conv.id || '') }, sendText(conv, text, extra) { const trimmed = String(text || '').trim() if (!trimmed || !this.me) return const quote = extra && extra.quote ? { msgId: extra.quote.id || extra.quote.msgId || '', fromId: extra.quote.fromId || '', name: extra.quote.name || '', text: extra.quote.text || previewOf(extra.quote) } : null const to = this.sendTarget(conv) const fp = imClient.sendData({ to, typeu: ImTypeu.text, payload: { sessionId: conv.id, kind: 'text', text: trimmed, from: this.me.id, quote } }) conv.messages.push({ id: fp, fromId: this.me.id, text: trimmed, kind: 'text', time: Date.now(), mine: true, recalled: false, quote, read: false }) conv.lastMessage = trimmed conv.lastTime = fmtListTime(Date.now()) this._sort() this.emit() }, sendMedia(conv, file) { if (!conv || !this.me || !file) return const name = String(file.name || file.fileName || '附件') const type = String(file.contentType || '') const isImg = type.startsWith('image/') || /\.(png|jpe?g|gif|webp|bmp|heic)$/i.test(name) const kind = isImg ? 'image' : 'file' const src = file.url || (file.id ? `/api/files/${file.id}` : '') const fp = imClient.sendData({ to: this.sendTarget(conv), typeu: isImg ? ImTypeu.image : ImTypeu.file, payload: { sessionId: conv.id, kind, text: isImg ? '[图片]' : name, src, name, fileId: file.id || '', from: this.me.id } }) conv.messages.push({ id: fp, fromId: this.me.id, text: isImg ? '[图片]' : name, kind, time: Date.now(), mine: true, recalled: false, read: false, fileName: name, fileUrl: file.url || api.absUrl(src), fileId: String(file.id || '') }) conv.lastMessage = isImg ? '[图片]' : `[文件] ${name}` conv.lastTime = fmtListTime(Date.now()) this._sort() this.emit() }, sendVoice(conv, file) { if (!conv || !this.me || !file) return const sec = Math.max(1, Number(file.duration) || 1) const src = file.url || (file.id ? `/api/files/${file.id}` : '') const fp = imClient.sendData({ to: this.sendTarget(conv), typeu: ImTypeu.file, payload: { sessionId: conv.id, kind: 'voice', text: `[语音] ${sec}″`, src, name: file.name || 'voice.mp3', fileId: file.id || '', duration: sec, from: this.me.id } }) conv.messages.push({ id: fp, fromId: this.me.id, text: `[语音] ${sec}″`, kind: 'voice', time: Date.now(), mine: true, recalled: false, read: false, fileName: file.name || 'voice.mp3', fileUrl: file.url || api.absUrl(src), fileId: String(file.id || ''), duration: sec }) conv.lastMessage = `[语音] ${sec}″` conv.lastTime = fmtListTime(Date.now()) this._sort() this.emit() }, _openCallPage() { const pages = getCurrentPages() const cur = pages[pages.length - 1] const route = cur && (cur.route || cur.$page && cur.$page.fullPath) || '' if (String(route).indexOf('pages/chat/call') >= 0) { this.emit() return } uni.navigateTo({ url: '/pages/chat/call' }) }, _onCallSignal(from, data) { const action = String((data && data.action) || '') if (action === 'invite') { this.call = { direction: 'in', peerId: String(from), room: data.room || '', url: livekitPublicUrl(data.url), token: '', video: data.video !== false, status: 'ringing' } this._openCallPage() this.emit() return } if (action === 'accept' && this.call) { this.call.status = 'active' this.emit() return } if (action === 'reject' || action === 'hangup') { this.call = null this.emit() } }, async startCall(peerId, { video = true } = {}) { const pid = String(peerId || '') if (!pid || !this.me) throw new Error('无法发起通话') const room = callRoomOf(this.me.id, pid) const tk = await api.livekitToken({ peerId: pid, room }, this.token) this.call = { direction: 'out', peerId: pid, room: (tk && tk.room) || room, url: livekitPublicUrl((tk && tk.url) || ''), token: (tk && tk.token) || '', video, status: 'calling' } imClient.sendData({ to: pid, typeu: ImTypeu.call, payload: { action: 'invite', room: this.call.room, url: this.call.url, video } }) this._openCallPage() this.emit() return this.call }, async acceptCall() { if (!this.call || !this.me) return const tk = await api.livekitToken({ peerId: this.call.peerId, room: this.call.room }, this.token) this.call.url = livekitPublicUrl((tk && tk.url) || this.call.url) this.call.token = (tk && tk.token) || '' this.call.status = 'active' imClient.sendData({ to: this.call.peerId, typeu: ImTypeu.call, payload: { action: 'accept', room: this.call.room } }) this.emit() }, endCall(notify = true) { const c = this.call this.call = null if (notify && c && c.peerId) { imClient.sendData({ to: c.peerId, typeu: ImTypeu.call, qos: false, payload: { action: 'hangup', room: c.room } }) } this.emit() }, recall(conv, msg) { if (!conv || !msg || !msg.mine || msg.recalled) return imClient.sendData({ to: this.sendTarget(conv), typeu: ImTypeu.recall, qos: false, payload: { sessionId: conv.id, kind: 'recall', msgId: msg.id } }) try { api.recall({ sessionId: conv.id, msgId: msg.id }, this.token) } catch (_) {} msg.recalled = true msg.text = '你撤回了一条消息' conv.lastMessage = previewOf(msg) this.emit() }, removeMessage(conv, msg) { if (!conv || !msg) return conv.messages = (conv.messages || []).filter((m) => m.id !== msg.id) const last = conv.messages[conv.messages.length - 1] conv.lastMessage = last ? previewOf(last) : '' conv.lastTime = last ? fmtListTime(last.time) : conv.lastTime this.emit() }, forwardTo(dest, msg) { if (!dest || !msg || msg.recalled) return if (msg.kind === 'image' || msg.kind === 'file') { this.sendMedia(dest, { id: msg.fileId, name: msg.fileName || (msg.kind === 'image' ? '图片' : '文件'), url: msg.fileUrl, contentType: msg.kind === 'image' ? 'image/jpeg' : '' }) return } if (msg.kind === 'voice') { this.sendVoice(dest, { id: msg.fileId, name: msg.fileName || 'voice.mp3', url: msg.fileUrl, duration: msg.duration }) return } this.sendText(dest, msg.text || msg.title || '') }, forwardMany(dest, msgs, merge) { const rows = (msgs || []).filter((m) => m && !m.recalled) if (!dest || !rows.length) return if (merge) { this.sendText(dest, rows.map((m) => previewOf(m)).join('\n')) return } rows.forEach((m) => this.forwardTo(dest, m)) }, clearHistory(conv) { if (!conv) return conv.messages = [] conv.lastMessage = '' this.emit() }, hideConv(conv) { if (!conv) return this.conversations = this.conversations.filter((c) => c.id !== conv.id) try { api.sessionAction({ sessionId: conv.id, hidden: true }, this.token) } catch (_) {} this.emit() }, markUnread(conv) { if (!conv) return conv.unread = Math.max(1, Number(conv.unread) || 1) this.emit() }, async setSession(conv, patch) { if (!conv || !patch) return if (patch.pinned != null) conv.pinned = !!patch.pinned if (patch.muted != null) conv.muted = !!patch.muted try { await api.sessionAction({ sessionId: conv.id, ...patch }, this.token) } catch (_) {} this._sort() this.emit() }, async createGroup(name, memberIds) { const ids = [...new Set([this.me && this.me.id, ...(memberIds || []).map(String)])].filter(Boolean) const title = String(name || '').trim() || '群聊' let sid = '' try { const data = await api.createGroup({ name: title, memberIds: ids }, this.token) sid = String((data && (data.id || data.sessionId)) || '') } catch (_) {} if (!sid) sid = 'g:' + Date.now() let conv = this.findConv(sid) if (!conv) { conv = { id: sid, type: 'group', name: title, avatar: '', peerId: '', memberIds: ids, messages: [], unread: 0, lastMessage: '你创建了群聊', lastTime: fmtListTime(Date.now()), pinned: false, muted: false } this.conversations.unshift(conv) } else { conv.name = title conv.memberIds = ids } this._sort() this.emit() return conv }, async refreshWorkbench() { try { const ov = await api.oaGet('/dashboard/overview', this.token) this.overview = ov && typeof ov === 'object' ? ov : {} this.pendingItems = asList(this.overview.pendingItems) } catch (_) {} try { const sc = await api.oaGet('/dashboard/shortcuts', this.token) this.shortcuts = asList(sc && sc.shortcuts ? sc.shortcuts : sc) } catch (_) { this.shortcuts = [] } try { this.announcements = asList(await api.oaGet('/announcements', this.token)) } catch (_) {} try { const page = await api.oaGet('/workflow/requests?tab=pending&page=1&size=30', this.token) this.todos = asList(page && page.records ? page.records : page) const all = Number(this.overview && this.overview.pendingAll) this.todoCount = Number.isFinite(all) && all > 0 ? all : this.todos.length } catch (_) {} this.emit() }, async refreshAttendance() { try { this.attendRules.locations = asList(await api.oaGet('/profile/attendance/locations', this.token)) } catch (_) {} try { const pack = await api.oaGet('/system/attend-rules', this.token) if (pack && typeof pack === 'object') { this.attendRules.workStart = pack.workStart || this.attendRules.workStart this.attendRules.workEnd = pack.workEnd || this.attendRules.workEnd } } catch (_) {} try { this.attendance = asList(await api.oaGet('/profile/attendance', this.token)) } catch (_) {} this.emit() }, punch() { return new Promise((resolve, reject) => { uni.getLocation({ type: 'gcj02', isHighAccuracy: true, success: async (pos) => { try { const rec = await api.oaPost('/profile/attendance/punch', { source: 'mobile', latitude: pos.latitude, longitude: pos.longitude, accuracyM: pos.accuracy }, this.token) await this.refreshAttendance() resolve((rec && rec.title) || '打卡成功') } catch (e) { reject(e) } }, fail() { reject(new Error('需要定位权限才能打卡')) } }) }) }, async audit(no, action, comment) { await api.oaPost(`/workflow/requests/${no}/audit`, { action, comment }, this.token) await this.refreshWorkbench() }, async submitApply(body) { await api.oaPost('/workflow/requests', body, this.token) await this.refreshWorkbench() }, logout() { try { imClient.logout() } catch (_) {} uni.removeStorageSync(KEY_TOKEN) this.token = '' this.me = null this.menus = {} this.conversations = [] this.people = {} this.phase = 'login' this.emit() } } export function savedPhone() { return uni.getStorageSync(KEY_PHONE) || '' }