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

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>