1202 lines
32 KiB
Plaintext
1202 lines
32 KiB
Plaintext
<template>
|
|
<view class="page">
|
|
<view class="header">
|
|
<text class="title">聊天与通知</text>
|
|
<view class="actions">
|
|
<!-- <button class="btn" @click="goAI">AI 助手</button> -->
|
|
<button class="btn primary" @click="openCreate">新建会话</button>
|
|
</view>
|
|
</view>
|
|
<scroll-view ref="tabScroll" class="tabs" direction="horizontal" :show-scrollbar="false">
|
|
<view class="tabs-row">
|
|
<view v-for="(tab, index) in tabItems" :key="tab.key" class="tabs-item-wrapper"
|
|
@click="onTabClick(index)">
|
|
<text ref="tabItem" class="tabs-item" :class="swiperIndex===index ? 'active' : ''">
|
|
{{ tab.label }}
|
|
</text>
|
|
<view
|
|
v-if="formatBadgeCount(tab.key === 'conversations' ? unreadMessageCount : unreadNotificationCount)"
|
|
class="tabs-badge">
|
|
<text class="tabs-badge-text">
|
|
{{ formatBadgeCount(tab.key === 'conversations' ? unreadMessageCount : unreadNotificationCount) }}
|
|
</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
<view ref="indicator" class="tabs-indicator"></view>
|
|
</scroll-view>
|
|
|
|
<swiper ref="swiperEl" class="swiper-view" :current="swiperIndex" @transition="onSwiperTransition"
|
|
@animationfinish="onSwiperAnimationfinish">
|
|
<swiper-item class="swiper-pane">
|
|
<scroll-view class="list" direction="vertical">
|
|
<view v-for="conv in conversations" :key="conv.id" class="item" @click="openRoom(conv)">
|
|
<view class="item-avatar" :class="conv.is_group ? 'group' : ''">
|
|
<template v-if="conv.is_group">
|
|
<template v-if="conv.avatarMembers != null && conv.avatarMembers.length > 0"
|
|
class="avatar-info">
|
|
<view v-for="(member, idx) in conv.avatarMembers" :key="member.id"
|
|
class="avatar-circle group" :style="groupAvatarStyle(idx)">
|
|
<image v-if="hasMemberAvatar(member)" :src="member.avatar" mode="aspectFill" />
|
|
<text v-else class="avatar-text">{{ member.initials }}</text>
|
|
</view>
|
|
</template>
|
|
<view v-else class="avatar-circle group">
|
|
<text class="avatar-text">{{ getConversationInitials(conv) }}</text>
|
|
</view>
|
|
</template>
|
|
<template v-else>
|
|
<view class="avatar-circle">
|
|
<image v-if="hasPrimaryAvatar(conv)" :src="getPrimaryAvatarUrl(conv)"
|
|
mode="aspectFill" />
|
|
<text v-else class="avatar-text">{{ getPrimaryAvatarInitials(conv) }}</text>
|
|
</view>
|
|
</template>
|
|
</view>
|
|
<view class="item-body">
|
|
<view class="item-main">
|
|
<text class="item-title">{{ getConversationTitle(conv) }}</text>
|
|
<text class="item-sub">{{ formatLocalTime(conv.last_message_at) }}</text>
|
|
</view>
|
|
<view class="item-meta">
|
|
<text class="item-time">{{ formatLocalTime(conv.updated_at) }}</text>
|
|
<view v-if="formatBadgeCount(conv.unreadCount)" class="item-badge">
|
|
<text class="item-badge-text">{{ formatBadgeCount(conv.unreadCount) }}</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
<view v-if="conversations.length===0" class="empty">暂无会话</view>
|
|
</scroll-view>
|
|
</swiper-item>
|
|
<swiper-item class="swiper-pane">
|
|
<scroll-view class="list" direction="vertical">
|
|
<view v-for="n in notifications" :key="n.id" class="item" @click="openMsg(n)">
|
|
<view class="item-avatar notification">
|
|
<view class="avatar-circle notification">
|
|
<text class="avatar-text">{{ getNotificationBadge(n) }}</text>
|
|
</view>
|
|
</view>
|
|
<view class="item-body">
|
|
<view class="item-main">
|
|
<text class="item-title">{{ n.type === 'message' ? '新消息' : '通知' }}</text>
|
|
<text class="item-sub">{{ n.message_id ?? '' }}</text>
|
|
</view>
|
|
<text class="item-time">{{ formatLocalTime(n.created_at) }}</text>
|
|
</view>
|
|
</view>
|
|
<view v-if="notifications.length===0" class="empty">暂无通知</view>
|
|
</scroll-view>
|
|
</swiper-item>
|
|
</swiper>
|
|
|
|
<!-- Create / Invite Modal -->
|
|
<view v-if="showCreate" class="modal-mask" @click="closeCreate">
|
|
<view class="modal" @click.stop>
|
|
<text class="modal-title">新建会话</text>
|
|
<view class="form-item">
|
|
<text class="label">群聊</text>
|
|
<switch :checked="isGroup" @change="(e : UniSwitchChangeEvent)=>{isGroup=e.detail.value}" />
|
|
</view>
|
|
<view class="form-item">
|
|
<text class="label">标题</text>
|
|
<input class="ipt" :value="title" @input="(e : UniInputChangeEvent)=>{title=e.detail.value}"
|
|
placeholder="可选,群聊建议填写" />
|
|
</view>
|
|
<view class="form-item">
|
|
<text class="label">搜索成员</text>
|
|
<input class="ipt" :value="query"
|
|
@input="(e : UniInputChangeEvent)=>{query=e.detail.value;search()}" placeholder="输入用户名/昵称/邮箱" />
|
|
</view>
|
|
<scroll-view class="search-list" direction="vertical">
|
|
<view v-for="u in results" :key="(u as UTSJSONObject)['id']" class="user-item"
|
|
@click="togglePick(u)">
|
|
<text
|
|
class="name">{{ (u as UTSJSONObject)['nickname'] ?? (u as UTSJSONObject)['username'] ?? (u as UTSJSONObject)['email'] ?? (u as UTSJSONObject)['id'] }}</text>
|
|
<text class="picked" v-if="pickedIds.includes((u as UTSJSONObject)['id'] as string)">已选</text>
|
|
</view>
|
|
<view v-if="results.length===0" class="empty small">无匹配结果</view>
|
|
</scroll-view>
|
|
<view class="modal-actions">
|
|
<button class="btn" @click="closeCreate">取消</button>
|
|
<button class="btn primary" :disabled="pickedIds.length===0" @click="submitCreate">创建</button>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup lang="uts">
|
|
import { supaReady } from '@/components/supadb/aksupainstance.uts'
|
|
import { ChatDataService, type ChatConversation, type ChatNotification, type ChatParticipantWithProfile } from '@/utils/chatDataService.uts'
|
|
import { getCurrentUser } from '@/utils/store.uts'
|
|
import MediaCacheService from '@/utils/mediaCacheService.uts'
|
|
import { getCurrentInstance, nextTick, onMounted, onUnmounted, ref as vueRef } from 'vue'
|
|
import { dayuts } from '@/uni_modules/lime-dayuts/common'
|
|
import { MemberProfile, ChatConversationView, CreateConversationPayload } from '@/utils/chatDataService.uts'
|
|
|
|
|
|
|
|
type TabRect = {
|
|
x : number
|
|
w : number
|
|
}
|
|
|
|
const tabItems = [
|
|
{ key: 'conversations', label: '会话' },
|
|
{ key: 'notifications', label: '通知' }
|
|
]
|
|
|
|
const swiperIndex = vueRef<number>(0)
|
|
const tabScroll = vueRef<UniElement | null>(null)
|
|
const indicator = vueRef<UniElement | null>(null)
|
|
const swiperEl = vueRef<UniElement | null>(null)
|
|
const swiperWidth = vueRef<number>(0)
|
|
const tabRects = vueRef<Array<TabRect>>([])
|
|
const animationFinishIndex = vueRef<number>(0)
|
|
|
|
const conversations = vueRef<Array<ChatConversationView>>([])
|
|
const notifications = vueRef<Array<ChatNotification>>([])
|
|
|
|
const unreadMessageCount = vueRef<number>(0)
|
|
const unreadNotificationCount = vueRef<number>(0)
|
|
|
|
const showCreate = vueRef<boolean>(false)
|
|
const isGroup = vueRef<boolean>(false)
|
|
const title = vueRef<string>('')
|
|
const query = vueRef<string>('')
|
|
const results = vueRef<Array<any>>([])
|
|
const pickedIds = vueRef<Array<string>>([])
|
|
|
|
type NotificationSubscription = {
|
|
dispose : () => void
|
|
}
|
|
type ChatNotificationSubscription = {
|
|
dispose : () => void
|
|
}
|
|
let notifSub : NotificationSubscription | null = null
|
|
let notificationSubscription : ChatNotificationSubscription | null = null
|
|
|
|
|
|
const instance = getCurrentInstance()
|
|
|
|
const DEFAULT_INITIAL = '?'
|
|
const MAX_GROUP_AVATARS = 3
|
|
|
|
|
|
function buildInitials(raw : string | null | undefined) : string {
|
|
if (raw == null) return DEFAULT_INITIAL
|
|
let text = (raw as string).trim()
|
|
if (text === '') return DEFAULT_INITIAL
|
|
if (text.includes('@')) {
|
|
text = text.split('@')[0]
|
|
}
|
|
const asciiLike = /^[A-Za-z0-9\s._-]+$/.test(text)
|
|
if (asciiLike) {
|
|
const parts = text.replace(/[_-]+/g, ' ').split(/\s+/).filter((w : string) => w !== '')
|
|
let letters = ''
|
|
for (let i = 0; i < parts.length && letters.length < 2; i++) {
|
|
const part = parts[i]
|
|
if (part.length > 0) {
|
|
letters += part.charAt(0)
|
|
}
|
|
}
|
|
if (letters.length === 0) {
|
|
letters = text.slice(0, 2)
|
|
}
|
|
return letters.toUpperCase()
|
|
}
|
|
return text.slice(0, 2)
|
|
}
|
|
|
|
|
|
function ensureInitials(raw : string | null | undefined) : string {
|
|
const initials = buildInitials(raw)
|
|
return initials == null || initials.trim() === '' ? DEFAULT_INITIAL : initials.trim()
|
|
}
|
|
|
|
async function resolveAvatarPath(src : string | null | undefined) : Promise<string | null> {
|
|
if (typeof src !== 'string') return null
|
|
const trimmed = (src as string).trim()
|
|
if (trimmed === '') return null
|
|
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
|
|
try {
|
|
return await MediaCacheService.getCachedPath(trimmed)
|
|
} catch (_) {
|
|
return trimmed
|
|
}
|
|
}
|
|
return trimmed
|
|
}
|
|
async function buildMemberProfile(participant : ChatParticipantWithProfile) : Promise<MemberProfile> {
|
|
const profile = participant.user
|
|
const candidates = new Array<string | null | undefined>()
|
|
candidates.push(profile?.['nickname'] as string | null | undefined)
|
|
candidates.push(profile?.['real_name'] as string | null | undefined)
|
|
candidates.push(profile?.['username'] as string | null | undefined)
|
|
candidates.push(profile?.['email'] as string | null | undefined)
|
|
candidates.push(profile?.['phone'] as string | null | undefined)
|
|
let display = ''
|
|
for (let i : Int = 0; i < candidates.length; i++) {
|
|
const candidate = candidates[i]
|
|
if (typeof candidate === 'string') {
|
|
const trimmed = (candidate as string).trim()
|
|
if (trimmed !== '') {
|
|
display = trimmed
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if (display === '') {
|
|
display = participant.user_id as string
|
|
}
|
|
const avatarSource = (profile?.avatar_url ?? '') as string | null
|
|
let avatarPath : string | null = null
|
|
if (typeof avatarSource === 'string') {
|
|
const trimmedAvatar = (avatarSource as string).trim()
|
|
if (trimmedAvatar !== '') {
|
|
avatarPath = await resolveAvatarPath(trimmedAvatar)
|
|
}
|
|
}
|
|
return {
|
|
id: participant.user_id as string,
|
|
name: display,
|
|
avatar: avatarPath,
|
|
initials: ensureInitials(display)
|
|
}
|
|
}
|
|
|
|
|
|
function buildGroupLabel(displayMembers : Array<MemberProfile>) : string {
|
|
const names = new Array<string>()
|
|
for (let i : Int = 0; i < displayMembers.length; i++) {
|
|
const memberName = displayMembers[i].name
|
|
if (typeof memberName === 'string') {
|
|
const trimmed = memberName.trim()
|
|
if (trimmed !== '') {
|
|
names.push(trimmed)
|
|
}
|
|
}
|
|
}
|
|
if (names.length === 0) {
|
|
return ''
|
|
}
|
|
let preview = ''
|
|
const previewLimit = names.length < 4 ? names.length : 4
|
|
for (let i : Int = 0; i < previewLimit; i++) {
|
|
const currentName = names[i]
|
|
preview = preview === '' ? currentName : `${preview}、${currentName}`
|
|
}
|
|
if (names.length > 4) {
|
|
preview = `${preview}…`
|
|
}
|
|
return preview
|
|
}
|
|
|
|
function pickAvatarMembers(source : Array<MemberProfile>, limit : number) : Array<MemberProfile> {
|
|
const result = new Array<MemberProfile>()
|
|
const max = source.length < limit ? source.length : limit
|
|
for (let i : Int = 0; i < max; i++) {
|
|
result.push(source[i])
|
|
}
|
|
return result
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatLocalTime(value : string | null | undefined) : string {
|
|
if (value == null || value === '') return ''
|
|
try {
|
|
const dt = dayuts(value)
|
|
if (!dt.isValid()) return value as string
|
|
return dt.format('YYYY-MM-DD HH:mm')
|
|
} catch (_) {
|
|
try {
|
|
return new Date(value as string).toLocaleString()
|
|
} catch (_) {
|
|
return value as string
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
function getConversationTitle(conv : ChatConversationView) : string {
|
|
const rawTitle = typeof conv.title === 'string' ? conv.title.trim() : ''
|
|
if (rawTitle.length > 0) return rawTitle
|
|
if (conv.is_group) {
|
|
if (conv.memberNames != null && conv.memberNames.length > 0) return conv.memberNames
|
|
return '群聊'
|
|
}
|
|
if (conv.memberNames != null && conv.memberNames.length > 0) return conv.memberNames
|
|
return '私聊'
|
|
}
|
|
|
|
|
|
function getConversationInitials(conv : ChatConversationView) : string {
|
|
return ensureInitials(getConversationTitle(conv))
|
|
}
|
|
|
|
function lerpNumber(value1 : number, value2 : number, amount : number) : number {
|
|
return value1 + (value2 - value1) * amount
|
|
}
|
|
function updateTabIndicator(currentIndex : number, moveToIndex : number, percentage : number) {
|
|
const currentSize = tabRects.value[currentIndex]
|
|
const moveToSize = tabRects.value[moveToIndex]
|
|
if (currentSize == null || moveToSize == null) return
|
|
|
|
const indicatorLineX = lerpNumber(currentSize.x, moveToSize.x, percentage)
|
|
const indicatorLineW = lerpNumber(currentSize.w, moveToSize.w, percentage)
|
|
const indicatorNode = indicator.value
|
|
if (indicatorNode == null || indicatorNode.style == null) return
|
|
|
|
let translateX = indicatorLineX
|
|
const indicatorWidth = indicatorLineW
|
|
|
|
// #ifdef APP
|
|
translateX = indicatorLineX + indicatorLineW / 2
|
|
indicatorNode.style.setProperty('transform', `translateX(${translateX}px) scaleX(${indicatorWidth})`)
|
|
// #endif
|
|
// #ifdef WEB || MP
|
|
indicatorNode.style.setProperty('width', `${indicatorWidth}px`)
|
|
indicatorNode.style.setProperty('transform', `translateX(${translateX}px)`)
|
|
// #endif
|
|
|
|
const scrollX = Math.max(translateX - swiperWidth.value / 2, 0)
|
|
const tabScrollEl = tabScroll.value
|
|
if (tabScrollEl == null) return
|
|
// #ifndef MP-WEIXIN
|
|
tabScrollEl.scrollLeft = scrollX
|
|
// #endif
|
|
// #ifdef MP-WEIXIN
|
|
tabScrollEl.scrollTo?.({ left: scrollX })
|
|
// #endif
|
|
}
|
|
|
|
function setSwiperIndex(index : number, updateIndicator : boolean) {
|
|
const safeIndex = Math.max(0, Math.min(index, tabItems.length - 1))
|
|
const changed = swiperIndex.value !== safeIndex
|
|
swiperIndex.value = safeIndex
|
|
if (updateIndicator || changed) {
|
|
updateTabIndicator(safeIndex, safeIndex, 1)
|
|
}
|
|
if (updateIndicator) {
|
|
animationFinishIndex.value = safeIndex
|
|
}
|
|
}
|
|
function onTabClick(index : number) {
|
|
setSwiperIndex(index, false)
|
|
}
|
|
|
|
function groupAvatarStyle(index : number) : any {
|
|
return {
|
|
marginLeft: index === 0 ? '0rpx' : '-18rpx',
|
|
zIndex: `${20 - index}`
|
|
}
|
|
}
|
|
|
|
function getPrimaryAvatar(conv : ChatConversationView) : MemberProfile | null {
|
|
if (conv.avatarMembers != null && conv.avatarMembers.length > 0) {
|
|
return conv.avatarMembers[0]
|
|
}
|
|
return null
|
|
}
|
|
function hasMemberAvatar(member : MemberProfile | null | undefined) : boolean {
|
|
if (member == null) return false
|
|
const avatar = (member as MemberProfile).avatar as string | null | undefined
|
|
if (typeof avatar !== 'string') return false
|
|
return (avatar as string).trim() !== ''
|
|
}
|
|
|
|
function getNotificationBadge(n : ChatNotification) : string {
|
|
if (n.type === 'message') return '讯'
|
|
return '通'
|
|
}
|
|
|
|
function formatBadgeCount(count : number | null | undefined) : string {
|
|
if (typeof count !== 'number') return ''
|
|
const safe = Math.max(0, Math.trunc(count as number))
|
|
if (safe <= 0) return ''
|
|
return safe > 99 ? '99+' : `${safe}`
|
|
}
|
|
|
|
function getPrimaryAvatarUrl(conv : ChatConversationView) : string {
|
|
const member = getPrimaryAvatar(conv)
|
|
if (member != null && hasMemberAvatar(member)) {
|
|
return member.avatar as string
|
|
}
|
|
return ''
|
|
}
|
|
|
|
function getPrimaryAvatarInitials(conv : ChatConversationView) : string {
|
|
const member = getPrimaryAvatar(conv)
|
|
if (member != null) return member.initials
|
|
return getConversationInitials(conv)
|
|
}
|
|
|
|
function hasPrimaryAvatar(conv : ChatConversationView) : boolean {
|
|
return hasMemberAvatar(getPrimaryAvatar(conv))
|
|
}
|
|
|
|
|
|
function onSwiperTransition(e : UniSwiperTransitionEvent) {
|
|
if (swiperWidth.value === 0) return
|
|
const offsetX = e.detail.dx
|
|
const currentOffsetX = offsetX % swiperWidth.value
|
|
const currentOffsetI = offsetX / swiperWidth.value
|
|
const currentIndex = animationFinishIndex.value + Math.trunc(currentOffsetI)
|
|
let moveToIndex = currentIndex
|
|
if (currentOffsetX > 0 && moveToIndex < tabItems.length - 1) {
|
|
moveToIndex += 1
|
|
} else if (currentOffsetX < 0 && moveToIndex > 0) {
|
|
moveToIndex -= 1
|
|
}
|
|
const percentage = Math.min(Math.abs(currentOffsetX) / swiperWidth.value, 1)
|
|
if (currentIndex !== moveToIndex) {
|
|
updateTabIndicator(currentIndex, moveToIndex, percentage)
|
|
}
|
|
}
|
|
|
|
function onSwiperAnimationfinish(e : SwiperAnimationFinishEvent) {
|
|
setSwiperIndex(e.detail.current, true)
|
|
}
|
|
|
|
async function cacheTabItemsSize() {
|
|
tabRects.value.length = 0
|
|
if (instance == null) return
|
|
const tabRefValue = instance?.refs['tabItem']
|
|
const tabElements = Array.isArray(tabRefValue)
|
|
? tabRefValue as UniElement[]
|
|
: (tabRefValue != null ? [tabRefValue as UniElement] : [])
|
|
for (let i = 0; i < tabElements.length; i++) {
|
|
const element = tabElements[i]
|
|
// #ifdef MP
|
|
const rect = await element.getBoundingClientRectAsync()!
|
|
const x = rect.left
|
|
const w = rect.width
|
|
// #endif
|
|
// #ifndef MP
|
|
const x = element.offsetLeft
|
|
const w = element.offsetWidth
|
|
// #endif
|
|
tabRects.value.push({ x, w } as TabRect)
|
|
}
|
|
}
|
|
|
|
|
|
async function initSwiperTabs() {
|
|
await nextTick()
|
|
const swiperElement = swiperEl.value
|
|
if (swiperElement != null) {
|
|
const rect = swiperElement.getBoundingClientRect()
|
|
if (rect != null) {
|
|
swiperWidth.value = rect.width
|
|
} else {
|
|
swiperWidth.value = 0
|
|
}
|
|
}
|
|
if (swiperWidth.value === 0) {
|
|
try {
|
|
const sys = uni.getSystemInfoSync()
|
|
swiperWidth.value = (sys.windowWidth ?? sys.screenWidth) ?? 0
|
|
} catch (_) { }
|
|
}
|
|
await cacheTabItemsSize()
|
|
if (tabRects.value.length > 0) {
|
|
animationFinishIndex.value = swiperIndex.value
|
|
updateTabIndicator(swiperIndex.value, swiperIndex.value, 1)
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function updateUnreadCounters() {
|
|
const countMap = new Map<string, number>()
|
|
let messageTotal = 0
|
|
let otherTotal = 0
|
|
for (let i = 0; i < notifications.value.length; i++) {
|
|
const row = notifications.value[i]
|
|
if (row == null || row.is_read) continue
|
|
if (row.type === 'message') {
|
|
messageTotal += 1
|
|
const convId = row.conversation_id
|
|
if (typeof convId === 'string' && convId !== '') {
|
|
const currentCount = countMap.get(convId) ?? 0
|
|
countMap.set(convId as string, currentCount + 1)
|
|
}
|
|
} else {
|
|
otherTotal += 1
|
|
}
|
|
}
|
|
let mutated = false
|
|
for (let i = 0; i < conversations.value.length; i++) {
|
|
const conv = conversations.value[i]
|
|
const next = countMap.get(conv.id) ?? 0
|
|
if ((conv.unreadCount ?? 0) !== next) {
|
|
conv.unreadCount = next
|
|
mutated = true
|
|
}
|
|
}
|
|
if (mutated) {
|
|
conversations.value = conversations.value.slice()
|
|
}
|
|
unreadMessageCount.value = messageTotal
|
|
unreadNotificationCount.value = otherTotal
|
|
}
|
|
|
|
async function loadConversations() {
|
|
await supaReady;
|
|
const me = await getCurrentUser()
|
|
if (me == null || me.id == null) return
|
|
const res = await ChatDataService.listMyConversations(me.id as string)
|
|
const rawRows = Array.isArray(res.data) ? (res.data as Array<ChatConversation>) : []
|
|
const rows = rawRows.map((conv) => ({
|
|
id: conv.id,
|
|
title: conv.title,
|
|
is_group: conv.is_group,
|
|
owner_id: conv.owner_id,
|
|
last_message_at: conv.last_message_at,
|
|
metadata: conv.metadata,
|
|
created_at: conv.created_at,
|
|
updated_at: conv.updated_at,
|
|
memberNames: '',
|
|
members: [],
|
|
avatarMembers: [],
|
|
unreadCount: 0
|
|
}) as ChatConversationView)
|
|
if (rows.length > 0) {
|
|
const myId = me.id as string
|
|
await Promise.all(rows.map(async (conv) => {
|
|
conv.unreadCount = conv.unreadCount ?? 0
|
|
try {
|
|
const partRes = await ChatDataService.listParticipantsWithProfile(conv.id)
|
|
if (!(partRes.status >= 200 && partRes.status < 300) || partRes.data == null) return
|
|
const participants = partRes.data as Array<ChatParticipantWithProfile>
|
|
const mapProfile = async (p : ChatParticipantWithProfile) : Promise<MemberProfile> => {
|
|
const profile = p as UTSJSONObject
|
|
const user = profile['user'] as UTSJSONObject | null
|
|
const candidates = [
|
|
user?.['nickname'] as string | null | undefined,
|
|
user?.['real_name'] as string | null | undefined,
|
|
user?.['username'] as string | null | undefined,
|
|
user?.['email'] as string | null | undefined,
|
|
user?.['phone'] as string | null | undefined
|
|
]
|
|
let display = ''
|
|
for (let i = 0; i < candidates.length; i++) {
|
|
const value = candidates[i]
|
|
if (typeof value === 'string') {
|
|
const trimmed = (value as string).trim()
|
|
if (trimmed !== '') {
|
|
display = trimmed
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if (display === '') {
|
|
display = p.user_id as string
|
|
}
|
|
const avatarSource = profile?.avatar_url ?? profile?.avatar ?? null
|
|
let avatarPath : string | null = null
|
|
if (typeof avatarSource === 'string' && avatarSource.trim() !== '') {
|
|
avatarPath = await resolveAvatarPath(avatarSource)
|
|
}
|
|
return {
|
|
id: p.user_id as string,
|
|
name: display,
|
|
avatar: avatarPath,
|
|
initials: ensureInitials(display)
|
|
}
|
|
}
|
|
const memberProfiles = await Promise.all(participants.map((participant) => mapProfile(participant)))
|
|
const others = memberProfiles.filter((m) => (m.id as string) !== myId)
|
|
if (conv.is_group) {
|
|
const displayMembers = others.length > 0 ? others : memberProfiles
|
|
const namesForLabel = displayMembers.map((m) => m.name).filter((n) => n != null && n !== '')
|
|
if (namesForLabel.length > 0) {
|
|
const preview = namesForLabel.slice(0, 4).join('、')
|
|
conv.memberNames = namesForLabel.length > 4 ? `${preview}…` : preview
|
|
} else {
|
|
conv.memberNames = ''
|
|
}
|
|
conv.members = memberProfiles
|
|
conv.avatarMembers = displayMembers.slice(0, MAX_GROUP_AVATARS)
|
|
} else {
|
|
const peer = others.length > 0
|
|
? others[0]
|
|
: memberProfiles.find((m) => m.id !== myId) ?? null
|
|
const fallbackMember = peer != null ? peer : (memberProfiles.length > 0 ? memberProfiles[0] : null)
|
|
conv.memberNames = fallbackMember != null ? fallbackMember.name : ''
|
|
conv.members = memberProfiles
|
|
conv.avatarMembers = fallbackMember != null ? [fallbackMember] : []
|
|
}
|
|
} catch (err) {
|
|
console.error('load participants failed', err)
|
|
}
|
|
}))
|
|
}
|
|
conversations.value = rows
|
|
updateUnreadCounters()
|
|
}
|
|
|
|
async function loadNotifications() {
|
|
await supaReady;
|
|
const me = await getCurrentUser()
|
|
if (me == null || me.id == null) return
|
|
const res = await ChatDataService.listNotifications(me.id as string, 50)
|
|
notifications.value = Array.isArray(res.data) ? (res.data as Array<ChatNotification>) : []
|
|
updateUnreadCounters()
|
|
const localNotifSub = notifSub
|
|
if (localNotifSub != null) {
|
|
try {
|
|
localNotifSub.dispose?.()
|
|
} catch (_) { }
|
|
notifSub = null
|
|
}
|
|
const localNotificationSub = notificationSubscription
|
|
if (localNotificationSub != null) {
|
|
try {
|
|
localNotificationSub.dispose?.()
|
|
} catch (_) { }
|
|
notificationSubscription = null
|
|
}
|
|
const subscription = await ChatDataService.subscribeNotifications(me.id as string, (n) => {
|
|
notifications.value.unshift(n)
|
|
if (notifications.value.length > 200) {
|
|
notifications.value.length = 200
|
|
}
|
|
notifications.value = notifications.value.slice()
|
|
updateUnreadCounters()
|
|
})
|
|
const subRef = subscription
|
|
notificationSubscription = {
|
|
dispose: () => {
|
|
subRef.dispose?.()
|
|
}
|
|
}
|
|
notifSub = {
|
|
dispose: () => {
|
|
try {
|
|
notificationSubscription?.dispose?.()
|
|
} catch (_) { }
|
|
notificationSubscription = null
|
|
notifSub = null
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
async function openRoom(conv : ChatConversationView) {
|
|
const conversationId = conv.id
|
|
const me = await getCurrentUser()
|
|
if (me != null && me.id != null) {
|
|
try {
|
|
await ChatDataService.markConversationNotificationsRead(me.id as string, conversationId)
|
|
} catch (err) {
|
|
console.warn('mark conversation notifications read failed', err)
|
|
}
|
|
let mutated = false
|
|
for (let i = 0; i < notifications.value.length; i++) {
|
|
const row = notifications.value[i]
|
|
if (row == null) continue
|
|
if (!row.is_read && row.type === 'message') {
|
|
const rowConvId = typeof row.conversation_id === 'string' ? row.conversation_id : null
|
|
if (rowConvId === conversationId) {
|
|
row.is_read = true
|
|
mutated = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if (mutated) {
|
|
let convMutated = false
|
|
for (let i = 0; i < conversations.value.length; i++) {
|
|
const item = conversations.value[i]
|
|
if (item.id === conversationId) {
|
|
if ((item.unreadCount ?? 0) !== 0) {
|
|
item.unreadCount = 0
|
|
convMutated = true
|
|
}
|
|
break
|
|
}
|
|
}
|
|
if (convMutated) {
|
|
conversations.value = conversations.value.slice()
|
|
}
|
|
notifications.value = notifications.value.slice()
|
|
updateUnreadCounters()
|
|
}
|
|
}
|
|
uni.navigateTo({ url: `/pages/sport/chat/room?cid=${encodeURIComponent(conversationId)}` })
|
|
}
|
|
|
|
async function openMsg(n : ChatNotification) {
|
|
if (!n.is_read) {
|
|
try {
|
|
await ChatDataService.markNotificationRead(n.id)
|
|
} catch (err) {
|
|
console.warn('mark notification read failed', err)
|
|
}
|
|
n.is_read = true
|
|
notifications.value = notifications.value.slice()
|
|
updateUnreadCounters()
|
|
}
|
|
if (typeof n.conversation_id === 'string' && n.conversation_id !== '') {
|
|
const targetId = n.conversation_id
|
|
uni.navigateTo({ url: `/pages/sport/chat/room?cid=${encodeURIComponent(targetId)}` })
|
|
}
|
|
}
|
|
|
|
function goAI() {
|
|
uni.navigateTo({ url: '/pages/info/chat' })
|
|
}
|
|
|
|
function openCreate() {
|
|
showCreate.value = true
|
|
setSwiperIndex(0, true)
|
|
isGroup.value = false
|
|
title.value = ''
|
|
query.value = ''
|
|
results.value = []
|
|
pickedIds.value = []
|
|
}
|
|
|
|
function closeCreate() { showCreate.value = false }
|
|
|
|
async function search() {
|
|
const k = query.value.trim()
|
|
if (k === '') { results.value = []; return }
|
|
const res = await ChatDataService.searchUsers(k, 20)
|
|
results.value = Array.isArray(res.data) ? (res.data as Array<any>) : []
|
|
}
|
|
|
|
function togglePick(u : any) {
|
|
const id = (u as UTSJSONObject)['id'] as string
|
|
const list = pickedIds.value
|
|
const idx = list.indexOf(id)
|
|
if (idx >= 0) list.splice(idx, 1)
|
|
else list.push(id)
|
|
}
|
|
|
|
async function submitCreate() {
|
|
const me = await getCurrentUser()
|
|
if (me == null || me.id == null) return
|
|
const payload : CreateConversationPayload = {
|
|
title: title.value != null && title.value != '' ? title.value : null,
|
|
isGroup: isGroup.value,
|
|
memberIds: pickedIds.value
|
|
}
|
|
const res = await ChatDataService.createConversation(me.id as string, payload)
|
|
if (res.status >= 200 && res.status < 300) {
|
|
showCreate.value = false
|
|
await loadConversations()
|
|
if (res.data != null) {
|
|
const created = res.data as ChatConversation
|
|
const targetId = created.id
|
|
if (typeof targetId === 'string' && targetId !== '') {
|
|
uni.navigateTo({ url: `/pages/sport/chat/room?cid=${encodeURIComponent(targetId)}` })
|
|
}
|
|
}
|
|
} else {
|
|
uni.showToast({ title: '创建失败', icon: 'none' })
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
(async () => {
|
|
await supaReady;
|
|
await loadConversations()
|
|
await loadNotifications()
|
|
initSwiperTabs()
|
|
|
|
})()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
try { notifSub?.dispose?.() } catch (_) { }
|
|
notifSub = null
|
|
notificationSubscription = null
|
|
})
|
|
onResize((options) => {
|
|
console.log(options)
|
|
initSwiperTabs()
|
|
}
|
|
)
|
|
</script>
|
|
|
|
<style scoped>
|
|
.page {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%
|
|
}
|
|
|
|
.header {
|
|
padding: 12rpx 16rpx;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
flex-direction: row;
|
|
}
|
|
|
|
.title {
|
|
font-size: 34rpx;
|
|
font-weight: 600
|
|
}
|
|
|
|
.actions {
|
|
display: flex
|
|
}
|
|
|
|
.actions .btn {
|
|
margin-right: 12rpx
|
|
}
|
|
|
|
.actions .btn:last-child {
|
|
margin-right: 0
|
|
}
|
|
|
|
.actions .btn {
|
|
padding: 8rpx 16rpx;
|
|
border: 1px solid #ddd;
|
|
border-radius: 8rpx
|
|
}
|
|
|
|
.actions .btn.primary {
|
|
background: #667eea;
|
|
color: #fff;
|
|
border-color: #667eea
|
|
}
|
|
|
|
.tabs {
|
|
position: relative;
|
|
border-bottom: 1px solid #eee;
|
|
background: #fff;
|
|
flex-shrink: 0
|
|
}
|
|
|
|
.tabs-row {
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
white-space: nowrap;
|
|
margin-left: -6rpx;
|
|
margin-right: -6rpx
|
|
}
|
|
|
|
.tabs-item-wrapper {
|
|
position: relative;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
margin-left: 6rpx;
|
|
margin-right: 6rpx
|
|
}
|
|
|
|
.tabs-item {
|
|
color: #555;
|
|
font-size: 28rpx;
|
|
padding: 20rpx 32rpx;
|
|
white-space: nowrap
|
|
}
|
|
|
|
.tabs-item.active {
|
|
color: #667eea;
|
|
font-weight: bold
|
|
}
|
|
|
|
.tabs-badge {
|
|
position: absolute;
|
|
top: 6rpx;
|
|
right: 16rpx;
|
|
min-width: 28rpx;
|
|
padding: 4rpx 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center
|
|
}
|
|
|
|
.tabs-badge-text {
|
|
color: #ff4d4f;
|
|
font-size: 22rpx;
|
|
font-weight: bold;
|
|
line-height: 1
|
|
}
|
|
|
|
.tabs-indicator {
|
|
position: absolute;
|
|
bottom: 0;
|
|
left: 0;
|
|
height: 6rpx;
|
|
border-radius: 999rpx;
|
|
background: #667eea;
|
|
width: 0;
|
|
transform: translateX(0);
|
|
transition: transform .2s ease, width .2s ease
|
|
}
|
|
|
|
.swiper-view {
|
|
flex: 1
|
|
}
|
|
|
|
.swiper-pane {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column
|
|
}
|
|
|
|
.list {
|
|
flex: 1
|
|
}
|
|
|
|
.item {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
padding: 20rpx 16rpx;
|
|
border-bottom: 1px solid #f2f2f2;
|
|
flex-direction: row;
|
|
}
|
|
|
|
.item-avatar {
|
|
margin-right: 16rpx
|
|
}
|
|
|
|
.avatar-info {
|
|
flex-direction: row;
|
|
}
|
|
|
|
.item-avatar {
|
|
width: 100rpx;
|
|
height: 100rpx;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
position: relative
|
|
}
|
|
|
|
.item-avatar.group {
|
|
width: 148rpx;
|
|
justify-content: flex-start;
|
|
flex-direction: row;
|
|
}
|
|
|
|
.item-avatar.notification {
|
|
width: 88rpx;
|
|
height: 88rpx;
|
|
}
|
|
|
|
.avatar-circle {
|
|
width: 92rpx;
|
|
height: 92rpx;
|
|
border-radius: 999rpx;
|
|
background: #e5e7eb;
|
|
color: #334155;
|
|
font-size: 32rpx;
|
|
font-weight: bold;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
overflow: hidden
|
|
}
|
|
|
|
.avatar-circle image {
|
|
width: 100%;
|
|
height: 100%
|
|
}
|
|
|
|
.avatar-circle.group {
|
|
width: 68rpx;
|
|
height: 68rpx;
|
|
font-size: 26rpx;
|
|
background: #f1f5f9;
|
|
color: #1f2937;
|
|
border: 2rpx solid #fff;
|
|
box-shadow: 0 6rpx 14rpx rgba(15, 23, 42, 0.12)
|
|
}
|
|
|
|
.avatar-circle.notification {
|
|
width: 80rpx;
|
|
height: 80rpx;
|
|
background: #dbeafe;
|
|
color: #1d4ed8;
|
|
font-size: 30rpx
|
|
}
|
|
|
|
.avatar-text {
|
|
font-size: 28rpx;
|
|
font-weight: bold;
|
|
letter-spacing: 2rpx
|
|
}
|
|
|
|
.item-body {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
min-width: 0
|
|
}
|
|
|
|
.item-main {
|
|
margin-right: 12rpx
|
|
}
|
|
|
|
.item-main {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-width: 0
|
|
}
|
|
|
|
.item-title {
|
|
font-size: 30rpx;
|
|
font-weight: bold;
|
|
line-height: 1.3;
|
|
color: #0f172a;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap
|
|
}
|
|
|
|
.item-sub {
|
|
font-size: 24rpx;
|
|
color: #64748b;
|
|
margin-top: 6rpx;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap
|
|
}
|
|
|
|
.item-meta {
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center
|
|
}
|
|
|
|
.item-time {
|
|
margin-bottom: 0;
|
|
margin-right: 12rpx;
|
|
}
|
|
|
|
.item-time {
|
|
font-size: 22rpx;
|
|
color: #94a3b8;
|
|
white-space: nowrap
|
|
}
|
|
|
|
.item-badge {
|
|
min-width: 38rpx;
|
|
padding: 4rpx 12rpx;
|
|
border-radius: 999rpx;
|
|
background: #ff4d4f;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center
|
|
}
|
|
|
|
.item-badge-text {
|
|
color: #fff;
|
|
font-size: 22rpx;
|
|
font-weight: bold;
|
|
line-height: 1
|
|
}
|
|
|
|
.empty {
|
|
padding: 40rpx;
|
|
text-align: center;
|
|
color: #999
|
|
}
|
|
|
|
.modal-mask {
|
|
position: fixed;
|
|
left: 0;
|
|
top: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0, 0, 0, .35);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 99
|
|
}
|
|
|
|
.modal {
|
|
width: 86%;
|
|
max-height: 80%;
|
|
background: #fff;
|
|
border-radius: 12rpx;
|
|
padding: 20rpx;
|
|
display: flex;
|
|
flex-direction: column
|
|
}
|
|
|
|
.modal-title {
|
|
font-size: 30rpx;
|
|
font-weight: bold;
|
|
margin-bottom: 12rpx
|
|
}
|
|
|
|
.form-item {
|
|
display: flex;
|
|
align-items: center;
|
|
flex-direction: row;
|
|
margin: 10rpx 0
|
|
}
|
|
|
|
.label {
|
|
margin-right: 12rpx
|
|
}
|
|
|
|
.label {
|
|
width: 120rpx;
|
|
color: #333
|
|
}
|
|
|
|
.ipt {
|
|
flex: 1;
|
|
border: 1px solid #ddd;
|
|
border-radius: 8rpx;
|
|
padding: 10rpx
|
|
}
|
|
|
|
.search-list {
|
|
max-height: 300rpx;
|
|
border: 1px solid #eee;
|
|
border-radius: 8rpx;
|
|
margin-top: 8rpx
|
|
}
|
|
|
|
.user-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: 12rpx 10rpx;
|
|
border-bottom: 1px solid #f6f6f6
|
|
}
|
|
|
|
.user-item:last-child {
|
|
border-bottom: 0
|
|
}
|
|
|
|
.name {
|
|
font-size: 28rpx
|
|
}
|
|
|
|
.picked {
|
|
font-size: 24rpx;
|
|
color: #2f855a
|
|
}
|
|
|
|
.modal-actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
margin-top: 16rpx
|
|
}
|
|
|
|
.modal-actions .btn {
|
|
margin-left: 12rpx
|
|
}
|
|
|
|
.small {
|
|
font-size: 22rpx
|
|
}
|
|
</style> |