oa_app仓库初始化

This commit is contained in:
2026-09-23 16:52:48 +08:00
commit 65c5e9f66f
82 changed files with 6490 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
import { IM_HTTP, OA_BASE } from './config.js'
function unwrap(json) {
if (!json || typeof json !== 'object') return json
const code = json.code
if (code != null && code !== 0 && code !== 200) {
const err = new Error(json.message || '请求失败')
err.status = code
throw err
}
return Object.prototype.hasOwnProperty.call(json, 'data') ? json.data : json
}
export function request(path, { method = 'GET', data, token } = {}) {
const url = path.startsWith('http') ? path : `${IM_HTTP}${path.startsWith('/') ? path : '/' + path}`
return new Promise((resolve, reject) => {
uni.request({
url,
method,
data,
timeout: 20000,
header: {
Accept: 'application/json',
...(data != null ? { 'Content-Type': 'application/json' } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {})
},
success(res) {
const body = res.data
if (res.statusCode >= 400) {
const msg = body && body.message ? body.message : `HTTP ${res.statusCode}`
const err = new Error(msg)
err.status = res.statusCode
reject(err)
return
}
try {
resolve(unwrap(body))
} catch (e) {
reject(e)
}
},
fail(e) {
reject(new Error((e && e.errMsg) || '网络异常'))
}
})
})
}
export function loginOptions() {
return request('/api/im/sms/login-options')
}
export function login(body) {
return request('/api/im/login', { method: 'POST', data: body })
}
export function sendLoginCode(account) {
return request('/api/im/sms/login-code', { method: 'POST', data: { account } })
}
export function changePassword(body, token) {
return request('/api/im/change-password', { method: 'POST', data: body, token })
}
export function bootstrap(token) {
return request('/api/im/bootstrap', { token })
}
export function messages(sessionId, token) {
return request(`/api/im/messages?sessionId=${encodeURIComponent(sessionId)}`, { token })
}
export function markRead(sessionId, token) {
return request('/api/im/read', { method: 'POST', data: { sessionId }, token })
}
export function createGroup(body, token) {
return request('/api/im/groups', { method: 'POST', data: body, token })
}
export function sessionAction(body, token) {
return request('/api/im/sessions/action', { method: 'POST', data: body, token })
}
export function recall(body, token) {
return request('/api/im/recall', { method: 'POST', data: body, token })
}
export function livekitToken(body, token) {
return request('/api/im/livekit/token', { method: 'POST', data: body, token })
}
export function oaGet(path, token) {
return request(`/api/im/oa${path}`, { token })
}
export function oaPost(path, data, token) {
return request(`/api/im/oa${path}`, { method: 'POST', data, token })
}
export function oaPut(path, data, token) {
return request(`/api/im/oa${path}`, { method: 'PUT', data, token })
}
export function absUrl(path) {
if (!path) return ''
const s = String(path)
if (s.startsWith('http') || s.startsWith('data:') || s.startsWith('blob:')) return s
// 证件照 / 附件票在 OA 域名,不能拼到 /im 后面
if (
s.startsWith('/api/org/') ||
s.startsWith('/api/files') ||
s.startsWith('/api/auth/') ||
s.startsWith('/api/profile/') ||
s.startsWith('/api/hr/')
) {
return `${OA_BASE}${s}`
}
const base = IM_HTTP || OA_BASE
return s.startsWith('/') ? `${base}${s}` : `${base}/${s}`
}
/** 证件照完整地址:优先 avatar 票,否则用用户 ID 拼 org/photo(需票,一般走 refreshPeoplePhotos */
export function photoUrl(raw) {
if (!raw) return ''
if (typeof raw === 'string') return absUrl(raw)
const av = raw.avatar || raw.photo || raw.faceUrl || ''
if (av) return absUrl(av)
return ''
}
+201
View File
@@ -0,0 +1,201 @@
export const APPLY_KINDS = [
{ kind: 'expense', title: '报销申请', group: '费用', hint: '一张报销单可添加多张发票', multiInvoice: true, icon: 'receipt_long' },
{
kind: 'loan', title: '借支申请', group: '资金', hint: '因公业务需向公司预支资金', icon: 'payments',
fields: [
{ key: 'amount', label: '借支金额', type: 'money', placeholder: '请输入借支金额(元)' },
{ key: 'costType', label: '费用归属', type: 'select', options: ['投标费用', '正式项目', '部门费用', '行政'] },
{ key: 'project', label: '关联项目', type: 'text', placeholder: '投标案件或正式项目名称' },
{ key: 'days', label: '预计归还期限', type: 'text', placeholder: '例如:30 天' },
{ key: 'reason', label: '借支用途', type: 'textarea', placeholder: '请说明借支用途' }
]
},
{
kind: 'leave', title: '请假申请', group: '休假', hint: '年假、事假、病假等', icon: 'date_range',
fields: [
{ key: 'leaveType', label: '假期类型', type: 'select', options: ['年假', '事假', '病假', '婚假', '产假/陪产假', '调休'] },
{ key: 'start', label: '开始时间', type: 'datetime' },
{ key: 'end', label: '结束时间', type: 'datetime' },
{ key: 'days', label: '请假时长(天)', type: 'number', placeholder: '0.5 / 1 / 2' },
{ key: 'reason', label: '请假事由', type: 'textarea', placeholder: '请说明请假原因' }
]
},
{
kind: 'trip', title: '出差申请', group: '差旅', hint: '跨城市差旅报备', icon: 'flight_takeoff',
fields: [
{ key: 'city', label: '出差地点', type: 'text', placeholder: '例如:上海 · 浦东' },
{ key: 'start', label: '出发日期', type: 'date' },
{ key: 'end', label: '返回日期', type: 'date' },
{ key: 'reason', label: '出差事由', type: 'textarea' }
]
},
{
kind: 'procurement', title: '采购申请', group: '采购', hint: '设备、物资、服务采购', icon: 'shopping_cart',
fields: [
{ key: 'goods', label: '采购内容', type: 'text' },
{ key: 'amount', label: '预算金额', type: 'money' },
{ key: 'needDate', label: '期望到货日期', type: 'date' },
{ key: 'supplier', label: '意向供应商', type: 'text', optional: true },
{ key: 'reason', label: '采购理由', type: 'textarea' }
]
},
{
kind: 'overtime', title: '加班申请', group: '考勤', hint: '加班时长申报', icon: 'schedule',
fields: [
{ key: 'date', label: '加班日期', type: 'date' },
{ key: 'hours', label: '加班时长(小时)', type: 'number' },
{ key: 'reason', label: '申请说明', type: 'textarea' }
]
},
{
kind: 'outing', title: '外出申请', group: '考勤', hint: '工作期间临时外出', icon: 'near_me',
fields: [
{ key: 'place', label: '外出地点', type: 'text' },
{ key: 'start', label: '开始时间', type: 'datetime' },
{ key: 'end', label: '结束时间', type: 'datetime' },
{ key: 'reason', label: '申请说明', type: 'textarea' }
]
},
{
kind: 'card', title: '补卡申请', group: '考勤', hint: '漏打卡补卡说明', icon: 'edit_calendar',
fields: [
{ key: 'punchDate', label: '补卡日期', type: 'date' },
{ key: 'punchKind', label: '补卡类型', type: 'select', options: ['上班', '下班'] },
{ key: 'reason', label: '申请说明', type: 'textarea' }
]
},
{
kind: 'transfer', title: '转岗申请', group: '人事', hint: '岗位 / 部门调动', icon: 'swap_horiz',
fields: [
{ key: 'fromDept', label: '原部门', type: 'text' },
{ key: 'toDept', label: '拟调入部门', type: 'text' },
{ key: 'newTitle', label: '拟任岗位', type: 'text', optional: true },
{ key: 'reason', label: '申请说明', type: 'textarea' }
]
},
{
kind: 'regularize', title: '转正申请', group: '人事', hint: '实习/试用期满转正', onlyRegularize: true, icon: 'how_to_reg',
fields: [
{ key: 'currentStatus', label: '当前身份', type: 'select', options: ['实习', '试用'] },
{ key: 'hiredAt', label: '入职日期', type: 'date' },
{ key: 'summary', label: '试用/实习工作总结', type: 'textarea' },
{ key: 'nextPlan', label: '转正后工作计划', type: 'textarea' },
{ key: 'reason', label: '转正说明', type: 'textarea' }
]
},
{
kind: 'resign', title: '离职申请', group: '人事', hint: '本人提前申请离职', icon: 'logout',
fields: [
{ key: 'lastDay', label: '最后工作日', type: 'date' },
{ key: 'reason', label: '离职原因', type: 'textarea' }
]
},
{
kind: 'layoff', title: '裁员', group: '人事', hint: '人事或总监发起', onlyLayoff: true, icon: 'person_off',
fields: [
{ key: 'targetUsername', label: '被裁员工', type: 'select' },
{ key: 'lastDay', label: '最后工作日', type: 'date' },
{ key: 'reason', label: '裁员说明', type: 'textarea' }
]
},
{
kind: 'seal', title: '用章申请', group: '印章', hint: '选择鲁兵或风影', icon: 'approval',
fields: [
{ key: 'sealCompany', label: '用章主体', type: 'select', options: ['鲁兵', '风影'] },
{ key: 'sealType', label: '申请印章', type: 'select', options: ['法人章', '公章', '法人章和公章'] },
{ key: 'reason', label: '申请说明', type: 'textarea' }
]
},
{
kind: 'receive', title: '领用申请', group: '物资', hint: '办公物资与耗材领用', icon: 'inventory_2',
fields: [
{ key: 'goods', label: '领用物品', type: 'text' },
{ key: 'qty', label: '数量', type: 'text' },
{ key: 'reason', label: '申请说明', type: 'textarea' }
]
}
]
export function applyKindById(kind) {
return APPLY_KINDS.find((k) => k.kind === kind) || null
}
export function isFixedChain(kind) {
return kind === 'expense' || kind === 'loan' || kind === 'procurement' || kind === 'seal'
}
export function canApplyRegularize(status) {
const s = String(status || '').trim()
return s === '实习' || s === '试用' || s === 'INTERN' || s === 'PROBATION'
}
export function canApplyLayoff({ isSuper, role }) {
if (isSuper) return true
const r = String(role || '')
return r === '企业负责人' || r.includes('总监') || r.includes('负责人')
}
export function visibleApplyKinds({ employeeStatus, isSuper, role }) {
return APPLY_KINDS.filter((t) => {
if (t.onlyRegularize && !canApplyRegularize(employeeStatus)) return false
if (t.onlyLayoff && !canApplyLayoff({ isSuper, role })) return false
return true
})
}
export function groupedApplyKinds(kinds) {
const groups = []
const map = {}
kinds.forEach((k) => {
if (!map[k.group]) {
map[k.group] = { category: k.group, items: [] }
groups.push(map[k.group])
}
map[k.group].items.push(k)
})
return groups
}
export function buildApplyPayload({ spec, title, values, invoices, expenseRemark, approverUsername, suggestedUsername, me, layoffTargetName, attachments }) {
const form = { reason: values.reason || '', ...values }
if (Array.isArray(attachments) && attachments.length) form.attachments = attachments
let amount = null
const moneyField = (spec.fields || []).find((f) => f.type === 'money' && !f.optional)
if (moneyField) amount = Number(values[moneyField.key]) || null
if (spec.kind === 'resign' && me) {
form.targetUsername = me.username
form.targetName = me.name
}
if (spec.kind === 'layoff') form.targetName = layoffTargetName || ''
if (spec.multiInvoice) {
const rows = (invoices || []).map((r, i) => ({
seq: i + 1,
ticketType: r.ticketType,
amount: Number(r.amount) || 0,
invoiceNo: r.invoiceNo || '',
date: r.date || '',
note: r.note || '',
files: Array.isArray(r.files) ? r.files : [],
proxyPay: r.proxyPay === true,
proxyPayee: r.proxyPayee || '',
proxyPayeeName: r.proxyPayeeName || ''
}))
amount = rows.reduce((s, r) => s + r.amount, 0)
form.remark = expenseRemark || ''
form.reason = expenseRemark || rows.map((r) => r.note).filter(Boolean).join('')
form.invoices = rows
form.receipts = rows.length
}
const approver = isFixedChain(spec.kind)
? (suggestedUsername || approverUsername)
: approverUsername
return {
type: spec.kind,
typeLabel: spec.title,
title: String(title || '').trim(),
amount: amount && amount > 0 ? amount : null,
reason: form.reason || '',
approverUsername: approver,
form
}
}
+87
View File
@@ -0,0 +1,87 @@
/**
* 工作台入口目录:只描述「有这个能力时显示什么」。
* 谁能看见由 /auth/my-perms 决定,这里不写人、不写岗位。
*/
export const PERSONAL_APPS = [
{ id: 'clock', title: '考勤打卡', icon: '📍', menu: 'personal', action: 'punch', url: '/pages/tabs/clock' },
{ id: 'todo', title: '待办审批', icon: '☑', menu: 'personal', action: 'approve', url: '/pages/todo/list', badge: 'todo' },
{ id: 'apply', title: '发起申请', icon: '✎', menu: 'personal', action: 'apply', url: '/pages/apply/list' },
{ id: 'expense', title: '我的报销', icon: '🧾', menu: 'personal', action: 'view', url: '/pages/bills/list?type=expense&title=我的报销' },
{ id: 'loan', title: '我的借支', icon: '¥', menu: 'personal', action: 'view', url: '/pages/bills/list?type=loan&title=我的借支' },
{ id: 'report', title: '工作汇报', icon: '▤', menu: 'personal', action: 'report', url: '/pages/reports/list' },
{ id: 'salary', title: '工资条', icon: '💳', menu: 'personal', action: 'view', url: '/pages/me/payslip' },
{ id: 'perf', title: '我的绩效', icon: '↗', menu: 'personal', action: 'view', url: '/pages/me/performance' },
{ id: 'archive', title: '我的资料', icon: '👤', menu: 'personal', action: 'view', url: '/pages/me/archive' },
{ id: 'notice', title: '公告', icon: '📢', menu: 'personal', action: 'view', url: '/pages/notice/list' },
{ id: 'coop', title: '工作协同', icon: '🤝', menu: 'personal', action: 'view', url: '/pages/coop/list' },
{ id: 'seals', title: '我的用章', icon: '©', menu: 'personal', action: 'view', url: '/pages/bills/list?type=seal&title=我的用章' },
{ id: 'contacts', title: '通讯录', icon: '☎', menu: 'personal', action: 'view', url: '/pages/tabs/contacts' }
]
export const ERP_MODULES = [
{ id: 'bidding', title: '投标管理', icon: '◎', menu: 'bidding', action: 'view', url: '/pages/erp/hub?id=bidding' },
{ id: 'project', title: '项目管理', icon: '▣', menu: 'project', action: 'view', url: '/pages/erp/hub?id=project' },
{ id: 'finance', title: '财务管理', icon: '◈', menu: 'finance', action: 'view', url: '/pages/erp/hub?id=finance' },
{ id: 'hr', title: '人事管理', icon: '☰', menu: 'hr', action: 'view', url: '/pages/erp/hub?id=hr' },
{ id: 'contracts', title: '合同管理', icon: '▤', menu: 'contracts', action: 'view', url: '/pages/erp/hub?id=contracts' },
{ id: 'seals', title: '证照用章', icon: '◆', menu: 'seals', action: 'view', url: '/pages/erp/hub?id=seals' },
{ id: 'crm', title: '客户管理', icon: '◇', menu: 'crm', action: 'view', url: '/pages/erp/hub?id=crm' },
{ id: 'system', title: '系统管理', icon: '⚙', menu: 'system', action: 'view', url: '/pages/erp/hub?id=system' }
]
export const ERP_TABS = {
bidding: [
{ id: 'filter', title: '项目筛选', action: 'view' },
{ id: 'special-filter', title: '特殊筛选', action: 'specialFilter' },
{ id: 'doc-make', title: '标书制作', action: 'view' },
{ id: 'special-make', title: '特殊制作', action: 'specialMake' },
{ id: 'execution', title: '投标执行', action: 'view' },
{ id: 'results', title: '中标结果', action: 'view' },
{ id: 'abort', title: '落标归档', action: 'view' },
{ id: 'stats', title: '项目统计', action: 'view' }
],
project: [
{ id: 'list', title: '项目列表' },
{ id: 'progress', title: '项目进度' },
{ id: 'workhours', title: '工时记录', action: 'hours' },
{ id: 'accept', title: '项目验收', action: 'accept' }
],
finance: [
{ id: 'ledger', title: '总账管理' },
{ id: 'expense', title: '报销记录' },
{ id: 'loan', title: '借支记录' },
{ id: 'tender-bond', title: '投标保证金' },
{ id: 'invoice', title: '开票记录' },
{ id: 'receivable', title: '应收账款' }
],
hr: [
{ id: 'staff', title: '员工管理' },
{ id: 'org', title: '组织管理' },
{ id: 'attendance', title: '考勤管理' },
{ id: 'salary', title: '薪酬核算' },
{ id: 'notice', title: '公告发布', action: 'notice' }
],
contracts: [
{ id: 'bid', title: '投标合同' },
{ id: 'direct', title: '直签合同' },
{ id: 'other', title: '其他合同' }
],
seals: [
{ id: 'qualification', title: '资质管理' },
{ id: 'borrow', title: '证照借用' },
{ id: 'logs', title: '用章记录' },
{ id: 'manage', title: '印章管理' }
],
crm: [
{ id: 'list', title: '客户列表' },
{ id: 'opportunity', title: '商机跟进', action: 'follow' },
{ id: 'pool', title: '公海池', action: 'claim' }
],
system: [
{ id: 'security', title: '权限设置', action: 'grant' },
{ id: 'attend', title: '考勤规则', action: 'attend' },
{ id: 'logs', title: '操作日志' },
{ id: 'online', title: '在线人数' }
]
}
+21
View File
@@ -0,0 +1,21 @@
export const OA_BASE = 'https://oa.fysxkj.com'
export const IM_HTTP = 'https://oa.fysxkj.com/im'
export const IM_WS = 'wss://oa.fysxkj.com/im/websocket'
export const LIVEKIT_WS = 'wss://oa.fysxkj.com/livekit'
export const BRAND = '风影随行通讯'
export function livekitPublicUrl(raw) {
const trimmed = String(raw || '').trim().replace(/\/$/, '')
if (!trimmed) return LIVEKIT_WS
try {
const u = new URL(trimmed)
if (u.port === '7880' && (u.hostname === 'oa.fysxkj.com' || u.hostname === '47.96.23.244')) {
return LIVEKIT_WS
}
} catch (_) {}
return trimmed
}
export function callRoomOf(a, b) {
return 'call-' + [String(a), String(b)].sort().join('-')
}
+230
View File
@@ -0,0 +1,230 @@
import { OA_BASE, IM_HTTP } from './config.js'
import { toast } from './format.js'
export function fileId(file) {
if (file == null) return ''
if (typeof file === 'string' || typeof file === 'number') {
const s = String(file)
return !s || s === '0' || s === 'null' ? '' : s
}
const id = file.id ?? file.fileId
if (id == null || id === '' || id === 0 || id === '0' || id === 'null') return ''
return String(id)
}
export function fileName(file, fallback = '附件') {
if (!file || typeof file !== 'object') return fallback
return String(file.name || file.fileName || file.originalName || fallback)
}
export function isImageFile(file) {
const name = fileName(file, '')
const type = String((file && file.contentType) || '').toLowerCase()
return type.startsWith('image/') || /\.(png|jpe?g|gif|webp|bmp|heic)$/i.test(name)
}
function unwrapUpload(body) {
if (!body || typeof body !== 'object') return body
if (body.code != null && body.code !== 0 && body.code !== 200) {
throw new Error(body.message || '上传失败')
}
return Object.prototype.hasOwnProperty.call(body, 'data') ? body.data : body
}
export function uploadLocal(filePath, token, { kind = 'attach', name } = {}) {
const urls = [`${OA_BASE}/api/files`, `${IM_HTTP}/api/im/oa/files`]
const send = (url) => new Promise((resolve, reject) => {
uni.uploadFile({
url,
filePath,
name: 'file',
formData: { kind, ...(kind === 'im' || kind === 'IM' ? { bizType: 'im' } : {}) },
header: token ? { Authorization: 'Bearer ' + token } : {},
timeout: 120000,
success(res) {
let body = res.data
try { body = typeof body === 'string' ? JSON.parse(body) : body } catch (_) {}
if (res.statusCode >= 400) {
reject(new Error((body && body.message) || `上传失败 ${res.statusCode}`))
return
}
try {
const data = unwrapUpload(body)
const id = fileId(data)
if (!id) throw new Error((body && body.message) || '上传未返回文件')
resolve({
id,
name: (data && data.name) || name || '附件',
size: (data && data.size) || 0,
contentType: (data && data.contentType) || ''
})
} catch (e) {
reject(e)
}
},
fail(e) {
reject(new Error((e && e.errMsg) || '上传失败'))
}
})
})
return send(urls[0]).catch(() => send(urls[1]))
}
export function fileTicket(id, token) {
return new Promise((resolve, reject) => {
const urls = [`${OA_BASE}/api/files/${id}/ticket`, `${IM_HTTP}/api/im/oa/files/${id}/ticket`]
const post = (url, next) => {
uni.request({
url,
method: 'POST',
header: {
Accept: 'application/json',
...(token ? { Authorization: 'Bearer ' + token } : {})
},
success(res) {
const body = res.data
if (res.statusCode >= 400) {
if (next) return next()
reject(new Error((body && body.message) || '无法预览'))
return
}
const data = body && body.data !== undefined ? body.data : body
if (data && data.url) {
resolve(data)
return
}
if (next) return next()
reject(new Error('无法预览'))
},
fail() {
if (next) next()
else reject(new Error('无法预览'))
}
})
}
post(urls[0], () => post(urls[1], null))
})
}
export async function openFile(file, token) {
const id = fileId(file)
if (!id) {
toast('文件未迁移或无法预览')
return
}
try {
uni.showLoading({ title: '打开附件…', mask: true })
const ticket = await fileTicket(id, token)
const url = ticket.url
uni.hideLoading()
if (isImageFile(file)) {
uni.previewImage({ urls: [url], current: url })
return
}
uni.downloadFile({
url,
header: token ? { Authorization: 'Bearer ' + token } : {},
success(res) {
if (res.statusCode === 200 && res.tempFilePath) {
uni.openDocument({
filePath: res.tempFilePath,
showMenu: true,
fail() {
plus.runtime.openURL(url)
}
})
} else {
toast('下载失败')
}
},
fail() {
try { plus.runtime.openURL(url) } catch (_) { toast('无法打开附件') }
}
})
} catch (e) {
uni.hideLoading()
toast((e && e.message) || '无法预览附件')
}
}
function uploadPaths(paths, token, kind) {
return Promise.all(paths.map((p) => uploadLocal(p, token, { kind })))
}
export function pickAndUpload(token, kind = 'attach') {
return new Promise((resolve, reject) => {
const fromImages = () => {
uni.chooseImage({
count: 6,
sizeType: ['compressed', 'original'],
sourceType: ['album', 'camera'],
success(res) {
const paths = res.tempFilePaths || []
if (!paths.length) return resolve([])
uni.showLoading({ title: '上传中…', mask: true })
uploadPaths(paths, token, kind)
.then((rows) => { uni.hideLoading(); resolve(rows) })
.catch((e) => { uni.hideLoading(); reject(e) })
},
fail() { resolve([]) }
})
}
const fromFiles = () => {
const chooser = typeof uni.chooseFile === 'function' ? uni.chooseFile : null
if (!chooser) return fromImages()
chooser({
count: 6,
type: 'all',
extension: ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.pdf', '.doc', '.docx', '.xls', '.xlsx'],
success(res) {
const files = res.tempFiles || []
const paths = files.map((f) => f.path || f.tempFilePath).filter(Boolean)
if (!paths.length) return resolve([])
uni.showLoading({ title: '上传中…', mask: true })
uploadPaths(paths, token, kind)
.then((rows) => { uni.hideLoading(); resolve(rows) })
.catch((e) => { uni.hideLoading(); reject(e) })
},
fail() { resolve([]) }
})
}
uni.showActionSheet({
itemList: ['拍照或相册', '选择文件'],
success(res) {
if (res.tapIndex === 0) fromImages()
else fromFiles()
},
fail() { resolve([]) }
})
})
}
export function collectFiles(row) {
const out = []
const seen = new Set()
const push = (f) => {
if (!f) return
const id = fileId(f)
const key = id || fileName(f)
if (!key || seen.has(key)) return
seen.add(key)
out.push(typeof f === 'object' ? f : { id })
}
const walk = (v) => {
if (!v) return
if (Array.isArray(v)) v.forEach(walk)
else if (typeof v === 'object') {
if (fileId(v) || v.name) push(v)
}
}
if (!row || typeof row !== 'object') return out
walk(row.files)
walk(row.attachments)
walk(row.invoiceFiles)
walk(row.receiptFiles)
const form = row.form && typeof row.form === 'object' ? row.form : row
walk(form.files)
walk(form.attachments)
;(form.invoices || row.invoices || []).forEach((inv) => walk(inv && inv.files))
return out
}
+124
View File
@@ -0,0 +1,124 @@
export function asList(raw) {
if (Array.isArray(raw)) return raw.filter((x) => x && typeof x === 'object')
if (raw && typeof raw === 'object') {
const nested = raw.records || raw.list || raw.items || raw.messages || raw.projects || raw.data
if (Array.isArray(nested)) return nested.filter((x) => x && typeof x === 'object')
return [raw]
}
return []
}
export function firstText(row, keys, fallback = '') {
for (const k of keys) {
const v = row && row[k]
if (v != null && String(v).trim() && String(v) !== 'null') return String(v).trim()
}
return fallback
}
export function initial(name) {
const s = String(name || '').trim()
return s ? s.slice(0, 1) : '?'
}
export function maskPhone(phone) {
const s = String(phone || '')
if (s.length < 7) return s
return s.slice(0, 3) + '****' + s.slice(-4)
}
function asDate(t) {
if (t == null || t === '') return null
let d
const n = Number(t)
if (!Number.isNaN(n) && String(t).length >= 10) {
d = new Date(n < 1e12 ? n * 1000 : n)
} else {
d = new Date(t)
}
if (Number.isNaN(d.getTime())) return null
return d
}
function dayStart(d) {
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
}
function period(d) {
const h = d.getHours()
if (h < 6) return '凌晨'
if (h < 12) return '上午'
if (h < 13) return '中午'
if (h < 18) return '下午'
return '晚上'
}
function hm12(d) {
const h = d.getHours() % 12 || 12
const m = String(d.getMinutes()).padStart(2, '0')
return `${h}:${m}`
}
export function fmtTime(t) {
const d = asDate(t)
if (!d) return t == null ? '' : String(t)
const label = period(d) + hm12(d)
const now = new Date()
const diff = (dayStart(now) - dayStart(d)) / 86400000
if (diff === 0) return label
if (diff === 1) return '昨天 ' + label
if (diff < 7) {
const week = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
return week[d.getDay()] + ' ' + label
}
return `${d.getMonth() + 1}${d.getDate()}${label}`
}
export function fmtListTime(t) {
const d = asDate(t)
if (!d) return t == null ? '' : String(t)
const now = new Date()
const diff = (dayStart(now) - dayStart(d)) / 86400000
if (diff === 0) return period(d) + hm12(d)
if (diff === 1) return '昨天'
if (diff < 7) {
const week = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
return week[d.getDay()]
}
return `${d.getMonth() + 1}${d.getDate()}`
}
export function pad2(n) {
return String(n).padStart(2, '0')
}
export function clockText(date) {
const d = date || new Date()
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
}
export function yuan(v) {
const n = Number(v)
if (Number.isNaN(n)) return ''
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
export function dmId(a, b) {
const pair = [String(a), String(b)].sort()
return `dm:${pair[0]}:${pair[1]}`
}
export function convType(s) {
const t = String((s && s.type) || '')
if (t === 'dm') return 'direct'
if (t === 'group' || t === 'work' || t === 'files' || t === 'direct') return t
const id = String((s && s.id) || '')
if (id.startsWith('g:')) return 'group'
if (id.startsWith('work:')) return 'work'
if (id.startsWith('files:')) return 'files'
return 'direct'
}
export function toast(title, icon = 'none') {
uni.showToast({ title: String(title || ''), icon, duration: 2200 })
}
+252
View File
@@ -0,0 +1,252 @@
const ImType = {
login: 0,
keepAlive: 1,
commonData: 2,
logout: 3,
recived: 4,
responseLogin: 50,
responseKeepAlive: 51,
kickout: 54
}
export const ImTypeu = {
text: 1,
image: 2,
file: 3,
card: 4,
recall: 5,
read: 6,
call: 8,
group: 9
}
function frame(partial) {
return {
bridge: false,
type: 0,
dataContent: null,
from: '-1',
to: '-1',
fp: null,
QoS: false,
typeu: -1,
sm: Date.now(),
...partial
}
}
function uuid() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0
const v = c === 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}
class ImClient {
constructor() {
this.userId = ''
this.token = ''
this.url = ''
this._closed = true
this._tries = 0
this._alive = 0
this._reconnect = 0
this._handlers = {}
this._bound = false
this._opened = false
this._ready = false
this._queue = []
}
on(event, fn) {
this._handlers[event] = fn
}
emit(event, payload) {
const fn = this._handlers[event]
if (fn) fn(payload)
}
connect({ userId, token, url }) {
this._closed = false
this.userId = userId
this.token = token
this.url = url
this._open()
}
_bindSocket() {
if (this._bound) return
this._bound = true
uni.onSocketOpen(() => {
this._tries = 0
this._opened = true
this._send(frame({
type: ImType.login,
from: String(this.userId || ''),
to: '0',
dataContent: JSON.stringify({
loginUserId: String(this.userId || ''),
loginToken: this.token,
extra: 'hbuilder-app',
firstLoginTime: 0
})
}))
})
uni.onSocketMessage((res) => this._onMessage(res.data))
uni.onSocketClose(() => {
this._opened = false
this._ready = false
this._clearAlive()
if (!this._closed) this._schedule()
})
uni.onSocketError(() => {
if (!this._closed) this._schedule()
})
}
_open() {
this._clear()
this.emit('status', 'reconnecting')
this._bindSocket()
uni.connectSocket({ url: this.url })
}
_onMessage(raw) {
let p
try {
p = typeof raw === 'string' ? JSON.parse(raw) : raw
} catch (_) {
return
}
if (!p || typeof p !== 'object') return
const type = p.type
if (type === ImType.responseLogin) {
let info = {}
try {
info = JSON.parse(p.dataContent || '{}')
} catch (_) {}
if (info.code === 0) {
this._ready = true
this.emit('status', 'connected')
this._keep()
this._flush()
} else {
this.emit('status', 'offline')
this.emit('loginFailed', info)
}
return
}
if (type === ImType.responseKeepAlive) return
if (type === ImType.kickout) {
this._closed = true
this.emit('kick')
try { uni.closeSocket() } catch (_) {}
return
}
if (type === ImType.recived) {
this.emit('ack', p.fp || p.dataContent)
return
}
if (type === ImType.commonData) {
if (p.QoS && p.fp) {
this._send(frame({
type: ImType.recived,
from: this.userId,
to: '0',
dataContent: p.fp,
fp: p.fp
}))
}
let data = {}
try {
const dc = p.dataContent
data = typeof dc === 'string' ? JSON.parse(dc) : (dc || {})
} catch (_) {
data = { text: p.dataContent, kind: 'text' }
}
this.emit('data', { ...p, data, typeu: p.typeu || ImTypeu.text })
}
}
sendData({ to, typeu, payload, qos = true }) {
const fp = uuid()
this._send(frame({
type: ImType.commonData,
from: String(this.userId || ''),
to: String(to || ''),
typeu,
QoS: qos,
fp,
dataContent: JSON.stringify(payload)
}))
return fp
}
logout() {
this._closed = true
try {
this._send(frame({ type: ImType.logout, from: this.userId, to: '0' }))
} catch (_) {}
try { uni.closeSocket() } catch (_) {}
this._clear()
}
_send(p) {
const raw = JSON.stringify(p)
const ctrl = p.type === ImType.login || p.type === ImType.keepAlive || p.type === ImType.logout || p.type === ImType.recived
if (!this._opened || (!this._ready && !ctrl)) {
if (!ctrl) this._queue.push(raw)
return
}
try {
uni.sendSocketMessage({
data: raw,
fail: () => {
if (!ctrl) this._queue.push(raw)
}
})
} catch (_) {
if (!ctrl) this._queue.push(raw)
}
}
_flush() {
const pending = this._queue.splice(0, this._queue.length)
pending.forEach((raw) => {
try { uni.sendSocketMessage({ data: raw }) } catch (_) {}
})
}
_keep() {
this._clearAlive()
this._alive = setInterval(() => {
this._send(frame({ type: ImType.keepAlive, from: this.userId, to: '0', dataContent: '{}' }))
}, 5000)
}
_schedule() {
this.emit('status', 'reconnecting')
const wait = Math.min(15000, 800 * Math.pow(2, this._tries))
this._tries += 1
this._reconnect = setTimeout(() => this._open(), wait)
}
_clearAlive() {
if (this._alive) {
clearInterval(this._alive)
this._alive = 0
}
}
_clear() {
this._clearAlive()
if (this._reconnect) {
clearTimeout(this._reconnect)
this._reconnect = 0
}
}
}
export const imClient = new ImClient()
+85
View File
@@ -0,0 +1,85 @@
/**
* Web 路由 → 手机页。工作台 shortcuts / pendingItems 下发的是 Web path。
*/
export function mobileUrl(webPath, extra = {}) {
const raw = String(webPath || '').trim()
if (!raw) return ''
if (raw.startsWith('/pages/')) return raw
const qIndex = raw.indexOf('?')
const path = qIndex >= 0 ? raw.slice(0, qIndex) : raw
const query = qIndex >= 0 ? raw.slice(qIndex + 1) : ''
const no = extra.no || extra.id || extra.requestNo || ''
if (path === '/apply') return '/pages/apply/list'
const apply = path.match(/^\/apply\/([^/]+)$/)
if (apply) return '/pages/apply/form?kind=' + encodeURIComponent(apply[1])
if (path.startsWith('/todo')) {
if (no) return '/pages/todo/detail?no=' + encodeURIComponent(no)
const tab = path.split('/')[2] || 'pending'
return '/pages/todo/list?tab=' + encodeURIComponent(tab)
}
if (path === '/contacts') return '/pages/tabs/contacts'
if (path === '/profile' || path === '/me') return '/pages/me/archive'
if (path.startsWith('/hr/notice') || path === '/notice') return '/pages/notice/list'
if (path.startsWith('/report')) return '/pages/reports/list'
if (path.includes('clock') || path.includes('attend')) return '/pages/tabs/clock'
if (path.startsWith('/work')) {
if (no) return '/pages/coop/detail?id=' + encodeURIComponent(no)
return '/pages/coop/list'
}
const erp = [
['/project', 'project'],
['/bidding', 'bidding'],
['/finance', 'finance'],
['/hr', 'hr'],
['/contracts', 'contracts'],
['/seals', 'seals'],
['/crm', 'crm'],
['/system', 'system']
]
for (const [prefix, id] of erp) {
if (path === prefix || path.startsWith(prefix + '/')) return '/pages/erp/hub?id=' + id
}
if (query) {
const mapped = mobileUrl(path, extra)
if (mapped && mapped.includes('?')) return mapped + '&' + query
if (mapped) return mapped + '?' + query
}
return ''
}
export function openHref(webPath, extra = {}) {
const url = mobileUrl(webPath, extra)
if (!url) return false
if (url.startsWith('/pages/tabs/')) {
uni.switchTab({ url: url.split('?')[0] })
return true
}
uni.navigateTo({ url })
return true
}
const EMOJI = {
expense: '🧾', loan: '¥', leave: '休', trip: '✈', procurement: '购',
overtime: '加', outing: '出', card: '补', transfer: '转', regularize: '正',
resign: '离', layoff: '裁', seal: '章', receive: '领', clock: '📍',
todo: '☑', apply: '✎', report: '▤', salary: '💳', perf: '↗',
archive: '👤', notice: '📢', coop: '🤝', contacts: '☎',
bidding: '◎', project: '▣', finance: '◈', hr: '☰', contracts: '▤',
seals: '◆', crm: '◇', system: '⚙'
}
export function tileEmoji(item) {
if (item && item.emoji) return item.emoji
const to = String((item && (item.to || item.url || item.path)) || '')
const m = to.match(/\/apply\/([^/?]+)/)
if (m && EMOJI[m[1]]) return EMOJI[m[1]]
const id = String((item && (item.id || item.kind || item.key)) || '')
if (EMOJI[id]) return EMOJI[id]
const label = String((item && (item.label || item.title)) || '')
return label ? label.slice(0, 1) : '·'
}
+26
View File
@@ -0,0 +1,26 @@
/**
* 权限只认 /auth/my-perms。
* 和 Web permissions.js 一致:格子有 allowed 就用格子;超管看服务端下发的格子。
* 工作台入口可见性禁止写死人名/岗位。
*/
export function cellAllowed(cell) {
if (cell === true || cell === 1 || cell === '1') return true
if (cell === false || cell === 0 || cell === '0') return false
if (cell && typeof cell === 'object' && 'allowed' in cell) {
return cell.allowed === true || cell.allowed === 1
}
return false
}
export function canMenu(menus, menuId, action = 'view', isSuper = false) {
if (!menuId) return false
const block = menus && typeof menus === 'object' ? menus[menuId] : null
const cell = block && typeof block === 'object' ? block[action] : undefined
if (cell !== undefined && cell !== null) return cellAllowed(cell)
return !!isSuper
}
export function visibleOf(catalog, menus, isSuper) {
return (catalog || []).filter((item) => canMenu(menus, item.menu, item.action || 'view', isSuper))
}
+908
View File
@@ -0,0 +1,908 @@
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 通常没有证件照票。
* 证件照在 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 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) || ''
}