Files
oa_app/pages/chat/chat.vue
T
2026-09-24 10:07:08 +08:00

953 lines
34 KiB
Vue
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.
<template>
<view class="page chat">
<fy-header
:title="barTitle"
:back="!selecting"
:left-label="selecting ? '取消' : ''"
@left="exitSelect"
>
<template #right>
<text v-if="!selecting && isGroup" class="ico" @click="goMore">···</text>
<text v-else-if="!selecting && peerId" class="ico" @click="goMore">···</text>
</template>
</fy-header>
<scroll-view
class="msgs"
:scroll-y="!recording"
:scroll-into-view="tail"
scroll-with-animation
:refresher-enabled="!recording"
:refresher-triggered="refreshing"
@refresherrefresh="reload"
@click="onMsgsClick"
>
<view v-if="!list.length" class="empty">暂无消息发一条试试</view>
<view v-for="(m, i) in list" :key="m.id || i">
<view v-if="showTime(i)" class="chip-time">{{ timeOf(m) }}</view>
<view v-if="m.recalled" class="sys">{{ m.text || '已撤回一条消息' }}</view>
<view
v-else
class="row"
:class="{ mine: m.mine, pick: selecting }"
@click="selecting ? togglePick(m) : tapMsg(m)"
@longpress="act(m, i)"
>
<view v-if="selecting" class="ck" :class="{ on: isPicked(m) }" />
<view v-if="!m.mine" class="av" @click.stop="openSender(m)">
<fy-file-mark v-if="isFiles" />
<image v-else-if="avatarOf(m)" :src="avatarOf(m)" mode="aspectFill" />
<text v-else>{{ initialOf(m) }}</text>
</view>
<view class="col" :class="{ mine: m.mine }">
<text v-if="isGroup && !m.mine" class="who">{{ sender(m) }}</text>
<view :id="'bubble-' + i" class="ball" :class="ballClass(m)">
<view v-if="m.quote" class="quote">{{ quoteLine(m.quote) }}</view>
<image v-if="m.kind === 'image' && m.fileUrl" class="pic" :src="m.fileUrl" mode="widthFix" />
<view v-else-if="m.kind === 'voice'" class="voice" :style="{ width: voiceW(m) }">
<text>{{ m.mine ? '' : '♪' }} {{ voiceLen(m) }} {{ m.mine ? '♪' : '' }}</text>
</view>
<view v-else-if="m.kind === 'file'" class="file">
<text class="fn">{{ m.fileName || m.text || '文件' }}</text>
<text class="fs">点击打开</text>
</view>
<view v-else-if="isWorkCard(m)" class="cardtxt">
<text class="ct">{{ m.title || m.text || '工作卡片' }}</text>
<text class="cs">点击查看详情</text>
</view>
<text v-else>{{ m.text }}</text>
</view>
<text v-if="m.mine" class="tm" :class="{ read: readDone(m) }" @click="showRead(m)">{{ readLabel(m) }}</text>
</view>
<view v-if="m.mine" class="av self">
<image v-if="myAvatar" :src="myAvatar" mode="aspectFill" />
<text v-else>{{ myInitial }}</text>
</view>
</view>
</view>
<view id="tail" />
<view id="end" />
</scroll-view>
<view
v-if="recording"
class="recmask"
@touchend="holdEnd"
@touchcancel="holdEnd"
>
<view class="recbox" :class="{ cancel: recWillCancel }">
<text class="recsec">{{ recSec }}</text>
<text>{{ recWillCancel ? '松开取消' : '松开发送,上滑取消' }}</text>
</view>
</view>
<view v-if="actionMsg && !selecting" class="pop-mask" @click="closeAct" @touchmove.stop.prevent>
<view class="pop" :class="{ below: popBelow }" :style="popBoxStyle" @click.stop>
<view v-for="it in actItems" :key="it.k" class="pop-item" @click="runAct(it.k)">
<wd-icon :name="it.icon" size="22px" color="#fff" />
<text class="pop-lab">{{ it.label }}</text>
</view>
<view class="pop-caret" :style="{ left: caretLeft + 'px' }" />
</view>
</view>
<view v-if="selecting" class="selbar" :style="{ paddingBottom: safeBottom + 'px' }">
<view class="selbtn" @click="forwardPicked"><text>转发</text></view>
<view class="selbtn" @click="mergePicked"><text>合并转发</text></view>
<view class="selbtn danger" @click="deletePicked"><text>删除</text></view>
</view>
<view v-else-if="!isWork" class="composer" :style="{ paddingBottom: composerPad }">
<view v-if="quoting" class="qbar">
<text class="qt">{{ quoteLine(quoting) }}</text>
<text class="qx" @click="quoting = null">×</text>
</view>
<view class="bar">
<view class="tool" @click="toggleVoice">
<view v-if="voiceMode" class="kbd">
<view class="krow" />
<view class="krow" />
<view class="kspace" />
</view>
<view v-else class="mic">
<view class="mic-head" />
<view class="mic-arc" />
<view class="mic-stem" />
</view>
</view>
<view
v-if="voiceMode"
class="hold"
@touchstart="holdStart"
@touchend="holdEnd"
@touchcancel="holdEnd"
>
{{ recording ? (recWillCancel ? '松开取消' : '松开发送') : '按住 说话' }}
</view>
<view v-else class="box">
<input
v-model="draft"
confirm-type="send"
hold-keyboard
:adjust-position="false"
cursor-spacing="24"
@confirm="send"
@focus="onFocus"
:placeholder="isGroup ? '输入消息,@ 可提醒成员…' : ' '"
/>
</view>
<text v-if="isGroup && !voiceMode" class="tool" @click="mention">@</text>
<text class="tool" @click="toggleEmoji"></text>
<text v-if="draft.trim()" class="go" @click="send">发送</text>
<text v-else class="tool plus" @click="toggleMore">{{ more ? '×' : '+' }}</text>
</view>
</view>
<view v-if="emoji && !isWork && !selecting" class="panel emo" :style="{ paddingBottom: safeBottom + 'px' }">
<text v-for="e in emos" :key="e" class="em" @click="insertEmoji(e)">{{ e }}</text>
</view>
<view v-else-if="more && !isWork && !selecting" class="panel" :style="{ paddingBottom: safeBottom + 'px' }">
<view class="tile" @click="pick('album')">
<wd-icon name="picture" size="52rpx" color="#111827" />
<text>相册</text>
</view>
<view class="tile" @click="pick('camera')">
<wd-icon name="camera" size="52rpx" color="#111827" />
<text>拍摄</text>
</view>
<view class="tile" @click="pick('file')">
<wd-icon name="folder" size="52rpx" color="#111827" />
<text>文件</text>
</view>
<view v-if="!isGroup" class="tile" @click="startCall(false)">
<wd-icon name="call" size="52rpx" color="#111827" />
<text>语音通话</text>
</view>
<view v-if="!isGroup" class="tile" @click="startCall(true)">
<wd-icon name="video" size="52rpx" color="#111827" />
<text>视频通话</text>
</view>
<view v-if="isGroup" class="tile" @click="mention">
<wd-icon name="usergroup" size="52rpx" color="#111827" />
<text>提醒成员</text>
</view>
</view>
<view v-else-if="isWork" class="hint">工作通知为办件卡片不支持快捷回复</view>
</view>
</template>
<script>
import FyHeader from '../../components/fy-header.vue'
import FyFileMark from '../../components/fy-file-mark.vue'
import { store, previewOf } from '../../utils/store.js'
import { fmtTime, toast } from '../../utils/format.js'
import { uploadLocal, fileTicket, openFile } from '../../utils/files.js'
import { mobileUrl } from '../../utils/nav.js'
const EMOS = ['😀','😁','😂','😅','😊','😍','😘','😜','🤗','🤔','🙄','😴','😭','😡','👍','👎','👌','🙏','👏','💪','🔥','⭐','🎉','❤️','💕','✅','❌','💯','🤝','👀']
export default {
components: { FyHeader, FyFileMark },
data() {
return {
sid: '',
draft: '',
off: null,
tail: '',
list: [],
title: '聊天',
isGroup: false,
isWork: false,
isFiles: false,
peerId: '',
myAvatar: '',
myInitial: '我',
memberCount: 0,
kb: 0,
more: false,
emoji: false,
voiceMode: false,
refreshing: false,
safeBottom: 0,
recording: false,
recWillCancel: false,
recSec: 0,
actionMsg: null,
popTop: 0,
popLeft: 8,
popW: 224,
caretLeft: 24,
popBelow: false,
popAt: 0,
quoting: null,
selecting: false,
picked: [],
emos: EMOS,
_recAt: 0,
_recY: 0,
_recTimer: 0,
_rec: null,
_recSkip: false,
_playing: null,
_kb: null,
_kbBound: false,
_lastId: ''
}
},
computed: {
barTitle() {
if (this.selecting) return '已选择 ' + this.picked.length + ' 条消息'
if (this.isWork) return '工作通知'
if (this.isGroup) return this.title + (this.memberCount ? '(' + this.memberCount + ')' : '')
return this.title
},
composerPad() {
if (this.kb > 0) return this.kb + 'px'
return (10 + this.safeBottom) + 'px'
},
canRecall() {
const m = this.actionMsg
return !!(m && m.mine && !m.recalled && Date.now() - Number(m.time) < 120000)
},
actItems() {
const m = this.actionMsg
if (!m) return []
const items = []
if (m.kind === 'voice') items.push({ k: 'play', icon: 'play', label: '播放' })
else items.push({ k: 'copy', icon: 'copy', label: '复制' })
items.push({ k: 'forward', icon: 'share', label: '转发' })
items.push({ k: 'quote', icon: 'chat', label: '引用' })
items.push({ k: 'select', icon: 'view-list', label: '多选' })
if (this.canRecall) items.push({ k: 'recall', icon: 'rollback', label: '撤回' })
items.push({ k: 'delete', icon: 'delete', label: '删除' })
return items
},
popBoxStyle() {
return {
top: this.popTop + 'px',
left: this.popLeft + 'px',
width: this.popW + 'px'
}
}
},
onLoad(q) {
this.sid = decodeURIComponent(q.sid || '')
try {
this.safeBottom = Number(uni.getSystemInfoSync().safeAreaInsets && uni.getSystemInfoSync().safeAreaInsets.bottom) || 0
} catch (_) {}
if (typeof uni.getRecorderManager === 'function') {
this._rec = uni.getRecorderManager()
this._rec.onStop((res) => this.afterRecord(res))
this._rec.onError(() => {
this.recording = false
toast('录音失败,请检查麦克风权限')
})
}
},
onShow() {
this.bindKb()
if (this.off) this.off()
store.refreshPeoplePhotos()
this.pull()
this.off = store.on(() => this.pull())
const c = store.findConv(this.sid)
if (c) store.openChat(c)
},
onHide() {
this.unbind()
this.recCancel()
this.actionMsg = null
if (store.activeSid === this.sid) store.setActiveChat('')
},
onUnload() {
this.unbind()
this.unbindKb()
this.recCancel()
this.unbindRecDoc()
if (store.activeSid === this.sid) store.setActiveChat('')
if (this._playing) {
try { this._playing.stop() } catch (_) {}
}
},
methods: {
unbind() {
if (this.off) this.off()
this.off = null
},
bindKb() {
if (this._kbBound) return
this._kbBound = true
this._kb = (res) => {
this.kb = Number(res && res.height) || 0
if (this.kb > 0) {
this.more = false
this.emoji = false
}
this.scrollEnd()
}
if (typeof uni.onKeyboardHeightChange === 'function') uni.onKeyboardHeightChange(this._kb)
},
unbindKb() {
if (this._kb) {
try { uni.offKeyboardHeightChange(this._kb) } catch (_) {}
}
this._kb = null
this._kbBound = false
},
pull() {
const c = store.findConv(this.sid)
this.title = (c && c.name) || '聊天'
this.isGroup = !!(c && c.type === 'group')
this.isWork = !!(c && c.type === 'work')
this.isFiles = !!(c && c.type === 'files')
this.peerId = (c && c.peerId) || ''
this.memberCount = c && c.memberIds ? c.memberIds.length : 0
this.myAvatar = (store.me && store.me.avatar) || ''
this.myInitial = ((store.me && store.me.name) || '我').slice(0, 1)
const next = ((c && c.messages) || []).map((m) => ({ ...m }))
const lastId = next.length ? next[next.length - 1].id : ''
this.list = next
if (lastId !== this._lastId) {
this._lastId = lastId
this.scrollEnd()
}
},
scrollEnd() {
this.$nextTick(() => {
this.tail = this.tail === 'end' ? 'tail' : 'end'
})
},
async reload() {
this.refreshing = true
const c = store.findConv(this.sid)
try { if (c) await store.openChat(c) } finally { this.refreshing = false }
},
showTime(i) {
if (i === 0) return true
return Math.abs(Number(this.list[i].time) - Number(this.list[i - 1].time)) > 5 * 60 * 1000
},
timeOf(m) { return fmtTime(m.time) },
voiceLen(m) { return Math.max(1, Number(m.duration) || 1) },
voiceW(m) { return Math.min(360, 120 + this.voiceLen(m) * 12) + 'rpx' },
readLabel(m) {
if (!m || !m.mine || m.recalled) return ''
if (this.isGroup && m.readReceipt) {
const r = (m.readReceipt.read || []).length
const u = (m.readReceipt.unread || []).length
if (!r && !u) return m.read ? '已读' : '未读'
return '已读 ' + r + '/' + (r + u)
}
return m.read ? '已读' : '未读'
},
readDone(m) {
if (!m || !m.mine || m.recalled) return false
if (this.isGroup && m.readReceipt) {
const r = (m.readReceipt.read || []).length
const u = (m.readReceipt.unread || []).length
if (!r && !u) return !!m.read
return u === 0
}
return !!m.read
},
showRead(m) {
if (!this.isGroup || !m || !m.readReceipt) return
const r = (m.readReceipt.read || []).map((p) => p.name || p).join('、') || '暂无'
const u = (m.readReceipt.unread || []).map((p) => p.name || p).join('、') || '暂无'
uni.showModal({
title: '已读详情',
content: '已读:' + r + '\n未读:' + u,
showCancel: false
})
},
sender(m) {
if (m.mine) return (store.me && store.me.name) || '我'
const p = store.person(m.fromId)
return (p && p.name) || this.title || '同事'
},
avatarOf(m) {
if (m.mine) return this.myAvatar
const p = store.person(m.fromId)
if (p && p.avatar) return p.avatar
const c = store.findConv(this.sid)
return (c && c.avatar) || ''
},
initialOf(m) { return (this.sender(m) || '?').slice(0, 1) },
isWorkCard(m) { return m.kind === 'card' || (this.isWork && !m.mine) },
quoteLine(q) {
if (!q) return ''
const name = q.name || this.sender(q) || ''
return (name ? name + '' : '') + (q.text || previewOf(q))
},
ballClass(m) {
return {
self: m.mine && !m.recalled,
work: this.isWorkCard(m),
voice: m.kind === 'voice'
}
},
onMsgsClick() {
if (this.more || this.emoji) {
this.more = false
this.emoji = false
}
},
onFocus() {
this.more = false
this.emoji = false
this.voiceMode = false
},
hideKb() {
try { uni.hideKeyboard() } catch (_) {}
this.kb = 0
},
toggleMore() {
this.emoji = false
this.more = !this.more
if (this.more) this.hideKb()
},
toggleEmoji() {
this.more = false
this.emoji = !this.emoji
if (this.emoji) this.hideKb()
},
toggleVoice() {
this.voiceMode = !this.voiceMode
this.more = false
this.emoji = false
this.hideKb()
},
insertEmoji(e) {
this.draft = (this.draft || '') + (e || '🙂')
},
send() {
const c = store.findConv(this.sid)
const text = String(this.draft || '').trim()
if (!c || this.isWork || !text) return
this.draft = ''
store.sendText(c, text, this.quoting ? { quote: { ...this.quoting, name: this.quoting.name || this.sender(this.quoting) } } : null)
this.quoting = null
},
holdStart(e) {
if (this.isWork || this.recording) return
const t = (e.touches && e.touches[0]) || (e.changedTouches && e.changedTouches[0]) || {}
this._recY = Number(t.clientY || t.pageY || 0)
this.recWillCancel = false
this._recSkip = false
this.recording = true
this.recSec = 0
this._recAt = Date.now()
if (this._recTimer) clearInterval(this._recTimer)
this._recTimer = setInterval(() => {
this.recSec = Math.floor((Date.now() - this._recAt) / 1000)
}, 300)
this.bindRecDoc()
if (!this._rec) {
this.unbindRecDoc()
this.recording = false
return
}
try {
this._rec.start({ format: 'mp3', duration: 60000, sampleRate: 16000 })
} catch (_) {
this.unbindRecDoc()
this.recording = false
toast('无法开始录音')
}
},
holdMove(e) {
if (!this.recording) return
const t = (e.touches && e.touches[0]) || (e.changedTouches && e.changedTouches[0]) || {}
const y = Number(t.clientY || t.pageY || 0)
this.recWillCancel = !!(this._recY && y && this._recY - y > 70)
},
holdEnd() {
if (!this.recording) return
this.unbindRecDoc()
if (this.recWillCancel) {
this.recCancel()
toast('已取消')
return
}
this.recording = false
this.recWillCancel = false
if (this._recTimer) clearInterval(this._recTimer)
try { this._rec.stop() } catch (_) {}
},
bindRecDoc() {
this.unbindRecDoc()
try {
if (typeof document === 'undefined') return
this._onRecMove = (ev) => this.holdMove(ev)
this._onRecEnd = () => this.holdEnd()
document.addEventListener('touchmove', this._onRecMove, { passive: true, capture: true })
document.addEventListener('touchend', this._onRecEnd, { passive: true, capture: true })
document.addEventListener('touchcancel', this._onRecEnd, { passive: true, capture: true })
} catch (_) {}
},
unbindRecDoc() {
try {
if (typeof document === 'undefined') return
if (this._onRecMove) document.removeEventListener('touchmove', this._onRecMove, true)
if (this._onRecEnd) {
document.removeEventListener('touchend', this._onRecEnd, true)
document.removeEventListener('touchcancel', this._onRecEnd, true)
}
} catch (_) {}
this._onRecMove = null
this._onRecEnd = null
},
recCancel() {
this.unbindRecDoc()
this._recSkip = true
this.recording = false
this.recWillCancel = false
if (this._recTimer) clearInterval(this._recTimer)
try { if (this._rec) this._rec.stop() } catch (_) {}
},
async afterRecord(res) {
if (this._recSkip) {
this._recSkip = false
return
}
const path = res && (res.tempFilePath || res.tempFile)
const ms = Date.now() - this._recAt
if (!path || ms < 600) {
toast('说话时间太短')
return
}
const conv = store.findConv(this.sid)
if (!conv) return
try {
uni.showLoading({ title: '发送语音…', mask: true })
const f = await uploadLocal(path, store.token, { kind: 'im', name: 'voice.mp3' })
let url = ''
try { url = (await fileTicket(f.id, store.token)).url } catch (_) {}
store.sendVoice(conv, { ...f, url, duration: Math.round(ms / 1000) })
} catch (e) {
toast((e && e.message) || '语音发送失败')
}
try { uni.hideLoading() } catch (_) {}
},
playVoice(m) {
if (!m.fileUrl) {
toast('语音无法播放')
return
}
if (this._playing) {
try { this._playing.stop() } catch (_) {}
this._playing = null
}
const audio = uni.createInnerAudioContext()
audio.src = m.fileUrl
audio.autoplay = true
audio.onEnded(() => { try { audio.destroy() } catch (_) {} })
this._playing = audio
},
pick(kind) {
this.more = false
const conv = store.findConv(this.sid)
if (!conv) return
const sendPaths = async (paths, names) => {
if (!paths.length) return
uni.showLoading({ title: '发送中…', mask: true })
try {
for (let i = 0; i < paths.length; i++) {
const f = await uploadLocal(paths[i], store.token, { kind: 'im', name: names[i] })
let url = ''
try { url = (await fileTicket(f.id, store.token)).url } catch (_) {}
store.sendMedia(conv, { ...f, url })
}
} catch (e) {
toast((e && e.message) || '发送失败')
}
try { uni.hideLoading() } catch (_) {}
}
if (kind === 'file') {
const chooser = typeof uni.chooseFile === 'function' ? uni.chooseFile : null
if (!chooser) return this.pick('album')
chooser({
count: 6,
type: 'all',
success(res) {
const files = res.tempFiles || []
sendPaths(files.map((f) => f.path || f.tempFilePath).filter(Boolean), files.map((f) => f.name || '文件'))
}
})
return
}
uni.chooseImage({
count: kind === 'camera' ? 1 : 9,
sizeType: ['compressed', 'original'],
sourceType: kind === 'camera' ? ['camera'] : ['album'],
success(res) {
const paths = res.tempFilePaths || []
const files = res.tempFiles || []
sendPaths(paths, files.map((f, i) => f.name || ('图片' + (i + 1))))
}
})
},
act(m, index) {
if (!m || m.recalled || this.selecting) return
this.more = false
this.emoji = false
this.hideKb()
this.popAt = Date.now()
this.actionMsg = m
this.$nextTick(() => this.placePop(index))
},
placePop(index) {
const q = uni.createSelectorQuery().in(this)
q.select('#bubble-' + index).boundingClientRect()
q.exec((res) => {
const r = res && res[0]
if (!r || !r.width) return
const sys = uni.getSystemInfoSync()
const n = this.actItems.length
const cols = Math.min(n, 5)
const itemW = 56
const w = cols * itemW
const rows = Math.ceil(n / cols)
const h = rows * 62 + 8
let left = r.left + r.width / 2 - w / 2
left = Math.max(8, Math.min(left, sys.windowWidth - w - 8))
const below = r.top < h + 16
const top = below ? r.bottom + 10 : r.top - h - 10
const caret = r.left + r.width / 2 - left - 6
this.popW = w
this.popLeft = left
this.popTop = Math.max(8, top)
this.popBelow = below
this.caretLeft = Math.max(14, Math.min(caret, w - 26))
})
},
runAct(k) {
if (k === 'copy') return this.doCopy()
if (k === 'play') return this.doPlay()
if (k === 'forward') return this.doForward()
if (k === 'quote') return this.doQuote()
if (k === 'select') return this.enterSelect()
if (k === 'recall') return this.doRecall()
if (k === 'delete') return this.doDelete()
},
closeAct() {
if (Date.now() - this.popAt < 400) return
this.actionMsg = null
},
liveMsg() {
const c = store.findConv(this.sid)
const m = this.actionMsg
if (!c || !m) return { c: null, live: null }
const live = (c.messages || []).find((x) => x.id === m.id) || m
return { c, live }
},
doCopy() {
const m = this.actionMsg
if (!m) return
uni.setClipboardData({ data: m.text || m.fileUrl || m.fileName || '' })
this.closeAct()
},
doPlay() {
if (this.actionMsg) this.playVoice(this.actionMsg)
this.closeAct()
},
doForward() {
const m = this.actionMsg
if (!m) return
store.pendingForward = { fromSid: this.sid, msgs: [{ ...m }], merge: false }
this.closeAct()
uni.navigateTo({ url: '/pages/chat/forward' })
},
doQuote() {
const m = this.actionMsg
if (!m) return
this.quoting = { ...m, name: this.sender(m), text: m.kind === 'text' ? (m.text || '') : previewOf(m) }
this.voiceMode = false
this.closeAct()
},
doRecall() {
const { c, live } = this.liveMsg()
if (c && live) store.recall(c, live)
this.closeAct()
},
doDelete() {
const { c, live } = this.liveMsg()
if (c && live) store.removeMessage(c, live)
this.closeAct()
},
enterSelect() {
const m = this.actionMsg
this.selecting = true
this.picked = m && m.id ? [m.id] : []
this.closeAct()
},
exitSelect() {
this.selecting = false
this.picked = []
},
isPicked(m) { return this.picked.indexOf(m.id) >= 0 },
togglePick(m) {
if (!m || m.recalled) return
const i = this.picked.indexOf(m.id)
if (i >= 0) this.picked.splice(i, 1)
else this.picked.push(m.id)
},
pickedMsgs() {
const set = new Set(this.picked)
return this.list.filter((m) => set.has(m.id) && !m.recalled)
},
forwardPicked() {
const msgs = this.pickedMsgs()
if (!msgs.length) return toast('请先选择消息')
store.pendingForward = { fromSid: this.sid, msgs: msgs.map((m) => ({ ...m })), merge: false }
this.exitSelect()
uni.navigateTo({ url: '/pages/chat/forward' })
},
mergePicked() {
const msgs = this.pickedMsgs()
if (!msgs.length) return toast('请先选择消息')
store.pendingForward = { fromSid: this.sid, msgs: msgs.map((m) => ({ ...m })), merge: true }
this.exitSelect()
uni.navigateTo({ url: '/pages/chat/forward' })
},
deletePicked() {
const c = store.findConv(this.sid)
const msgs = this.pickedMsgs()
if (!c || !msgs.length) return
msgs.forEach((m) => store.removeMessage(c, m))
this.exitSelect()
},
tapMsg(m) {
if (m.recalled) return
if (m.kind === 'voice') return this.playVoice(m)
if (m.kind === 'image' && m.fileUrl) {
uni.previewImage({ urls: [m.fileUrl], current: m.fileUrl })
return
}
if (m.kind === 'file') {
openFile({ id: m.fileId || '', name: m.fileName || m.text, contentType: '' }, store.token)
return
}
if (this.isWorkCard(m)) {
if (m.href) {
const url = mobileUrl(m.href, { no: m.no, id: m.taskId })
if (url) uni.navigateTo({ url })
return
}
if (m.taskId) uni.navigateTo({ url: '/pages/coop/detail?id=' + encodeURIComponent(m.taskId) })
else if (m.no) uni.navigateTo({ url: '/pages/todo/detail?no=' + encodeURIComponent(m.no) })
}
},
openSender(m) {
if (m.fromId) uni.navigateTo({ url: '/pages/contact/detail?id=' + encodeURIComponent(m.fromId) })
},
goMore() {
if (this.isGroup) uni.navigateTo({ url: '/pages/chat/group?sid=' + encodeURIComponent(this.sid) })
else uni.navigateTo({ url: '/pages/chat/setting?sid=' + encodeURIComponent(this.sid) })
},
mention() {
const c = store.findConv(this.sid)
const ids = ((c && c.memberIds) || []).filter((id) => !store.me || id !== store.me.id)
const names = ids.map((id) => {
const p = store.person(id)
return (p && p.name) || id
}).filter(Boolean).slice(0, 6)
if (!names.length) {
this.draft = (this.draft || '') + '@'
return
}
uni.showActionSheet({
itemList: names,
success: (res) => {
const name = names[res.tapIndex]
if (name) this.draft = (this.draft || '') + '@' + name + ' '
}
})
},
async startCall(video) {
this.more = false
if (!this.peerId) return
try {
await store.startCall(this.peerId, { video: !!video })
} catch (e) {
toast((e && e.message) || '无法发起通话')
}
}
}
}
</script>
<style scoped>
.chat { display: flex; flex-direction: column; height: 100%; overflow: hidden; background: #F5F7FA; }
.msgs { flex: 1; height: 0; padding: 8rpx 24rpx 12rpx; box-sizing: border-box; }
.chip-time {
text-align: center;
margin: 20rpx auto 8rpx;
color: #9CA3AF;
font-size: 22rpx;
width: fit-content;
}
.sys { text-align: center; color: #9CA3AF; font-size: 24rpx; padding: 12rpx 40rpx 20rpx; }
.empty { text-align: center; color: #9CA3AF; padding: 120rpx 40rpx; font-size: 26rpx; }
.row { margin-bottom: 24rpx; display: flex; align-items: flex-start; gap: 16rpx; }
.row.mine { justify-content: flex-end; }
.ck { width: 36rpx; height: 36rpx; border: 2rpx solid #D1D5DB; border-radius: 50%; margin-top: 22rpx; flex-shrink: 0; box-sizing: border-box; }
.ck.on { background: #1677FF; border-color: #1677FF; }
.col { max-width: 72%; display: flex; flex-direction: column; }
.col.mine { align-items: flex-end; }
.av { width: 76rpx; height: 76rpx; border-radius: 16rpx; background: #E8F1FF; color: #1677FF; overflow: hidden; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 28rpx; font-weight: 650; }
.av.self { background: #1677FF; color: #fff; }
.av image { width: 76rpx; height: 76rpx; }
.who { font-size: 22rpx; color: #9CA3AF; margin: 0 4rpx 8rpx; }
.ball {
background: #fff;
padding: 18rpx 22rpx;
border-radius: 8rpx 20rpx 20rpx 20rpx;
font-size: 30rpx;
word-break: break-all;
line-height: 1.5;
color: #111827;
box-shadow: 0 2rpx 8rpx rgba(17, 24, 39, 0.04);
}
.ball.self {
background: #1677FF;
color: #fff;
border-radius: 20rpx 8rpx 20rpx 20rpx;
box-shadow: none;
}
.ball.work { background: #fff; color: #111827; border-radius: 20rpx; }
.ball.voice { min-width: 160rpx; }
.quote { background: rgba(17, 24, 39, 0.05); color: #6B7280; font-size: 22rpx; padding: 8rpx 12rpx; border-radius: 10rpx; margin-bottom: 10rpx; }
.ball.self .quote { background: rgba(255, 255, 255, 0.18); color: rgba(255, 255, 255, 0.9); }
.pic { max-width: 420rpx; border-radius: 16rpx; display: block; }
.file, .cardtxt { display: flex; flex-direction: column; min-width: 280rpx; }
.fn, .ct { font-weight: 600; }
.fs, .cs { font-size: 22rpx; margin-top: 8rpx; color: #1677FF; }
.ball.self .fs, .ball.self .cs { color: rgba(255, 255, 255, 0.85); }
.tm { font-size: 22rpx; color: #C4C9D4; margin-top: 6rpx; }
.tm.read { color: #16A34A; }
.recmask {
position: fixed; left: 0; right: 0; top: 0; bottom: 0; z-index: 30;
display: flex; align-items: center; justify-content: center;
background: rgba(0, 0, 0, 0.01); touch-action: none;
}
.recbox {
width: 320rpx; padding: 48rpx 24rpx; background: rgba(17, 24, 39, 0.82); color: #fff;
border-radius: 24rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; font-size: 26rpx;
}
.recbox.cancel { background: rgba(220, 38, 38, 0.9); }
.recsec { font-size: 48rpx; font-weight: 650; margin-bottom: 12rpx; }
.composer { background: #F5F7FA; border-top: 1rpx solid #E8ECF2; }
.qbar { display: flex; align-items: center; padding: 12rpx 24rpx 0; }
.qt { flex: 1; font-size: 24rpx; color: #6B7280; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.qx { padding: 0 8rpx 0 16rpx; color: #9CA3AF; font-size: 32rpx; }
.bar { display: flex; align-items: flex-end; padding: 12rpx 16rpx 8rpx; gap: 8rpx; }
.tool { width: 64rpx; height: 72rpx; display: flex; align-items: center; justify-content: center; font-size: 36rpx; color: #374151; }
.mic { width: 28rpx; height: 40rpx; display: flex; flex-direction: column; align-items: center; }
.mic-head { width: 14rpx; height: 20rpx; border: 3rpx solid #111827; border-radius: 10rpx; box-sizing: border-box; }
.mic-arc { width: 24rpx; height: 12rpx; margin-top: -4rpx; border: 3rpx solid #111827; border-top: none; border-radius: 0 0 14rpx 14rpx; box-sizing: border-box; }
.mic-stem { width: 3rpx; height: 8rpx; background: #111827; }
.kbd { width: 36rpx; display: flex; flex-direction: column; gap: 4rpx; }
.krow { height: 5rpx; background: #111827; border-radius: 2rpx; }
.kspace { width: 18rpx; height: 5rpx; margin: 0 auto; background: #111827; border-radius: 2rpx; }
.plus { font-size: 48rpx; font-weight: 300; color: #111827; }
.box { flex: 1; min-height: 72rpx; background: #fff; border-radius: 12rpx; display: flex; align-items: center; padding: 0 20rpx; }
.box input { flex: 1; height: 72rpx; font-size: 30rpx; }
.hold {
flex: 1; height: 72rpx; background: #fff; border-radius: 12rpx;
text-align: center; line-height: 72rpx; font-size: 28rpx; font-weight: 600; color: #111827;
touch-action: none; user-select: none;
}
.go {
height: 64rpx; padding: 0 24rpx; margin-bottom: 4rpx;
background: #1677FF; color: #fff; border-radius: 12rpx;
font-size: 26rpx; font-weight: 600; display: flex; align-items: center;
}
.panel { display: flex; flex-wrap: wrap; background: #F5F7FA; padding: 8rpx 24rpx 28rpx; gap: 24rpx; }
.panel.emo { gap: 0; padding-top: 8rpx; }
.tile {
width: 148rpx; height: 148rpx; background: #fff; border-radius: 20rpx;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 12rpx; font-size: 22rpx; color: #4B5563;
}
.em { width: 12.5%; text-align: center; font-size: 44rpx; line-height: 88rpx; }
.hint { text-align: center; font-size: 24rpx; color: #9CA3AF; padding: 24rpx; background: #fff; }
.ico { padding: 8rpx 8rpx; font-size: 40rpx; color: #111827; letter-spacing: 2rpx; }
.pop-mask { position: fixed; left: 0; right: 0; top: 0; bottom: 0; z-index: 40; }
.pop {
position: fixed;
display: flex;
flex-wrap: wrap;
background: #4C4C4C;
border-radius: 8px;
padding: 4px 0;
box-sizing: border-box;
}
.pop-item {
width: 56px;
height: 62px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #fff;
}
.pop-lab { margin-top: 4px; font-size: 12px; line-height: 1; color: #fff; }
.pop-caret {
position: absolute;
width: 0;
height: 0;
border: 6px solid transparent;
border-top-color: #4C4C4C;
bottom: -12px;
}
.pop.below .pop-caret {
top: -12px;
bottom: auto;
border-top-color: transparent;
border-bottom-color: #4C4C4C;
}
.selbar { display: flex; background: #fff; border-top: 1rpx solid #E8ECF2; }
.selbtn { flex: 1; height: 96rpx; display: flex; align-items: center; justify-content: center; font-size: 28rpx; color: #1677FF; }
.selbtn.danger { color: #DC2626; }
</style>