Files
oa_app/pages/chat/chat.vue
T
2026-09-23 17:38:24 +08:00

800 lines
30 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)"
>
<view v-if="selecting" class="ck" :class="{ on: isPicked(m) }" />
<view v-if="!m.mine" class="av" @click.stop="openSender(m)">
<image v-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: m.read || readLabel(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">
<text class="tool" @click="toggleVoice">{{ voiceMode ? '' : '🎤' }}</text>
<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 { 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 },
data() {
return {
sid: '',
draft: '',
off: null,
tail: '',
list: [],
title: '聊天',
isGroup: false,
isWork: 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 (_) {}
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()
}
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.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
return '已读 ' + r + '/' + (r + u)
}
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()
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: 100vh; overflow: hidden; background: #ededed; }
.msgs { flex: 1; height: 0; padding: 12rpx 20rpx 8rpx; box-sizing: border-box; }
.chip-time { text-align: center; margin: 16rpx auto; background: rgba(0,0,0,.08); color: #fff; font-size: 20rpx; padding: 4rpx 16rpx; border-radius: 8rpx; width: fit-content; }
.sys { text-align: center; color: #888; font-size: 22rpx; padding: 12rpx 0 20rpx; }
.empty { text-align: center; color: #888; padding: 80rpx 0; font-size: 26rpx; }
.row { margin-bottom: 22rpx; display: flex; align-items: flex-start; gap: 12rpx; }
.row.mine { justify-content: flex-end; }
.row.pick { padding-left: 0; }
.ck { width: 36rpx; height: 36rpx; border: 2rpx solid #bbb; border-radius: 50%; margin-top: 20rpx; flex-shrink: 0; }
.ck.on { background: #07c160; border-color: #07c160; }
.col { max-width: 70%; display: flex; flex-direction: column; }
.col.mine { align-items: flex-end; }
.av { width: 80rpx; height: 80rpx; border-radius: 10rpx; background: #dae2fd; color: #1677FF; overflow: hidden; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 28rpx; font-weight: 700; }
.av.self { background: #1677FF; color: #fff; }
.av image { width: 80rpx; height: 80rpx; }
.who { font-size: 20rpx; color: #888; margin-bottom: 4rpx; }
.ball { background: #fff; padding: 16rpx 20rpx; border-radius: 8rpx; font-size: 30rpx; word-break: break-all; line-height: 1.45; }
.ball.self { background: #95ec69; color: #111; }
.ball.work { background: #fff; }
.ball.voice { min-width: 140rpx; }
.quote { background: rgba(0,0,0,.06); color: #666; font-size: 22rpx; padding: 8rpx 12rpx; border-radius: 6rpx; margin-bottom: 10rpx; }
.pic { max-width: 420rpx; border-radius: 8rpx; display: block; }
.file, .cardtxt { display: flex; flex-direction: column; }
.fn, .ct { font-weight: 600; }
.fs, .cs { font-size: 22rpx; margin-top: 6rpx; color: #1677FF; }
.tm { font-size: 20rpx; color: #b2b2b2; margin-top: 6rpx; }
.tm.read { color: #07c160; }
.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: 360rpx; height: 280rpx; background: rgba(0,0,0,.72); color: #fff; border-radius: 20rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; font-size: 26rpx; }
.recbox.cancel { background: rgba(186,26,26,.85); }
.recsec { font-size: 48rpx; font-weight: 700; margin-bottom: 12rpx; }
.composer { background: #f7f7f7; border-top: 1rpx solid #e5e5e5; }
.qbar { display: flex; align-items: center; padding: 10rpx 20rpx 0; }
.qt { flex: 1; font-size: 22rpx; color: #888; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.qx { padding: 0 12rpx; color: #888; }
.bar { display: flex; align-items: flex-end; padding: 12rpx 12rpx 0; gap: 10rpx; }
.tool { width: 64rpx; height: 64rpx; text-align: center; line-height: 64rpx; font-size: 36rpx; color: #111; }
.plus { font-size: 44rpx; font-weight: 300; }
.box { flex: 1; min-height: 72rpx; background: #fff; border-radius: 8rpx; display: flex; align-items: center; padding: 0 16rpx; }
.box input { flex: 1; height: 72rpx; }
.hold {
flex: 1;
height: 72rpx;
background: #fff;
border-radius: 8rpx;
text-align: center;
line-height: 72rpx;
font-size: 28rpx;
font-weight: 600;
color: #111;
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
.go { height: 64rpx; padding: 0 24rpx; background: #07c160; color: #fff; border-radius: 8rpx; font-size: 26rpx; font-weight: 600; display: flex; align-items: center; margin-bottom: 4rpx; }
.panel { display: flex; flex-wrap: wrap; background: #f7f7f7; padding: 20rpx 24rpx 24rpx; gap: 24rpx; }
.panel.emo { gap: 8rpx; }
.tile { width: 140rpx; height: 140rpx; background: #fff; border-radius: 16rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; font-size: 22rpx; color: #333; }
.te { font-size: 48rpx; margin-bottom: 8rpx; }
.em { width: 12.5%; text-align: center; font-size: 44rpx; line-height: 88rpx; }
.hint { text-align: center; font-size: 22rpx; color: #888; padding: 20rpx; }
.ico { padding: 8rpx 16rpx; font-size: 36rpx; }
.mask { position: fixed; left: 0; right: 0; top: 0; bottom: 0; background: rgba(0,0,0,.45); z-index: 40; display: flex; align-items: flex-end; }
.sheet { width: 100%; background: #fff; border-radius: 24rpx 24rpx 0 0; padding: 28rpx 28rpx 12rpx; }
.sheet-t { display: block; text-align: center; font-size: 30rpx; font-weight: 700; }
.sheet-s { display: block; text-align: center; font-size: 22rpx; color: #888; 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: #f7f7f7; border-radius: 16rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; font-size: 22rpx; color: #333; }
.ce { font-size: 36rpx; margin-bottom: 8rpx; color: #111; font-weight: 700; }
.sheet-cancel { margin: 20rpx 0 8rpx; height: 88rpx; border-radius: 16rpx; background: #f7f7f7; display: flex; align-items: center; justify-content: center; font-size: 28rpx; }
.selbar { display: flex; background: #fff; border-top: 1rpx solid #eee; }
.selbtn { flex: 1; height: 96rpx; display: flex; align-items: center; justify-content: center; font-size: 28rpx; }
.selbtn.danger { color: #fa5151; }
</style>