Initial commit of akmon project

This commit is contained in:
2026-01-20 08:04:15 +08:00
commit 77a2bab985
1309 changed files with 343305 additions and 0 deletions

891
pages/msg/index.uvue Normal file
View File

@@ -0,0 +1,891 @@
<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>