Files
akmon/pages/sport/chat/room.uvue
2026-01-20 08:04:15 +08:00

1101 lines
29 KiB
Plaintext
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">
<view class="header">
<view class="title-row">
<view class="title-left">
<text class="title">{{ roomTitle }}</text>
<text v-if="presenceText.length > 0" class="presence">{{ presenceText }}</text>
</view>
<view class="info-button" @click="toggleInfoPanel">
<text class="info-icon">⋯</text>
</view>
</view>
</view>
<view v-if="showInfoPanel" class="room-info-overlay" @click="closeInfoPanel">
<view class="room-info-card" @click.stop="">
<view class="info-header">
<text class="info-title">{{ roomTitle }}</text>
<text class="info-subtitle">{{ participants.length }} 位成员</text>
</view>
<view class="info-metadata">
<text class="info-meta-row">房间ID{{ roomInfo?.id != null ? roomInfo.id : convId }}</text>
<text class="info-meta-row">类型:{{ roomInfo?.is_group == true ? '群聊' : '单聊' }}</text>
<text v-if="roomInfo?.owner_id" class="info-meta-row">创建者:{{ roomInfo?.owner_id }}</text>
</view>
<view class="info-section">
<view v-for="member in participants" :key="member.id" class="member-row">
<image class="member-avatar"
:src="member.user?.avatar_url != null ? member.user.avatar_url : defaultAvatar"></image>
<view class="member-texts">
<text class="member-name">{{ formatMemberName(member) }}</text>
<text v-if="member.user?.email" class="member-extra">{{ member.user?.email }}</text>
</view>
</view>
<view v-if="participants.length == 0" class="info-empty">暂无成员信息</view>
</view>
<view class="info-actions">
<button class="close-btn" @click="closeInfoPanel">关闭</button>
</view>
</view>
</view>
<scroll-view class="messages" direction="vertical" :scroll-into-view="scrollIntoView" refresher-enabled="true"
:refresher-triggered="isRefreshing" @refresherrefresh="onRefresh">
<view v-for="msg in messages" :key="msg.id" :id="msg.anchorId" :class="msg.rowExtraClass">
<image class="avatar" :src="msg.avatar"></image>
<view class="bubble" :class="msg.bubbleExtraClass" :style="msg.bubbleInlineStyle">
<text v-if="!msg.isMe && msg.senderName != null && msg.senderName !== ''"
class="sender-name">{{ msg.senderName }}</text>
<view v-if="msg.content_type == 'audio'" class="audio">
<button class="audio-btn" :data-id="msg.id" @click="play(msg)">播放语音</button>
<text class="meta">{{ msg.durationSeconds }}s</text>
</view>
<view v-else-if="msg.content_type == 'location'" class="location" :data-id="msg.id"
@click="onOpenMap">
<text>📍 {{ msg.locationTitle }}</text>
<text class="meta">{{ msg.content }}</text>
</view>
<view v-else-if="msg.content_type == 'presence'" class="presence-line">
<text class="meta">{{ msg.content }}</text>
</view>
<view v-else-if="msg.content_type == 'video'">
<video :src="msg.videoSrc" controls class="video-box"></video>
</view>
<view v-else>
<text class="text">{{ msg.content }}</text>
</view>
<view class="info-row">
<text v-if="msg.ingressLabel.length > 0" class="ingress-tag">{{ msg.ingressLabel }}</text>
<text class="time">{{ msg.timeText }}</text>
</view>
</view>
</view>
<view v-if="messages.length == 0" class="empty">暂无消息</view>
</scroll-view>
<view class="composer">
<input class="input" :value="draft" @input="onInput" @confirm="send" confirm-type="send"
placeholder="输入消息..." />
<button class="send" :disabled="draft == ''" @click="send">发送</button>
<!-- <button class="audio-rec" @touchstart="startRec" @touchend="stopRec">按住说话</button> -->
<!-- <button class="downlink" @click="downlinkText">下发</button> -->
</view>
</view>
</template>
<script setup lang="uts">
import { ref, nextTick } from 'vue'
import { ChatDataService, type ChatMessage, type ViewMessage, type SubscriptionDisposer, type UserOption, type ChatParticipantWithProfile, type ChatConversation, type ChatAudioContext } from '@/utils/chatDataService.uts'
import { getCurrentUser } from '@/utils/store.uts'
import AudioUploadService from '@/utils/audioUploadService.uts'
function buildTimeText(iso : string) : string {
if (typeof iso !== 'string' || iso == '') return ''
try {
const d = new Date(iso as string)
if (isNaN(d.getTime())) return ''
const now = new Date()
const pad = (n : number) => (n < 10 ? ('0' + n) : ('' + n))
const sameDay = d.getFullYear() == now.getFullYear() && d.getMonth() == now.getMonth() && d.getDate() == now.getDate()
const hm = pad(d.getHours()) + ':' + pad(d.getMinutes())
if (sameDay) return hm
return pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + hm
} catch (_) { return '' }
}
function makeTempMessageId() : string {
const ts = Date.now().toString()
const rand = Math.floor(Math.random() * 100000).toString()
return 'tmp-' + ts + '-' + rand
}
function formatIngressLabel(type : string | null | undefined) : string {
if (type == null || type == '') return ''
switch (type) {
case 'manual':
return '人工'
case 'device':
return '设备'
case 'system':
return '系统'
default:
return type as string
}
}
const messages = ref<Array<ViewMessage>>([])
const meId = ref('')
const convId = ref('')
const draft = ref('')
const scrollIntoView = ref('')
const presenceText = ref('')
const peerIdCache = ref<string | null>(null)
const roomTitle = ref('会话')
const showInfoPanel = ref(false)
const participants = ref<Array<ChatParticipantWithProfile>>([])
const roomInfo = ref<ChatConversation | null>(null)
const defaultAvatar = '/static/logo.png'
const isRefreshing = ref(false)
type PendingMessageHint = {
senderId : string
content : string
contentType : string
createdAt : string
}
const pendingMessageHints = new Map<string, PendingMessageHint>()
type RecorderStartConfig = {
duration ?: number
sampleRate ?: number
numberOfChannels ?: number
encodeBitRate ?: number
format ?: string
frameSize ?: number
audioSource ?: string
}
type RecorderStopPayload = {
tempFilePath ?: string
duration ?: number
fileSize ?: number
}
type RecorderErrorPayload = {
errMsg ?: string
errCode ?: number
}
let msgSub : SubscriptionDisposer | null = null
let audioCtx : InnerAudioContext | null = null
let recorder : RecorderManager | null = null
let recorderBound = false
let recStartAt = 0
async function loadRoomMeta() {
if (convId.value == '') return
try {
const convRes = await ChatDataService.getConversation(convId.value)
if (convRes.status >= 200 && convRes.status < 300 && convRes.data != null) {
const conv = convRes.data as ChatConversation
roomInfo.value = conv
const title = (conv.title ?? '').trim()
roomTitle.value = title !== '' ? title : '会话'
} else {
roomInfo.value = null
roomTitle.value = '会话'
}
} catch (_) {
roomInfo.value = null
roomTitle.value = '会话'
}
try {
const memberRes = await ChatDataService.listParticipantsWithProfile(convId.value)
if (memberRes.status >= 200 && memberRes.status < 300 && memberRes.data != null) {
const raw = Array.isArray(memberRes.data) ? (memberRes.data as any[]) : []
participants.value = raw as Array<ChatParticipantWithProfile>
} else {
participants.value = []
}
} catch (_) {
participants.value = []
}
}
function formatMemberName(member : ChatParticipantWithProfile) : string {
const profile = member.user ?? null
if (profile == null) {
return '成员'
}
const nickname = profile.nickname as string | null
const realName = profile.real_name as string | null
const username = profile.username as string | null
if (nickname != null && nickname !== '') return nickname
if (realName != null && realName !== '') return realName
if (username != null && username !== '') return username
return '成员'
}
async function toggleInfoPanel() {
if (!showInfoPanel.value) {
await loadRoomMeta()
showInfoPanel.value = true
} else {
showInfoPanel.value = false
}
}
function closeInfoPanel() {
showInfoPanel.value = false
}
async function updateVideoSrc(vm : ViewMessage) {
try {
if (vm.content_type == 'video') {
const local = await ChatDataService.resolveVideoSource(vm.content)
if (local != null) { vm.videoSrc = local as string }
}
} catch (_) { }
}
function gen_deriveView(m : ChatMessage) : ViewMessage {
const normalizedType = m.content_type?.trim() ?? ''
const rawId = m.id as string | null
const ensuredId = (rawId != null && rawId !== '') ? rawId : makeTempMessageId(); m.id = ensuredId
m.content_type = normalizedType
const vm : ViewMessage = {
id: ensuredId,
conversation_id: (m.conversation_id as string) ?? '',
sender_id: (m.sender_id as string) ?? '',
content: '',
content_type: normalizedType,
reply_to: (m.reply_to as string | null) ?? null,
metadata: m.metadata,
created_at: (typeof m.created_at == 'string' && m.created_at !== '') ? (m.created_at as string) : new Date().toISOString(),
updated_at: (typeof m.updated_at == 'string' && m.updated_at !== '') ? (m.updated_at as string) : new Date().toISOString(),
ingress_type: (m.ingress_type as string | null) ?? null,
videoSrc: '',
timeText: '',
avatar: '',
senderName: '',
isMe: false,
bubbleExtraClass: '',
rowExtraClass: '',
anchorId: '',
bubbleInlineStyle: '',
ingressType: null,
ingressLabel: '',
durationSeconds: 0,
locationTitle: null,
}
vm.isMe = (m.sender_id == meId.value)
// console.log(m.sender_id, meId.value, vm.isMe)
vm.bubbleExtraClass = vm.isMe ? 'me' : ''
// console.log(vm.bubbleExtraClass)
vm.rowExtraClass = vm.isMe ? 'msg-row me' : 'msg-row peer'
// console.log(vm.rowExtraClass)
vm.anchorId = 'm-' + ensuredId
console.log(m)
const profile = m.sender_profile
if (profile != null) {
const nickname = profile.nickname as string | null
const username = profile.username as string | null
const realName = profile.real_name as string | null
const avatarUrl = profile.avatar_url as string | null
console.log(avatarUrl)
if (avatarUrl != null && avatarUrl !== '') {
vm.avatar = avatarUrl as string
}
const display = (nickname != null && nickname !== '') ? nickname : ((realName != null && realName !== '') ? realName : (username ?? ''))
vm.senderName = display ?? ''
} else {
vm.senderName = ''
}
if (typeof vm.avatar !== 'string' || vm.avatar == '') {
vm.avatar = '/static/logo.png'
}
const ingress = m.ingress_type as string | null
vm.ingressType = ingress
vm.ingress_type = ingress
vm.ingressLabel = formatIngressLabel(vm.ingressType)
const rawContent = m.content as string | null
vm.content = rawContent != null ? rawContent : ''
if (normalizedType == 'audio') {
let durMs = 0
const md = m.metadata
if (md != null) {
const v = md['duration_sec'] as number | null
if (v != null) {
durMs = v
}
}
vm.durationSeconds = durMs
} else {
vm.durationSeconds = 0
}
if (normalizedType == 'location') {
let title = '位置'
const md2 = m.metadata
if (md2 != null) {
const t = md2.getString('title')
if (t != null) { title = t as string }
}
vm.locationTitle = title
} else {
vm.locationTitle = null
}
vm.videoSrc = (normalizedType == 'video') ? vm.content : ''
const baseBubbleStyle = 'display:flex;flex-direction:column;'
vm.bubbleInlineStyle = vm.isMe ? (baseBubbleStyle + 'align-items:flex-end;') : (baseBubbleStyle + 'align-items:flex-start;')
const rawCreated = m.created_at
const iso = (typeof rawCreated == 'string' && rawCreated !== '') ? rawCreated : new Date().toISOString()
vm.timeText = buildTimeText(iso)
return vm
}
function matchPendingTempId(msg : ChatMessage) : string | null {
const sender = (msg.sender_id as string) ?? ''
if (sender == '') return null
const content = (() => {
const raw = msg.content
if (typeof raw == 'string') return raw
if (raw != null) return '' + raw
return ''
})()
const contentType = msg.content_type?.trim() ?? ''
const createdAt = (typeof msg.created_at == 'string') ? msg.created_at : ''
const keys = Array.from(pendingMessageHints.keys)
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
const hint = pendingMessageHints.get(key)
if (hint == null) continue
if (hint.senderId !== sender) continue
if (hint.content !== content) continue
if (hint.contentType !== contentType) continue
if (hint.createdAt !== '' && createdAt !== '' && hint.createdAt !== createdAt) continue
return key as string | null
}
return null
}
async function updateVideoSource(vm : ViewMessage) {
try {
if (vm.content_type == 'video') {
const local = await ChatDataService.resolveVideoSource(vm.content)
if (local != null) { vm.videoSrc = local as string }
}
} catch (_) { }
}
function removeTempMessage(tempId : string) {
for (let i = 0; i < messages.value.length; i++) {
if ((messages.value[i].id as string) == tempId) {
messages.value.splice(i, 1)
messages.value = messages.value.slice()
return
}
}
}
function addMsgIfNew(m : ChatMessage) {
const msg = m
if (msg == null) { return }
const rawId = msg.id
const ensuredId = (typeof rawId == 'string' && rawId !== '') ? rawId : makeTempMessageId()
msg.id = ensuredId
const senderId = (msg.sender_id as string) ?? ''
const normalizedContent = (() => {
const c = msg.content
return typeof c == 'string' ? c : (c != null ? ('' + c) : '')
})()
const ctype = msg.content_type?.trim() ?? ''
msg.content_type = ctype
let tempRemoved = false
for (let j = messages.value.length - 1; j >= 0; j--) {
const existingId : string = (messages.value[j].id as string) ?? ''
if (existingId.indexOf('tmp-') == 0) {
const sameSender = ((messages.value[j].sender_id as string) ?? '') == senderId
const sameContent = ((messages.value[j].content as string) ?? '') == normalizedContent
const sameType = ((messages.value[j].content_type as string) ?? '') == ctype
if (sameSender && sameContent && sameType) {
messages.value.splice(j, 1)
tempRemoved = true
}
}
}
if (tempRemoved) { messages.value = messages.value.slice() }
let exists = false
for (let i = 0; i < messages.value.length; i++) {
if ((messages.value[i].id as string) == ensuredId) { exists = true; break }
}
if (!exists) {
const vm = gen_deriveView(msg)
messages.value.push(vm)
messages.value = messages.value.slice()
if (vm.content_type == 'video') { updateVideoSrc(vm) }
}
if (msg.content_type == 'presence') {
const contentStr = msg.content as string | null
presenceText.value = contentStr != null ? contentStr : ''
}
}
function replaceTempMessage(tempId : string, raw : ChatMessage) {
const msg = raw
if (msg == null) { return }
const rawRealId = msg.id
const realId = (typeof rawRealId == 'string' && rawRealId !== '') ? (rawRealId as string) : tempId
for (let i = 0; i < messages.value.length; i++) {
if ((messages.value[i].id as string) == tempId) {
const vm = gen_deriveView(msg)
for (let j = messages.value.length - 1; j >= 0; j--) {
if (j !== i && (messages.value[j].id as string) == realId) {
messages.value.splice(j, 1)
}
}
messages.value.splice(i, 1, vm)
messages.value = messages.value.slice()
if (vm.content_type == 'video') { updateVideoSrc(vm) }
return
}
}
addMsgIfNew(msg)
}
function onInput(e : UniInputEvent) {
const v = e.detail.value
draft.value = v ?? ''
}
function scrollNext() {
if (messages.value.length == 0) return
const last = messages.value[messages.value.length - 1]
const target = last.anchorId
if (typeof target !== 'string' || target == '') return
scrollIntoView.value = ''
nextTick(() => {
scrollIntoView.value = target
})
}
async function initLoad() {
try {
if (msgSub != null) {
const sub = msgSub as SubscriptionDisposer
if (typeof sub.dispose == 'function') {
sub.dispose()
}
}
} catch (_) { }
msgSub = null
messages.value = []
pendingMessageHints.clear()
showInfoPanel.value = false
roomInfo.value = null
roomTitle.value = '会话'
participants.value = []
const me = await getCurrentUser()
if (me == null || me.id == null) return
meId.value = me.id as string
if (convId.value == '') return
await loadRoomMeta()
const res = await ChatDataService.listMessages(convId.value, 100)
const rawList = Array.isArray(res.data) ? (res.data as any[]) : []
const arr : Array<ChatMessage> = rawList as ChatMessage[]
const vms : Array<ViewMessage> = []
for (let i = 0; i < arr.length; i++) {
vms.push(gen_deriveView(arr[i]))
}
messages.value = vms
for (let j = 0; j < vms.length; j++) {
if (vms[j].content_type == 'video') { updateVideoSrc(vms[j]) }
}
peerIdCache.value = await ChatDataService.getPeerId(convId.value, meId.value)
scrollNext()
msgSub = await ChatDataService.subscribeMessages(convId.value, (msg : ChatMessage) => {
const matchedTempId = matchPendingTempId(msg)
if (matchedTempId != null) {
pendingMessageHints.delete(matchedTempId)
replaceTempMessage(matchedTempId, msg)
} else {
addMsgIfNew(msg)
}
scrollNext()
})
}
async function onRefresh() {
if (isRefreshing.value) {
return
}
isRefreshing.value = true
try {
await initLoad()
} catch (_) {
} finally {
isRefreshing.value = false
}
}
async function send() {
const text = draft.value.trim()
if (text == '') return
const me = await getCurrentUser()
if (me == null || me.id == null) return
const pendingId = makeTempMessageId()
const nowIso = new Date().toISOString()
const pendingProfile : UserOption = {
id: me.id as string,
username: (me['username'] as string | null) ?? null,
nickname: (me['nickname'] as string | null) ?? null,
real_name: (me['real_name'] as string | null) ?? null,
email: (me['email'] as string | null) ?? null,
avatar_url: (me['avatar_url'] as string | null) ?? null,
phone: (me['phone'] as string | null) ?? null,
}
const pendingMsg = {
id: pendingId,
conversation_id: convId.value,
sender_id: me.id as string,
content: text,
content_type: 'text',
reply_to: null,
metadata: null,
created_at: nowIso,
updated_at: nowIso,
ingress_type: 'manual',
sender_profile: pendingProfile,
} as ChatMessage
const pendingView = gen_deriveView(pendingMsg)
messages.value = messages.value.concat([pendingView])
scrollNext()
draft.value = ''
pendingMessageHints.set(pendingId, {
senderId: me.id as string,
content: text,
contentType: 'text',
createdAt: nowIso
})
let sendOk = false
try {
const res = await ChatDataService.sendMessage(convId.value, me.id as string, text)
const ok = res.status >= 200 && res.status < 300
if (ok) {
sendOk = true
if (res.data != null) {
const normalized = res.data as ChatMessage
if (normalized != null) {
replaceTempMessage(pendingId, normalized)
pendingMessageHints.delete(pendingId)
}
}
scrollNext()
}
} catch (_) {
}
if (!sendOk) {
pendingMessageHints.delete(pendingId)
removeTempMessage(pendingId)
draft.value = text
uni.showToast({ title: '发送失败', icon: 'none' })
}
}
async function handleRecorderStop(payload : RecorderStopPayload) : Promise<void> {
const tempFilePath = payload.tempFilePath ?? ''
const durationFromEvent = payload.duration ?? 0
const durationMs = durationFromEvent > 0 ? durationFromEvent : (Date.now() - recStartAt)
if (tempFilePath == '') {
uni.showToast({ title: '录音数据异常', icon: 'none' })
return
}
try {
const uploadRes = await AudioUploadService.uploadAudio(tempFilePath, { filename: ('audio_' + Date.now() + '.mp3') as string, mime: 'audio/mpeg' })
if (uploadRes.success && uploadRes.url != null) {
const me = await getCurrentUser()
if (me != null && me.id != null) {
await ChatDataService.sendAudioMessage({ conversationId: convId.value, senderId: me.id as string, s3Url: uploadRes.url as string, durationMs })
}
} else {
uni.showToast({ title: '语音上传失败', icon: 'none' })
}
} catch (_) {
uni.showToast({ title: '录音失败', icon: 'none' })
}
}
function handleRecorderError(err : RecorderErrorPayload) {
const msg = (err.errMsg != null && err.errMsg !== '') ? err.errMsg : '录音失败'
uni.showToast({ title: msg, icon: 'none' })
}
function ensureRecorder() {
if (recorder == null) {
recorder = uni.getRecorderManager()
}
if (recorder == null || recorderBound) {
return
}
const safeRecorder = recorder as RecorderManager
safeRecorder.onStop((payload : RecorderManagerOnStopResult) => {
handleRecorderStop(payload as RecorderStopPayload)
})
safeRecorder.onError((err : IRecorderManagerFail) => {
handleRecorderError(err as RecorderErrorPayload)
})
recorderBound = true
}
function startRec() {
ensureRecorder()
if (recorder == null) return
recStartAt = Date.now()
const safeRecorder = recorder as RecorderManager
safeRecorder.start({ format: 'mp3', duration: 60000 } as RecorderManagerStartOptions)
}
async function stopRec() {
if (recorder == null) return
recorder?.stop()
}
async function play(m : ViewMessage) {
try {
if (audioCtx != null) {
const safeCtx = audioCtx as ChatAudioContext
safeCtx.stop?.()
safeCtx.destroy?.()
}
} catch (_) { }
audioCtx = null
const ctx = await ChatDataService.playAudio(m.content)
if (ctx != null) {
audioCtx = ctx
}
}
async function downlinkText() {
const text = draft.value.trim()
if (text == '') { uni.showToast({ title: '请输入下发内容', icon: 'none' }); return }
const me = await getCurrentUser()
if (me == null || me.id == null) return
const res = await ChatDataService.sendDeviceTextDownlink({ conversationId: convId.value, createdBy: me.id as string, text, targetUserId: peerIdCache.value })
if (res.status >= 200 && res.status < 300) {
uni.showToast({ title: '已下发', icon: 'success' })
draft.value = ''
} else {
uni.showToast({ title: '下发失败', icon: 'none' })
}
}
function onOpenMap(e : any) {
const event = e as UTSJSONObject
const ct = event['currentTarget'] as UTSJSONObject | null
const ds = ct != null ? (ct['dataset'] as UTSJSONObject | null) : null
const id = ds != null ? (ds['id'] as string | null) : null
if (id == null || id == '') return
let targetMsg : ChatMessage | null = null
for (let i = 0; i < messages.value.length; i++) {
if ((messages.value[i].id as string) == id) {
targetMsg = messages.value[i] as ChatMessage
break
}
}
if (targetMsg == null) return
try {
const msg = targetMsg as ChatMessage
const contentStr = msg.content != null ? msg.content as string : ''
const parts = contentStr.split(',')
const lat = parseFloat(parts.length > 0 ? parts[0] : '0')
const lng = parseFloat(parts.length > 1 ? parts[1] : '0')
const md = msg.metadata
let name = '位置'
if (md != null) {
const title = md['title'] as string | null
if (title != null) {
name = title
}
}
uni.openLocation({
latitude: lat,
longitude: lng,
name: name
} as OpenLocationOptions)
} catch (_) { }
}
onLoad((options : UTSJSONObject) => {
if (options != null) {
const opts = options
const cid = opts.getString('cid') as string | null
convId.value = cid != null ? cid : ''
}
initLoad()
})
onUnload(() => {
try {
if (msgSub != null) {
const sub = msgSub as SubscriptionDisposer
sub.dispose()
}
} catch (_) { }
msgSub = null
pendingMessageHints.clear()
try {
if (audioCtx != null) {
const ctx = audioCtx as ChatAudioContext
ctx.stop?.()
ctx.destroy?.()
}
} catch (_) { }
audioCtx = null
recorder = null
recorderBound = false
})
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 100%;
}
.header {
padding: 12rpx 16rpx;
border-bottom: 1px solid #eee
}
.title-row {
display: flex;
align-items: center;
justify-content: space-between
}
.title-left {
display: flex;
align-items: center;
gap: 8rpx
}
.info-button {
display: flex;
align-items: center;
justify-content: center;
padding: 8rpx 16rpx;
border-radius: 24rpx;
background: rgba(0, 0, 0, 0.06)
}
.info-icon {
font-size: 32rpx;
line-height: 32rpx;
color: #666
}
.room-info-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
justify-content: flex-end;
padding: 24rpx;
box-sizing: border-box;
z-index: 1000
}
.room-info-card {
width: 560rpx;
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
display: flex;
flex-direction: column;
max-height: 90vh
}
.info-header {
display: flex;
flex-direction: column;
gap: 8rpx;
margin-bottom: 16rpx
}
.info-title {
font-size: 32rpx;
font-weight: 600;
color: #222
}
.info-subtitle {
font-size: 24rpx;
color: #666
}
.info-section {
flex: 1;
overflow-y: auto;
padding-right: 8rpx
}
.info-metadata {
display: flex;
flex-direction: column;
gap: 6rpx;
margin-bottom: 12rpx;
color: #555;
font-size: 24rpx
}
.info-meta-row {
line-height: 32rpx
}
.member-row {
display: flex;
align-items: center;
padding: 12rpx 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.06)
}
.member-row:last-child {
border-bottom: none
}
.member-avatar {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: #eee;
margin-right: 16rpx
}
.member-texts {
display: flex;
flex-direction: column;
gap: 4rpx
}
.member-name {
font-size: 28rpx;
color: #333
}
.member-extra {
font-size: 22rpx;
color: #888
}
.info-empty {
text-align: center;
color: #999;
font-size: 24rpx;
padding: 40rpx 0
}
.info-actions {
display: flex;
justify-content: flex-end;
margin-top: 16rpx
}
.close-btn {
background: #667eea;
color: #fff;
padding: 12rpx 32rpx;
border-radius: 20rpx;
font-size: 26rpx
}
.close-btn::after {
display: none
}
.title {
font-size: 32rpx;
font-weight: 600;
}
.presence {
font-size: 24rpx;
color: #2f855a;
margin-left: 12rpx
}
.messages {
flex: 1;
padding: 12rpx 16rpx 32rpx
}
.msg-row {
display: flex;
align-items: flex-end;
justify-content: flex-start;
margin: 12rpx 0;
width: 100%;
flex-direction: row-reverse;
}
.msg-row.peer {
justify-content: flex-start;
flex-direction: row;
}
.msg-row.avatar {
margin-right: 10rpx;
}
.msg-row.me {
flex-direction: row-reverse;
justify-content: flex-start;
}
.msg-row.me .avatar {
margin-right: 0;
margin-left: 10rpx;
}
.avatar {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: #eee
}
.bubble {
max-width: 70%;
background: #f2f3f5;
border-radius: 12rpx;
padding: 12rpx 16rpx;
position: relative
}
.sender-name {
display: block;
font-size: 22rpx;
color: #666;
margin-bottom: 6rpx
}
.bubble.me {
background: #667eea
}
.text {
white-space: pre-wrap;
word-break: break-word;
font-size: 26rpx;
}
.meta {
color: #888;
font-size: 22rpx;
margin-left: 8rpx
}
.time {
display: block;
color: #999;
font-size: 15rpx;
margin-top: 6rpx
}
.bubble.me .text,
.bubble.me .sender-name,
.bubble.me .time {
color: #fff;
text-align: right;
}
.bubble.me .meta {
color: rgba(255, 255, 255, 0.85);
text-align: right;
margin-left: 0;
margin-right: 8rpx;
}
.info-row {
display: flex;
align-items: center;
gap: 8rpx;
margin-top: 6rpx
}
.bubble .info-row {
justify-content: flex-start;
flex-direction: row;
font-size: small;
}
.bubble.me .info-row {
justify-content: flex-end;
flex-direction: row;
font-size: small;
}
.ingress-tag {
font-size: 15rpx;
color: #666;
background: rgba(0, 0, 0, 0.06);
padding: 2rpx 8rpx;
border-radius: 8rpx
}
.bubble.me .ingress-tag {
color: rgba(255, 255, 255, 0.85);
background: rgba(255, 255, 255, 0.24)
}
.audio {
display: flex;
align-items: center;
flex-direction: row;
}
.audio .audio-btn {
margin-right: 10rpx;
}
.location {
display: flex;
flex-direction: column
}
.video-box {
width: 480rpx;
height: 320rpx;
border-radius: 12rpx;
overflow: hidden
}
.composer {
display: flex;
flex-direction: row;
padding: 10rpx;
border-top: 1px solid #eee;
background: #fff;
box-sizing: border-box;
flex-shrink: 0
}
.input {
flex: 1;
border: 1px solid #ddd;
border-radius: 8rpx;
padding: 10rpx;
margin-right: 10rpx
}
.send {
padding: 0 16rpx
}
.audio-rec {
margin-left: 6rpx;
padding: 0 16rpx;
border: 1px solid #ddd;
border-radius: 8rpx;
background: #fff
}
.downlink {
margin-left: 6rpx;
padding: 0 16rpx;
border: 1px solid #ddd;
border-radius: 8rpx;
background: #fff
}
</style>