861 lines
32 KiB
Vue
861 lines
32 KiB
Vue
<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)"
|
||
>
|
||
<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 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="mask" @click="closeAct">
|
||
<view class="sheet" :style="{ paddingBottom: (safeBottom + 12) + 'px' }" @click.stop>
|
||
<text class="sheet-t">消息操作</text>
|
||
<text class="sheet-s">{{ actPreview }}</text>
|
||
<view class="sheet-grid">
|
||
<view v-if="actionMsg.kind === 'voice'" class="cell" @click="doPlay"><text class="ce">▶</text><text>播放</text></view>
|
||
<view v-if="actionMsg.kind !== 'voice'" class="cell" @click="doCopy"><text class="ce">文</text><text>复制</text></view>
|
||
<view class="cell" @click="doForward"><text class="ce">↪</text><text>转发</text></view>
|
||
<view class="cell" @click="doQuote"><text class="ce">❝</text><text>引用</text></view>
|
||
<view class="cell" @click="enterSelect"><text class="ce">多</text><text>多选</text></view>
|
||
<view v-if="canRecall" class="cell" @click="doRecall"><text class="ce">↩</text><text>撤回</text></view>
|
||
<view class="cell" @click="doDelete"><text class="ce">删</text><text>删除</text></view>
|
||
</view>
|
||
<view class="sheet-cancel" @click="closeAct">取消</view>
|
||
</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')"><text class="te">🖼</text><text>相册</text></view>
|
||
<view class="tile" @click="pick('camera')"><text class="te">📷</text><text>拍摄</text></view>
|
||
<view class="tile" @click="pick('file')"><text class="te">📎</text><text>文件</text></view>
|
||
<view v-if="!isGroup" class="tile" @click="startCall(false)"><text class="te">☎</text><text>语音通话</text></view>
|
||
<view v-if="!isGroup" class="tile" @click="startCall(true)"><text class="te">▷</text><text>视频通话</text></view>
|
||
<view v-if="isGroup" class="tile" @click="mention"><text class="te">@</text><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,
|
||
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)
|
||
},
|
||
actPreview() {
|
||
return previewOf(this.actionMsg)
|
||
}
|
||
},
|
||
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) {
|
||
if (!m || m.recalled || this.selecting) return
|
||
this.more = false
|
||
this.emoji = false
|
||
this.hideKb()
|
||
this.actionMsg = m
|
||
},
|
||
closeAct() { 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;
|
||
font-size: 22rpx; color: #4B5563;
|
||
}
|
||
.te { font-size: 44rpx; margin-bottom: 10rpx; }
|
||
.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; }
|
||
.mask { position: fixed; left: 0; right: 0; top: 0; bottom: 0; background: rgba(17, 24, 39, 0.45); z-index: 40; display: flex; align-items: flex-end; }
|
||
.sheet { width: 100%; background: #fff; border-radius: 28rpx 28rpx 0 0; padding: 28rpx 28rpx 12rpx; }
|
||
.sheet-t { display: block; text-align: center; font-size: 30rpx; font-weight: 650; }
|
||
.sheet-s { display: block; text-align: center; font-size: 24rpx; color: #9CA3AF; margin: 8rpx 0 24rpx; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.sheet-grid { display: flex; flex-wrap: wrap; gap: 16rpx; }
|
||
.cell {
|
||
width: calc(25% - 12rpx); height: 140rpx; background: #F5F7FA; border-radius: 16rpx;
|
||
display: flex; flex-direction: column; align-items: center; justify-content: center; font-size: 22rpx; color: #374151;
|
||
}
|
||
.ce { font-size: 32rpx; margin-bottom: 8rpx; color: #111827; font-weight: 650; }
|
||
.sheet-cancel {
|
||
margin: 20rpx 0 8rpx; height: 88rpx; border-radius: 16rpx; background: #F5F7FA;
|
||
display: flex; align-items: center; justify-content: center; font-size: 28rpx; color: #111827;
|
||
}
|
||
.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>
|