909 lines
28 KiB
JavaScript
909 lines
28 KiB
JavaScript
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) || ''
|
||
}
|