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

892 lines
21 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="msg-page">
<!-- 使用 scroll-view 替代 list-view 以避免渲染问题 -->
<scroll-view
class="message-container"
:scroll-y="true"
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="handleRefresh"
@scrolltolower="loadMoreMessages"
>
<!-- 头部导航 -->
<view class="header-item">
<view class="header">
<view class="header-left">
<text class="title">消息中心</text>
<view class="unread-badge" v-if="unreadCount > 0">
<text class="badge-text">{{ unreadCount > 99 ? '99+' : unreadCount }}</text>
</view>
</view>
<view class="header-right">
<view class="header-btn" @click="handleSearch">
<text class="icon">🔍</text>
</view>
<view class="header-btn" @click="handleRefresh">
<text class="icon" :class="{ 'rotating': loading }">↻</text>
</view>
<view class="header-btn" @click="handleCompose">
<text class="icon">✏️</text>
</view>
</view>
</view>
</view>
<!-- 搜索栏 -->
<view class="search-item" v-if="showSearch">
<view class="search-bar">
<input
class="search-input"
type="text"
placeholder="搜索消息..."
v-model="searchKeyword"
@confirm="performSearch"
/>
<view class="search-btn" @click="performSearch">
<text class="btn-text">搜索</text>
</view>
</view>
</view>
<!-- 消息类型筛选(吸顶效果) -->
<view class="filter-bar-select">
<view class="picker-trigger" @click="showTypeActionSheet">
<text>{{ currentTypeFilterName }}</text>
<text class="picker-arrow">▼</text>
</view>
</view>
<!-- 空状态 -->
<view class="empty-item" v-if="messages.length === 0 && !loading">
<view class="empty-state">
<text class="empty-icon">📪</text>
<text class="empty-text">暂无消息</text>
<view class="empty-btn" @click="handleRefresh">
<text class="btn-text">刷新试试</text>
</view>
</view>
</view>
<!-- 消息项 -->
<view
v-for="(message, index) in messages"
:key="`msg-${message?.id ?? index}-${index}`"
class="message-list-item"
>
<view class="message-item">
<MessageItem
:item="message"
@click="openMessage"
@action="handleMessageAction"
/>
</view>
</view>
<!-- 加载更多 -->
<view class="load-more-item" v-if="hasMore && !loading">
<view class="load-more">
<text class="load-text">上拉加载更多</text>
</view>
</view>
<!-- 加载中 -->
<view class="loading-item" v-if="loading">
<view class="loading">
<text class="loading-text">加载中...</text>
</view>
</view>
</scroll-view>
<!-- 底部操作栏 -->
<view class="bottom-bar" v-if="selectionMode">
<view class="bottom-left">
<text class="selected-count">已选择 {{ selectedMessages.length }} 条</text>
</view>
<view class="bottom-right">
<view class="bottom-btn" @click="markSelectedAsRead">
<text class="btn-text">标记已读</text>
</view>
<view class="bottom-btn danger" @click="deleteSelected">
<text class="btn-text">删除</text>
</view>
<view class="bottom-btn" @click="cancelSelection">
<text class="btn-text">取消</text>
</view>
</view>
</view>
<!-- 浮动操作按钮 -->
<view class="fab" @click="handleCompose" v-if="!selectionMode">
<text class="fab-icon">✏️</text>
</view>
</view>
</template>
<script setup lang="uts">
import { MsgDataServiceReal } from '@/utils/msgDataServiceReal.uts'
import { MsgUtils } from '@/utils/msgUtils.uts'
import supa from '@/components/supadb/aksupainstance.uts'
import {getCurrentUserId} from '@/utils/store.uts'
import MessageItem from '@/components/message/MessageItem.uvue'
import {
Message,
MessageWithRecipient,
MessageType,
MessageListParams,
MessageStats
} from '@/utils/msgTypes.uts'
// 定义消息操作数据类型
type MessageActionData = {
action: string
item: MessageWithRecipient
}
// 响应式数据
const messages = ref<Array<MessageWithRecipient>>([])
const messageTypes = ref<Array<MessageType>>([])
const loading = ref<boolean>(false)
const refreshing = ref<boolean>(false)
const hasMore = ref<boolean>(true)
const unreadCount = ref<number>(0)
// UI 状态
const showSearch = ref<boolean>(false)
const searchKeyword = ref<string>('')
const currentTypeFilter = ref<string>('')
const currentTypeFilterName = computed(() => {
if (currentTypeFilter.value == null || currentTypeFilter.value === '') {
return '全部类型'
}
const found = messageTypes.value.find(t => t.code === currentTypeFilter.value)
return found?.name ?? '全部类型'
})
const selectionMode = ref<boolean>(false)
const selectedMessages = ref<Array<string>>([])
const currentPage = ref<number>(1)
const pageSize = ref<number>(20)
// 加载消息列表
async function loadMessages(reset: boolean): Promise<void> {
if (loading.value) return
loading.value = true
try {
if (reset) {
currentPage.value = 1
// 安全地清空数组避免Vue渲染问题
messages.value.splice(0, messages.value.length)
}
const params: MessageListParams = {
limit: pageSize.value,
offset: (currentPage.value - 1) * pageSize.value,
message_type: currentTypeFilter.value === '' ? null : currentTypeFilter.value,
receiver_type: null, // 如有需要可补充
status: null, // 如有需要可补充
is_urgent: null, // 如有需要可补充
search: searchKeyword.value === '' ? null : searchKeyword.value
}
const response = await MsgDataServiceReal.getMessages(params)
console.log('获取消息响应:', response)
if (response.status >= 200 && response.status < 300 && response.data !== null) {
const data = response.data as Array<Message>;
if (!Array.isArray(data)) {
console.error('Invalid data format: expected array but got', typeof data);
return;
}
console.log(`收到 ${data.length} 条消息数据`)
// 现在 response.data 应该已经是 Array<Message> 类型
const newMessages: Array<MessageWithRecipient> = []
// 逐个处理消息,将 Message 转换为 MessageWithRecipient
for (let i = 0; i < data.length; i++) {
const msg: Message = data[i]
// 基本验证
if (msg == null || msg.id == null || msg.id === '') {
console.warn(`跳过无效消息 ${i}:`, msg)
continue
}
// 创建 MessageWithRecipient 对象
const messageItem: MessageWithRecipient = {
// 复制所有 Message 字段
id: msg.id,
message_type_id: msg.message_type_id,
sender_type: msg.sender_type,
sender_id: msg.sender_id,
sender_name: msg.sender_name,
receiver_type: msg.receiver_type,
receiver_id: msg.receiver_id,
title: msg.title,
content: msg.content,
content_type: msg.content_type,
attachments: msg.attachments,
media_urls: msg.media_urls,
metadata: msg.metadata,
device_data: msg.device_data,
location_data: msg.location_data,
priority: msg.priority,
expires_at: msg.expires_at,
is_broadcast: msg.is_broadcast,
is_urgent: msg.is_urgent,
conversation_id: msg.conversation_id,
parent_message_id: msg.parent_message_id,
thread_count: msg.thread_count,
status: msg.status,
total_recipients: msg.total_recipients,
delivered_count: msg.delivered_count,
read_count: msg.read_count,
reply_count: msg.reply_count,
delivery_options: msg.delivery_options,
push_notification: msg.push_notification,
email_notification: msg.email_notification,
sms_notification: msg.sms_notification,
created_at: msg.created_at,
updated_at: msg.updated_at,
scheduled_at: msg.scheduled_at,
delivered_at: msg.delivered_at,
// 添加 MessageWithRecipient 扩展字段
is_read: false,
is_starred: false,
is_archived: false,
is_deleted: false,
read_at: null,
replied_at: null,
message_type: msg.message_type_id,
recipients: null
}
newMessages.push(messageItem)
}
console.log(`成功处理 ${newMessages.length} 条消息`)
// 安全更新消息数组
if (reset) {
// 先清空,再添加
messages.value.splice(0, messages.value.length)
messages.value.push(...newMessages)
} else {
// 直接添加新消息
messages.value.push(...newMessages)
}
hasMore.value = response.hasmore ?? false
if (newMessages.length > 0) {
currentPage.value = currentPage.value + 1
}
} else {
let errMsg = '加载失败'
const err = response.error
if (err != null) {
if (typeof err === 'string') {
errMsg = err as string
} else if (typeof err === 'object' && err.message != null && typeof err.message === 'string') {
errMsg = err.message
}
}
uni.showToast({
title: errMsg,
icon: 'none'
})
}
} catch (error) {
console.log(error)
uni.showToast({
title: '加载失败',
icon: 'none'
})
} finally {
loading.value = false
refreshing.value = false
}
}
// 加载统计信息
async function loadStats(): Promise<void> {
const response = await MsgDataServiceReal.getMessageStats(getCurrentUserId())
if (response.status >= 200 && response.status < 300 && response.data !== null) {
unreadCount.value = ((response.data as MessageStats)?.unread_messages ?? 0)
}
}
// 刷新消息
async function handleRefresh(): Promise<void> {
refreshing.value = true
await loadMessages(true)
await loadStats()
}
// 加载更多消息
async function loadMoreMessages(): Promise<void> {
if (hasMore.value && !loading.value) {
await loadMessages(false)
}
}
// 搜索功能
function handleSearch(): void {
showSearch.value = !showSearch.value
if (!showSearch.value) {
searchKeyword.value = ''
handleRefresh()
}
}
async function performSearch(): Promise<void> {
await loadMessages(true)
}
// 类型筛选
async function filterByType(typeCode: string): Promise<void> {
currentTypeFilter.value = typeCode
await loadMessages(true)
}
// 显示类型选择操作表
function showTypeActionSheet(): void {
const options = [{ name: '全部类型', code: '' }, ...messageTypes.value.map(t => ({ name: t.name, code: t.code }))]
let actopt = options.map(o => o.name)
uni.showActionSheet({
itemList: actopt,
success: function(res) {
const idx = res.tapIndex ?? 0
// 防止索引越界
if (idx >= 0 && idx < options.length) {
const selected = options[idx]
currentTypeFilter.value = selected.code
loadMessages(true)
} else {
console.error('ActionSheet 选择索引越界:', idx, '数组长度:', options.length)
}
}
})
}
// 选择模式
function toggleSelection(messageId: string): void {
const index = selectedMessages.value.indexOf(messageId)
if (index > -1) {
// 更安全的数组移除方式,防止索引越界
try {
if (index >= 0 && index < selectedMessages.value.length) {
selectedMessages.value.splice(index, 1)
}
} catch (error) {
console.error('移除选中项时出错:', error)
// 回退到过滤方式
selectedMessages.value = selectedMessages.value.filter(id => id !== messageId)
}
} else {
// 确保不重复添加
if (!selectedMessages.value.includes(messageId)) {
selectedMessages.value.push(messageId)
}
}
}
// 打开消息详情
function openMessage(message: MessageWithRecipient): void {
if (selectionMode.value) {
toggleSelection(message.id)
return
}
uni.navigateTo({
url: `/pages/msg/detail?id=${message.id}`
})
}
// 标记消息为已读
async function markMessageAsRead(messageId: string): Promise<void> {
try {
const response = await MsgDataServiceReal.batchOperation([messageId], 'read', getCurrentUserId())
if (response.status >= 200 && response.status < 300) {
// 更新本地消息状态
const message = messages.value.find(m => m.id === messageId)
if (message != null) {
message.is_read = true
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
}
} catch (error) {
console.error('标记已读失败:', error)
}
}
// 切换消息收藏状态
async function toggleMessageStar(messageId: string): Promise<void> {
try {
const message = messages.value.find(m => m.id === messageId)
if (message == null) return
const action = (message.is_starred ?? false) ? 'unstar' : 'star'
const response = await MsgDataServiceReal.batchOperation([messageId], action, getCurrentUserId())
if (response.status >= 200 && response.status < 300) {
message.is_starred = !(message.is_starred ?? false)
}
} catch (error) {
console.error('切换收藏状态失败:', error)
}
}
// 删除消息
async function deleteMessage(messageId: string): Promise<void> {
try {
const response = await MsgDataServiceReal.batchOperation([messageId], 'delete', getCurrentUserId())
if (response.status >= 200 && response.status < 300) {
// 使用过滤方式删除消息,避免索引越界问题
try {
const index = messages.value.findIndex(m => m.id === messageId)
if (index >= 0 && index < messages.value.length) {
messages.value.splice(index, 1)
}
} catch (error) {
console.error('删除消息时索引越界:', error)
// 回退到过滤方式
messages.value = messages.value.filter(m => m.id !== messageId)
}
}
} catch (error) {
console.error('删除消息失败:', error)
}
}
// 归档消息
async function archiveMessage(messageId: string): Promise<void> {
try {
const response = await MsgDataServiceReal.batchOperation([messageId], 'archive', getCurrentUserId())
if (response.status >= 200 && response.status < 300) {
const message = messages.value.find(m => m.id === messageId)
if (message != null) {
message.is_archived = true
}
}
} catch (error) {
console.error('归档消息失败:', error)
}
}
// 处理消息操作
function handleMessageAction(data: MessageActionData): void {
const action = data.action
const item = data.item
const messageId = item.id
switch (action) {
case 'read':
markMessageAsRead(messageId)
break
case 'star':
toggleMessageStar(messageId)
break
case 'delete':
deleteMessage(messageId)
break
case 'archive':
archiveMessage(messageId)
break
default:
console.log('未知操作:', action)
}
}
function cancelSelection(): void {
selectionMode.value = false
selectedMessages.value = []
}
// 批量操作
async function markSelectedAsRead(): Promise<void> {
if (selectedMessages.value.length === 0) return
try { const response = await MsgDataServiceReal.batchOperation(
selectedMessages.value,
'read',
getCurrentUserId()
)
if (response.status >= 200 && response.status < 300) {
uni.showToast({
title: '已标记为已读',
icon: 'success'
})
await loadStats()
await loadMessages(true)
} else {
let errMsg = '操作失败'
const err = response.error
if (err != null) {
if (typeof err === 'string') {
errMsg = err as string
} else if (typeof err === 'object' && err.message != null && typeof err.message === 'string') {
errMsg = err.message
}
}
uni.showToast({
title: errMsg,
icon: 'none'
})
}
} catch (error) {
uni.showToast({
title: '操作失败',
icon: 'none'
})
}
cancelSelection()
}
async function deleteSelected(): Promise<void> {
if (selectedMessages.value.length === 0) return
uni.showModal({
title: '确认删除',
content: `确定要删除选中的 ${selectedMessages.value.length} 条消息吗?`,
success: (res) => {
if (res.confirm) {
MsgDataServiceReal.batchOperation(
selectedMessages.value,
'delete',
getCurrentUserId()
).then(response => {
if (response.status >= 200 && response.status < 300) {
uni.showToast({
title: '删除成功',
icon: 'success'
})
loadStats()
loadMessages(true)
} else {
let errMsg = '删除失败'
const err = response.error
if (err != null) {
if (typeof err === 'string') {
errMsg = err as string
} else if (typeof err === 'object' && err.message != null && typeof err.message === 'string') {
errMsg = err.message
}
}
uni.showToast({
title: errMsg,
icon: 'none'
})
}
cancelSelection()
}).catch(error => {
uni.showToast({
title: '删除失败',
icon: 'none'
})
cancelSelection()
})
}
}
})
}
// 加载消息类型
async function loadMessageTypes(): Promise<void> {
const response = await MsgDataServiceReal.getMessageTypes()
if (response.status >= 200 && response.status < 300 && Array.isArray(response.data)) {
const dataArray = response.data as Array<any>
const types: Array<MessageType> = []
for (let i = 0; i < dataArray.length; i++) {
types.push(dataArray[i] as MessageType)
}
messageTypes.value = types
} else {
messageTypes.value = []
}
}
// 写消息
function handleCompose(): void {
uni.navigateTo({
url: '/pages/msg/compose'
})
}
// 初始化加载
async function loadInitialData(): Promise<void> {
// 消息数据服务现在直接使用 aksupainstance.uts 中的 supa 实例,无需手动初始化
await loadMessageTypes()
await loadMessages(true)
await loadStats()
}
// 生命周期
onMounted(() => {
loadInitialData()
})
</script>
<style>
.msg-page {
display: flex;
flex-direction: column;
background-color: #f5f5f5;
flex: 1;
width: 100%;
height: 100vh;
}
.message-container {
flex: 1;
background-color: #f5f5f5;
}
.header-item {
background-color: #ffffff;
}
.header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background-color: #ffffff;
border-bottom: 1px solid #e0e0e0;
}
.header-left {
display: flex;
flex-direction: row;
align-items: center;
}
.title {
font-size: 18px;
font-weight: bold;
color: #333333;
}
.unread-badge {
margin-left: 8px;
background-color: #ff4757;
border-radius: 10px;
min-width: 20px;
height: 20px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}
.badge-text {
color: #ffffff;
font-size: 12px;
}
.header-right {
display: flex;
align-items: center;
flex-direction: row;
}
.header-btn {
width: 40px;
height: 40px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
margin-left: 8px;
}
.icon {
font-size: 18px;
color: #666666;
}
.rotating {
transform: rotate(360deg);
}
.search-item {
background-color: #ffffff;
}
.search-bar {
display: flex;
flex-direction: row;
padding: 12px 16px;
background-color: #ffffff;
border-bottom: 1px solid #e0e0e0;
}
.search-input {
flex: 1;
height: 36px;
padding: 0 12px;
border: 1px solid #e0e0e0;
border-radius: 18px;
background-color: #f8f8f8;
}
.search-btn {
margin-left: 12px;
padding: 0 16px;
height: 36px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
background-color: #2196F3;
border-radius: 18px;
}
.btn-text {
color: #ffffff;
font-size: 14px;
}
/* 吸顶筛选条样式 */
.filter-bar-select {
display: flex;
flex-direction: row;
align-items: center;
padding: 12px 16px;
background-color: #ffffff;
border-bottom: 1px solid #e0e0e0;
}
.picker-trigger {
flex: 1;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
border: 1px solid #e0e0e0;
border-radius: 18px;
background-color: #f8f8f8;
}
.picker-arrow {
font-size: 14px;
color: #666666;
}
.empty-item {
background-color: #f5f5f5;
}
.empty-state {
padding: 80px 20px;
display: flex;
flex-direction: column;
align-items: center;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
}
.empty-text {
font-size: 16px;
color: #999999;
margin-bottom: 20px;
}
.empty-btn {
padding: 8px 20px;
background-color: #2196F3;
border-radius: 20px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}
.message-list-item {
background-color: #ffffff;
}
.message-item {
background-color: #ffffff;
border-bottom: 1px solid #f0f0f0;
}
.load-more-item, .loading-item {
background-color: #f5f5f5;
}
.load-more, .loading {
padding: 20px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}
.load-text, .loading-text {
color: #999999;
font-size: 14px;
}
.bottom-bar {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background-color: #ffffff;
border-top: 1px solid #e0e0e0;
}
.selected-count {
color: #666666;
font-size: 14px;
}
.bottom-right {
display: flex;
flex-direction: row;
}
.bottom-btn {
padding: 6px 12px;
margin-left: 8px;
border-radius: 4px;
background-color: #2196F3;
}
.bottom-btn.danger {
background-color: #ff4757;
}
.fab {
position: absolute;
bottom: 80px;
right: 20px;
width: 56px;
height: 56px;
border-radius: 28px;
background-color: #2196F3;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
z-index: 999;
}
.fab-icon {
color: #ffffff;
font-size: 24px;
}
</style>