1101 lines
29 KiB
Plaintext
1101 lines
29 KiB
Plaintext
<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> |