Files
oa_app/utils/store.js
T
2026-09-24 15:12:49 +08:00

940 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.read === 1 || raw.read === '1' || raw.readAt || raw.isRead === true || raw.isRead === 1)),
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 通常没有证件照票。
* 证件照在 OAGET /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 from = String(pack.from || data.from || '')
const me = this.me ? String(this.me.id) : ''
if (!from || from === me) {
this.emit()
return
}
const sid = String(data.sessionId || '') || this.dmIdOf(from)
const conv = this.findConv(sid)
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 prev = new Map((conv.messages || []).filter((m) => m.id).map((m) => [m.id, m]))
const incoming = asList(rows && rows.messages ? rows.messages : rows).map((m) => {
const msg = mapMsg(m, this.me && this.me.id, m.typeu)
const old = prev.get(msg.id)
if (old && old.read) msg.read = true
if (old && old.readReceipt && !msg.readReceipt) msg.readReceipt = old.readReceipt
return msg
})
const seen = new Set(incoming.map((m) => m.id).filter(Boolean))
const extras = (conv.messages || []).filter((m) => m.id && !seen.has(m.id) && (m.kind === 'system' || (m.mine && 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() || '群聊'
if (ids.length < 2) throw new Error('请至少再选一位同事')
const data = await api.createGroup({
title,
name: title,
memberIds: ids,
members: ids
}, this.token)
const sid = String((data && (data.id || data.sessionId)) || '')
if (!sid) throw new Error('创建失败')
let conv = this.findConv(sid)
if (!conv) {
conv = {
id: sid,
type: 'group',
name: title,
avatar: '',
peerId: '',
memberIds: ids,
unread: 0,
messages: [{
id: 'sys-' + sid,
kind: 'system',
text: '你创建了群聊',
time: Date.now(),
mine: false,
fromId: '',
recalled: false
}],
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 (_) {}
if (this.can('system', 'attend')) {
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()
},
_locate() {
let type = 'gcj02'
// #ifdef H5
type = 'wgs84'
// #endif
return new Promise((resolve, reject) => {
uni.getLocation({
type,
isHighAccuracy: false,
timeout: 8000,
success: resolve,
fail: reject
})
})
},
async punch() {
const body = { source: 'mobile' }
try {
const pos = await this._locate()
body.latitude = pos.latitude
body.longitude = pos.longitude
body.accuracyM = pos.accuracy
} catch (_) {
// 桌面浏览器经常没有定位;坐标缺省时仍提交,由服务端决定是否允许
}
const rec = await api.oaPost('/profile/attendance/punch', body, this.token)
await this.refreshAttendance()
return (rec && rec.title) || '打卡成功'
},
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) || ''
}