739 lines
16 KiB
Plaintext
739 lines
16 KiB
Plaintext
<template>
|
|
<view class="msg-detail-page">
|
|
<!-- 导航栏 -->
|
|
<view class="nav-bar">
|
|
<view class="nav-left" @click="goBack">
|
|
<text class="nav-icon">←</text>
|
|
</view>
|
|
<view class="nav-center">
|
|
<text class="nav-title">消息详情</text>
|
|
</view>
|
|
<view class="nav-right">
|
|
<view class="nav-btn" @click="showActions">
|
|
<text class="nav-icon">⋮</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
|
|
<!-- 加载状态 -->
|
|
<view class="loading-container" v-if="loading">
|
|
<text class="loading-text">加载中...</text>
|
|
</view>
|
|
|
|
<!-- 消息内容 -->
|
|
<scroll-view class="message-content" v-if="message !== null && !loading">
|
|
<!-- 消息头部信息 -->
|
|
<view class="message-header">
|
|
<view class="sender-info">
|
|
<view class="sender-avatar">
|
|
<text class="avatar-text">{{ getSenderInitial(message?.sender_name) }}</text>
|
|
</view>
|
|
<view class="sender-details">
|
|
<text class="sender-name">{{ message?.sender_name ?? '未知发送者' }}</text>
|
|
<text class="sender-type">{{ getSenderTypeText(message?.sender_type ?? '') }}</text>
|
|
</view>
|
|
</view>
|
|
|
|
<view class="message-meta">
|
|
<view class="meta-item">
|
|
<text class="meta-label">发送时间:</text>
|
|
<text class="meta-value">{{ formatTime(message?.created_at ?? '') }}</text>
|
|
</view>
|
|
<view class="meta-item" v-if="message?.scheduled_at != null">
|
|
<text class="meta-label">定时发送:</text>
|
|
<text class="meta-value">{{ formatTime(message?.scheduled_at ?? '') }}</text>
|
|
</view>
|
|
<view class="meta-item" v-if="message?.expires_at != null">
|
|
<text class="meta-label">过期时间:</text>
|
|
<text class="meta-value" :class="{ 'expired': isExpired(message?.expires_at ?? '') }">
|
|
{{ formatTime(message?.expires_at ?? '') }}
|
|
</text>
|
|
</view>
|
|
</view>
|
|
|
|
<!-- 消息状态标签 -->
|
|
<view class="status-tags">
|
|
<view class="status-tag" :class="getStatusClass(message?.status ?? '')">
|
|
<text class="tag-text">{{ getStatusText(message?.status ?? '') }}</text>
|
|
</view>
|
|
<view class="status-tag priority" v-if="(message?.priority ?? 0) > 50">
|
|
<text class="tag-text">{{ getPriorityText(message?.priority ?? 0) }}</text>
|
|
</view>
|
|
<view class="status-tag urgent" v-if="message?.is_urgent === true">
|
|
<text class="tag-text">紧急</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
|
|
<!-- 消息标题 -->
|
|
<view class="message-title" v-if="message?.title != null && message?.title !== ''">
|
|
<text class="title-text">{{ message?.title ?? '' }}</text>
|
|
</view>
|
|
|
|
<!-- 消息正文 -->
|
|
<view class="message-body">
|
|
<text class="body-text">{{ message?.content ?? '' }}</text>
|
|
</view>
|
|
|
|
<!-- 附件信息 -->
|
|
<view class="attachments" v-if="hasAttachments">
|
|
<view class="section-title">
|
|
<text class="title-text">附件</text>
|
|
</view>
|
|
<view class="attachment-list">
|
|
<!-- 这里可以根据附件类型渲染不同的组件 -->
|
|
<view class="attachment-item" v-for="(attachment, index) in attachmentList" :key="index">
|
|
<text class="attachment-name">{{ attachment.name }}</text>
|
|
<text class="attachment-size">{{ attachment.size }}</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
|
|
<!-- 元数据信息 -->
|
|
<view class="metadata" v-if="hasMetadata">
|
|
<view class="section-title">
|
|
<text class="title-text">详细信息</text>
|
|
</view>
|
|
<view class="metadata-list">
|
|
<view class="metadata-item" v-for="(item, index) in metadataList" :key="index">
|
|
<text class="metadata-key">{{ item.key }}:</text>
|
|
<text class="metadata-value">{{ item.value }}</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
|
|
<!-- 回复消息 -->
|
|
<view class="replies" v-if="replies.length > 0">
|
|
<view class="section-title">
|
|
<text class="title-text">回复 ({{ replies.length }})</text>
|
|
</view>
|
|
<view class="reply-list">
|
|
<view class="reply-item" v-for="reply in replies" :key="reply.id">
|
|
<msg-item
|
|
:message="reply"
|
|
:compact="true"
|
|
@action="handleReplyAction"
|
|
></msg-item>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</scroll-view>
|
|
|
|
<!-- 操作按钮 -->
|
|
<view class="action-bar" v-if="message !== null">
|
|
<view class="action-btn" @click="markAsRead" v-if="!isRead">
|
|
<text class="btn-text">标记已读</text>
|
|
</view>
|
|
<view class="action-btn" @click="replyMessage">
|
|
<text class="btn-text">回复</text>
|
|
</view>
|
|
<view class="action-btn" @click="forwardMessage">
|
|
<text class="btn-text">转发</text>
|
|
</view>
|
|
<view class="action-btn danger" @click="deleteMessage">
|
|
<text class="btn-text">删除</text>
|
|
</view>
|
|
</view>
|
|
|
|
<!-- 操作菜单 -->
|
|
<view class="action-menu" v-if="showActionMenu" @click="hideActions">
|
|
<view class="menu-content" @click.stop>
|
|
<view class="menu-item" @click="copyContent">
|
|
<text class="menu-text">复制内容</text>
|
|
</view>
|
|
<view class="menu-item" @click="shareMessage">
|
|
<text class="menu-text">分享消息</text>
|
|
</view>
|
|
<view class="menu-item" @click="reportMessage">
|
|
<text class="menu-text">举报消息</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup lang="uts">
|
|
import { MsgDataServiceReal } from '@/utils/msgDataServiceReal.uts'
|
|
import supa from '@/components/supadb/aksupainstance.uts'
|
|
import { MsgUtils } from '@/utils/msgUtils.uts'
|
|
import { Message } from '@/utils/msgTypes.uts'
|
|
import { setClipboard } from '@/utils/utils.uts'
|
|
|
|
// 页面参数
|
|
const messageId = ref<string>('')
|
|
|
|
// 响应式数据
|
|
const message = ref<Message | null>(null)
|
|
const replies = ref<Array<Message>>([])
|
|
const loading = ref<boolean>(false)
|
|
const isRead = ref<boolean>(false)
|
|
const showActionMenu = ref<boolean>(false)
|
|
|
|
// 计算属性
|
|
const hasAttachments = computed(() => {
|
|
const msg = message.value
|
|
return msg !== null && msg.attachments !== null
|
|
})
|
|
|
|
const hasMetadata = computed(() => {
|
|
const msg = message.value
|
|
return msg !== null && msg.metadata !== null
|
|
})
|
|
|
|
const attachmentList = computed<Array<UTSJSONObject>>(() => {
|
|
const msg = message.value
|
|
if (msg === null || msg.attachments === null) {
|
|
return []
|
|
}
|
|
// 解析附件数据
|
|
try {
|
|
const attachments = msg.attachments
|
|
// 这里需要根据实际的附件数据结构来解析
|
|
return []
|
|
} catch (e) {
|
|
return []
|
|
}
|
|
})
|
|
|
|
type MetadataItem = { key: string, value: string }
|
|
|
|
const metadataList = computed((): MetadataItem[] => {
|
|
const msg = message.value
|
|
if (msg === null || msg.metadata === null) {
|
|
return []
|
|
}
|
|
const list: MetadataItem[] = []
|
|
const metadata = msg.metadata
|
|
// UTSJSONObject 遍历
|
|
const keys = UTSJSONObject.keys(metadata!)
|
|
for (let i = 0; i < keys.length; i++) {
|
|
const key = keys[i]
|
|
const value = metadata!.get(key)
|
|
list.push({ key: key, value: value != null ? (typeof value === 'string' ? value : JSON.stringify(value)) : '' })
|
|
}
|
|
return list
|
|
})
|
|
|
|
|
|
// 标记消息为已读
|
|
async function markAsReadIfNeeded(): Promise<void> {
|
|
const msg = message.value
|
|
if (msg === null) return
|
|
// 获取当前用户ID
|
|
const getCurrentUserId = (): string => {
|
|
const session = supa.getSession()
|
|
const user = session.user
|
|
if (user !== null) {
|
|
const id = user.getString('id')
|
|
return id !== null ? id : 'anonymous'
|
|
}
|
|
return 'anonymous'
|
|
}
|
|
try {
|
|
await MsgDataServiceReal.markAsRead(msg.id, getCurrentUserId())
|
|
isRead.value = true
|
|
} catch (error) {
|
|
// 标记已读失败不影响主流程
|
|
console.error('Mark as read failed:', error)
|
|
}
|
|
}
|
|
|
|
// 操作函数
|
|
function goBack(): void {
|
|
uni.navigateBack()
|
|
}
|
|
|
|
// 加载消息详情
|
|
async function loadMessageDetail(): Promise<void> {
|
|
if (messageId.value === '') return
|
|
loading.value = true
|
|
try {
|
|
const response = await MsgDataServiceReal.getMessageById(messageId.value)
|
|
if (response.status >= 200 && response.status < 300 && response.data !== null) {
|
|
message.value = (response.data ?? null) as Message | null
|
|
// 自动标记为已读
|
|
await markAsReadIfNeeded()
|
|
} else {
|
|
let errMsg: string = '加载失败'
|
|
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'
|
|
})
|
|
// 延迟返回上一页
|
|
setTimeout(() => {
|
|
goBack()
|
|
}, 2000)
|
|
}
|
|
} catch (error) {
|
|
uni.showToast({
|
|
title: '加载失败',
|
|
icon: 'none'
|
|
})
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
// 工具函数
|
|
function getSenderInitial(senderName: string | null): string {
|
|
if (senderName === null || senderName === '') {
|
|
return '?'
|
|
}
|
|
return senderName.charAt(0).toUpperCase()
|
|
}
|
|
|
|
function getSenderTypeText(senderType: string): string {
|
|
return MsgUtils.getSenderTypeText(senderType as string)
|
|
}
|
|
|
|
function formatTime(timeStr: string | null): string {
|
|
return MsgUtils.formatFullTime(timeStr)
|
|
}
|
|
|
|
function isExpired(expiresAt: string | null): boolean {
|
|
return MsgUtils.isMessageExpired(expiresAt)
|
|
}
|
|
|
|
function getStatusText(status: string): string {
|
|
return MsgUtils.getStatusText(status as string)
|
|
}
|
|
|
|
function getStatusClass(status: string): string {
|
|
return `status-${status}`
|
|
}
|
|
|
|
function getPriorityText(priority: number): string {
|
|
if (priority >= 90) return '紧急'
|
|
if (priority >= 70) return '重要'
|
|
if (priority >= 50) return '普通'
|
|
return '一般'
|
|
}
|
|
|
|
function showActions(): void {
|
|
showActionMenu.value = true
|
|
}
|
|
|
|
function hideActions(): void {
|
|
showActionMenu.value = false
|
|
}
|
|
|
|
async function markAsRead(): Promise<void> {
|
|
const msg = message.value
|
|
if (msg === null) return
|
|
const userId = 'current-user-id' // 实际应该从用户状态获取
|
|
const response = await MsgDataServiceReal.markAsRead(msg.id, userId)
|
|
if (response.status >= 200 && response.status < 300) {
|
|
isRead.value = true
|
|
uni.showToast({
|
|
title: '已标记为已读',
|
|
icon: 'success'
|
|
})
|
|
} else {
|
|
let errMsg: string = '操作失败'
|
|
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'
|
|
})
|
|
}
|
|
}
|
|
|
|
function replyMessage(): void {
|
|
const msg = message.value
|
|
if (msg === null) return
|
|
uni.navigateTo({
|
|
url: `/pages/msg/compose?replyTo=${msg.id}`
|
|
})
|
|
}
|
|
|
|
function forwardMessage(): void {
|
|
const msg = message.value
|
|
if (msg === null) return
|
|
uni.navigateTo({
|
|
url: `/pages/msg/compose?forward=${msg.id}`
|
|
})
|
|
}
|
|
|
|
async function deleteMessage(): Promise<void> {
|
|
const msg = message.value
|
|
if (msg === null) return
|
|
uni.showModal({
|
|
title: '确认删除',
|
|
content: '确定要删除这条消息吗?',
|
|
success: (res) => {
|
|
if (res.confirm && msg !== null) {
|
|
const userId = 'current-user-id' // 实际应该从用户状态获取
|
|
MsgDataServiceReal.deleteMessage(msg.id, userId).then(response => {
|
|
if (response.status >= 200 && response.status < 300) {
|
|
uni.showToast({
|
|
title: '删除成功',
|
|
icon: 'success'
|
|
})
|
|
setTimeout(() => {
|
|
goBack()
|
|
}, 1500)
|
|
} else {
|
|
let errMsg: string = '删除失败'
|
|
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'
|
|
})
|
|
}
|
|
})
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
function copyContent(): void {
|
|
const msg = message.value
|
|
if (msg === null || msg.content === null) return
|
|
setClipboard(msg.content as string)
|
|
uni.showToast({
|
|
title: '已复制到剪贴板',
|
|
icon: 'success'
|
|
})
|
|
hideActions()
|
|
}
|
|
|
|
function shareMessage(): void {
|
|
// 实现分享功能
|
|
hideActions()
|
|
}
|
|
|
|
function reportMessage(): void {
|
|
// 实现举报功能
|
|
hideActions()
|
|
}
|
|
|
|
function handleReplyAction(data: UTSJSONObject): void {
|
|
// 处理回复消息的操作
|
|
}
|
|
// 生命周期
|
|
onLoad((options) => {
|
|
if (options["id"] !== null) {
|
|
messageId.value = options.getString("id") ?? ''
|
|
loadMessageDetail()
|
|
}
|
|
})
|
|
|
|
</script>
|
|
|
|
<style>
|
|
.msg-detail-page {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100vh;
|
|
background-color: #f5f5f5;
|
|
}
|
|
|
|
.nav-bar {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
height: 44px;
|
|
padding: 0 16px;
|
|
background-color: #ffffff;
|
|
border-bottom: 1px solid #e0e0e0;
|
|
}
|
|
|
|
.nav-left, .nav-right {
|
|
width: 60px;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
|
|
.nav-center {
|
|
flex: 1;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
|
|
.nav-title {
|
|
font-size: 17px;
|
|
font-weight: 700;
|
|
color: #000000;
|
|
}
|
|
|
|
.nav-icon {
|
|
font-size: 18px;
|
|
color: #007AFF;
|
|
}
|
|
|
|
.nav-btn {
|
|
width: 40px;
|
|
height: 40px;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
|
|
.loading-container {
|
|
flex: 1;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
|
|
.loading-text {
|
|
color: #999999;
|
|
font-size: 16px;
|
|
}
|
|
|
|
.message-content {
|
|
flex: 1;
|
|
padding: 16px;
|
|
}
|
|
|
|
.message-header {
|
|
background-color: #ffffff;
|
|
border-radius: 8px;
|
|
padding: 16px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.sender-info {
|
|
display: flex;
|
|
align-items: center;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.sender-avatar {
|
|
width: 50px;
|
|
height: 50px;
|
|
border-radius: 25px;
|
|
background-color: #2196F3;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
margin-right: 12px;
|
|
}
|
|
|
|
.avatar-text {
|
|
color: #ffffff;
|
|
font-size: 20px;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.sender-details {
|
|
flex: 1;
|
|
}
|
|
|
|
.sender-name {
|
|
font-size: 16px;
|
|
font-weight: 700;
|
|
color: #333333;
|
|
margin-bottom: 2px;
|
|
}
|
|
|
|
.sender-type {
|
|
font-size: 14px;
|
|
color: #666666;
|
|
}
|
|
|
|
.message-meta {
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.meta-item {
|
|
display: flex;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.meta-label {
|
|
font-size: 14px;
|
|
color: #666666;
|
|
width: 80px;
|
|
}
|
|
|
|
.meta-value {
|
|
font-size: 14px;
|
|
color: #333333;
|
|
flex: 1;
|
|
}
|
|
|
|
.meta-value.expired {
|
|
color: #ff4757;
|
|
}
|
|
|
|
.status-tags {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.status-tag {
|
|
padding: 4px 8px;
|
|
border-radius: 12px;
|
|
margin-right: 8px;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.status-sent {
|
|
background-color: #e3f2fd;
|
|
}
|
|
|
|
.status-delivered {
|
|
background-color: #e8f5e8;
|
|
}
|
|
|
|
.status-failed {
|
|
background-color: #ffebee;
|
|
}
|
|
|
|
.status-tag.priority {
|
|
background-color: #fff3e0;
|
|
}
|
|
|
|
.status-tag.urgent {
|
|
background-color: #ffebee;
|
|
}
|
|
|
|
.tag-text {
|
|
font-size: 12px;
|
|
color: #333333;
|
|
}
|
|
|
|
.message-title {
|
|
background-color: #ffffff;
|
|
border-radius: 8px;
|
|
padding: 16px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.title-text {
|
|
font-size: 18px;
|
|
font-weight: 700;
|
|
color: #333333;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.message-body {
|
|
background-color: #ffffff;
|
|
border-radius: 8px;
|
|
padding: 16px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.body-text {
|
|
font-size: 16px;
|
|
color: #333333;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.attachments, .metadata, .replies {
|
|
background-color: #ffffff;
|
|
border-radius: 8px;
|
|
padding: 16px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.section-title {
|
|
margin-bottom: 12px;
|
|
border-bottom: 1px solid #f0f0f0;
|
|
padding-bottom: 8px;
|
|
}
|
|
|
|
.attachment-item, .metadata-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 8px 0;
|
|
border-bottom: 1px solid #f8f8f8;
|
|
}
|
|
|
|
.attachment-name, .metadata-key {
|
|
font-size: 14px;
|
|
color: #333333;
|
|
}
|
|
|
|
.attachment-size, .metadata-value {
|
|
font-size: 14px;
|
|
color: #666666;
|
|
}
|
|
|
|
.reply-item {
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.action-bar {
|
|
display: flex;
|
|
padding: 12px 16px;
|
|
background-color: #ffffff;
|
|
border-top: 1px solid #e0e0e0;
|
|
}
|
|
|
|
.action-btn {
|
|
flex: 1;
|
|
height: 40px;
|
|
margin: 0 4px;
|
|
border-radius: 20px;
|
|
background-color: #2196F3;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
|
|
.action-btn.danger {
|
|
background-color: #ff4757;
|
|
}
|
|
|
|
.btn-text {
|
|
color: #ffffff;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.action-menu {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background-color: rgba(0, 0, 0, 0.3);
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
|
|
.menu-content {
|
|
background-color: #ffffff;
|
|
border-radius: 12px;
|
|
min-width: 200px;
|
|
max-width: 300px;
|
|
}
|
|
|
|
.menu-item {
|
|
padding: 16px 20px;
|
|
border-bottom: 1px solid #f0f0f0;
|
|
}
|
|
|
|
.menu-text {
|
|
font-size: 16px;
|
|
color: #333333;
|
|
}
|
|
</style>
|