Initial commit of akmon project
This commit is contained in:
616
components/message/MessageInput.uvue
Normal file
616
components/message/MessageInput.uvue
Normal file
@@ -0,0 +1,616 @@
|
||||
<template>
|
||||
<view class="message-input">
|
||||
<!-- 消息类型选择 -->
|
||||
<view class="input-section">
|
||||
<text class="section-label">消息类型</text>
|
||||
<picker-view
|
||||
class="type-picker"
|
||||
:value="selectedTypeIndex"
|
||||
@change="handleTypeChange"
|
||||
>
|
||||
<picker-view-column>
|
||||
<view v-for="(type, index) in messageTypes" :key="type.id" class="picker-option">
|
||||
<text>{{ type.name }}</text>
|
||||
</view>
|
||||
</picker-view-column>
|
||||
</picker-view>
|
||||
</view>
|
||||
|
||||
<!-- 接收者选择 -->
|
||||
<view class="input-section">
|
||||
<text class="section-label">发送给</text>
|
||||
<view class="receiver-selector">
|
||||
<picker-view
|
||||
class="receiver-type-picker"
|
||||
:value="selectedReceiverTypeIndex"
|
||||
@change="handleReceiverTypeChange"
|
||||
>
|
||||
<picker-view-column>
|
||||
<view v-for="(type, index) in receiverTypes" :key="type.value" class="picker-option">
|
||||
<text>{{ type.label }}</text>
|
||||
</view>
|
||||
</picker-view-column>
|
||||
</picker-view>
|
||||
|
||||
<view v-if="selectedReceiverType === 'user'" class="user-selector">
|
||||
<input
|
||||
class="user-input"
|
||||
placeholder="输入用户名搜索"
|
||||
v-model="userSearchKeyword"
|
||||
@input="handleUserSearch"
|
||||
/>
|
||||
<scroll-view
|
||||
v-if="filteredUsers.length > 0"
|
||||
class="user-list"
|
||||
scroll-y="true"
|
||||
>
|
||||
<view
|
||||
v-for="user in filteredUsers"
|
||||
:key="user.id"
|
||||
class="user-item"
|
||||
:class="{ 'selected': isUserSelected(user.id) }"
|
||||
@click="toggleUserSelection(user)"
|
||||
>
|
||||
<text class="user-name">{{ user.name }}</text>
|
||||
<text v-if="isUserSelected(user.id)" class="selected-mark">✓</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<view v-if="selectedReceiverType === 'group'" class="group-selector">
|
||||
<picker-view
|
||||
class="group-picker"
|
||||
:value="selectedGroupIndex"
|
||||
@change="handleGroupChange"
|
||||
>
|
||||
<picker-view-column>
|
||||
<view v-for="(group, index) in messageGroups" :key="group.id" class="picker-option">
|
||||
<text>{{ group.name }} ({{ group.member_count }}人)</text>
|
||||
</view>
|
||||
</picker-view-column>
|
||||
</picker-view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 消息标题 -->
|
||||
<view class="input-section">
|
||||
<text class="section-label">标题</text>
|
||||
<input
|
||||
class="title-input"
|
||||
placeholder="请输入消息标题"
|
||||
v-model="messageData.title"
|
||||
:maxlength="100"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 消息内容 -->
|
||||
<view class="input-section">
|
||||
<text class="section-label">内容 *</text>
|
||||
<textarea
|
||||
class="content-textarea"
|
||||
placeholder="请输入消息内容"
|
||||
v-model="messageData.content"
|
||||
:maxlength="2000"
|
||||
auto-height
|
||||
/>
|
||||
<text class="char-count">{{ getContentLength() }}/2000</text>
|
||||
</view>
|
||||
|
||||
<!-- 消息选项 -->
|
||||
<view class="input-section">
|
||||
<text class="section-label">消息选项</text>
|
||||
<view class="options-grid">
|
||||
<view class="option-item">
|
||||
<switch
|
||||
:checked="messageData.is_urgent"
|
||||
@change="handleUrgentChange"
|
||||
/>
|
||||
<text class="option-label">紧急消息</text>
|
||||
</view>
|
||||
<view class="option-item">
|
||||
<switch
|
||||
:checked="messageData.push_notification"
|
||||
@change="handlePushChange"
|
||||
/>
|
||||
<text class="option-label">推送通知</text>
|
||||
</view>
|
||||
<view class="option-item">
|
||||
<switch
|
||||
:checked="messageData.email_notification"
|
||||
@change="handleEmailChange"
|
||||
/>
|
||||
<text class="option-label">邮件通知</text>
|
||||
</view>
|
||||
<view class="option-item">
|
||||
<switch
|
||||
:checked="enableScheduled"
|
||||
@change="handleScheduledChange"
|
||||
/>
|
||||
<text class="option-label">定时发送</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 定时发送设置 -->
|
||||
<view v-if="enableScheduled" class="input-section">
|
||||
<text class="section-label">发送时间</text>
|
||||
<view class="datetime-picker">
|
||||
<picker-date
|
||||
:value="scheduledDate"
|
||||
@change="handleDateChange"
|
||||
/>
|
||||
<picker-time
|
||||
:value="scheduledTime"
|
||||
@change="handleTimeChange"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 优先级设置 -->
|
||||
<view class="input-section">
|
||||
<text class="section-label">优先级</text>
|
||||
<picker-view
|
||||
class="priority-picker"
|
||||
:value="selectedPriorityIndex"
|
||||
@change="handlePriorityChange"
|
||||
>
|
||||
<picker-view-column>
|
||||
<view v-for="(priority, index) in priorityOptions" :key="priority.value" class="picker-option">
|
||||
<text>{{ priority.label }}</text>
|
||||
</view>
|
||||
</picker-view-column>
|
||||
</picker-view>
|
||||
</view>
|
||||
|
||||
<!-- 发送按钮 -->
|
||||
<view class="send-section">
|
||||
<button
|
||||
class="send-btn"
|
||||
:class="{ 'disabled': !canSend() }"
|
||||
:disabled="!canSend() || sending"
|
||||
@click="handleSend"
|
||||
>
|
||||
<text class="send-text">{{ sending ? '发送中...' : '发送消息' }}</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import type {
|
||||
MessageType,
|
||||
SendMessageParams,
|
||||
UserOption,
|
||||
GroupOption
|
||||
} from '../../utils/msgTypes.uts'
|
||||
|
||||
type ReceiverTypeOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
type PriorityOption = {
|
||||
value: number
|
||||
label: string
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'MessageInput',
|
||||
props: {
|
||||
messageTypes: {
|
||||
type: Array as PropType<Array<MessageType>>,
|
||||
default: (): Array<MessageType> => []
|
||||
},
|
||||
availableUsers: {
|
||||
type: Array as PropType<Array<UserOption>>,
|
||||
default: (): Array<UserOption> => []
|
||||
},
|
||||
messageGroups: {
|
||||
type: Array as PropType<Array<GroupOption>>,
|
||||
default: (): Array<GroupOption> => []
|
||||
},
|
||||
sending: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['send'],
|
||||
data() {
|
||||
return {
|
||||
selectedTypeIndex: [0] as Array<number>,
|
||||
selectedReceiverTypeIndex: [0] as Array<number>,
|
||||
selectedGroupIndex: [0] as Array<number>,
|
||||
selectedPriorityIndex: [1] as Array<number>,
|
||||
|
||||
userSearchKeyword: '' as string,
|
||||
selectedUsers: [] as Array<UserOption>,
|
||||
|
||||
enableScheduled: false as boolean,
|
||||
scheduledDate: '' as string,
|
||||
scheduledTime: '' as string,
|
||||
|
||||
messageData: {
|
||||
title: '' as string,
|
||||
content: '' as string,
|
||||
is_urgent: false as boolean,
|
||||
push_notification: true as boolean,
|
||||
email_notification: false as boolean
|
||||
},
|
||||
|
||||
receiverTypes: [
|
||||
{ value: 'user', label: '指定用户' },
|
||||
{ value: 'group', label: '群组' },
|
||||
{ value: 'broadcast', label: '广播' }
|
||||
] as Array<ReceiverTypeOption>,
|
||||
|
||||
priorityOptions: [
|
||||
{ value: 0, label: '普通' },
|
||||
{ value: 50, label: '中等' },
|
||||
{ value: 80, label: '高' },
|
||||
{ value: 100, label: '最高' }
|
||||
] as Array<PriorityOption>
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
selectedReceiverType(): string {
|
||||
const index = this.selectedReceiverTypeIndex[0]
|
||||
return this.receiverTypes[index].value
|
||||
},
|
||||
|
||||
filteredUsers(): Array<UserOption> {
|
||||
if (this.userSearchKeyword.length === 0) {
|
||||
return this.availableUsers
|
||||
}
|
||||
|
||||
const keyword = this.userSearchKeyword.toLowerCase()
|
||||
const filtered: Array<UserOption> = []
|
||||
|
||||
for (let i = 0; i < this.availableUsers.length; i++) {
|
||||
const user = this.availableUsers[i]
|
||||
if (user.name.toLowerCase().indexOf(keyword) !== -1) {
|
||||
filtered.push(user)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleTypeChange(e: UniPickerViewChangeEvent) {
|
||||
this.selectedTypeIndex = e.detail.value
|
||||
},
|
||||
|
||||
handleReceiverTypeChange(e: UniPickerViewChangeEvent) {
|
||||
this.selectedReceiverTypeIndex = e.detail.value
|
||||
// 清空之前的选择
|
||||
this.selectedUsers = []
|
||||
this.selectedGroupIndex = [0]
|
||||
},
|
||||
|
||||
handleGroupChange(e: UniPickerViewChangeEvent) {
|
||||
this.selectedGroupIndex = e.detail.value
|
||||
},
|
||||
|
||||
handlePriorityChange(e: UniPickerViewChangeEvent) {
|
||||
this.selectedPriorityIndex = e.detail.value
|
||||
},
|
||||
|
||||
handleUserSearch() {
|
||||
// 搜索逻辑在计算属性中处理
|
||||
},
|
||||
|
||||
isUserSelected(userId: string): boolean {
|
||||
for (let i = 0; i < this.selectedUsers.length; i++) {
|
||||
if (this.selectedUsers[i].id === userId) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
|
||||
toggleUserSelection(user: UserOption) {
|
||||
const index = this.findUserIndex(user.id)
|
||||
if (index !== -1) {
|
||||
this.selectedUsers.splice(index, 1)
|
||||
} else {
|
||||
this.selectedUsers.push(user)
|
||||
}
|
||||
},
|
||||
|
||||
findUserIndex(userId: string): number {
|
||||
for (let i = 0; i < this.selectedUsers.length; i++) {
|
||||
if (this.selectedUsers[i].id === userId) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
},
|
||||
|
||||
handleUrgentChange(e: UniSwitchChangeEvent) {
|
||||
this.messageData.is_urgent = e.detail.value
|
||||
},
|
||||
|
||||
handlePushChange(e: UniSwitchChangeEvent) {
|
||||
this.messageData.push_notification = e.detail.value
|
||||
},
|
||||
|
||||
handleEmailChange(e: UniSwitchChangeEvent) {
|
||||
this.messageData.email_notification = e.detail.value
|
||||
},
|
||||
|
||||
handleScheduledChange(e: UniSwitchChangeEvent) {
|
||||
this.enableScheduled = e.detail.value
|
||||
if (!this.enableScheduled) {
|
||||
this.scheduledDate = ''
|
||||
this.scheduledTime = ''
|
||||
}
|
||||
},
|
||||
|
||||
handleDateChange(e: any) {
|
||||
this.scheduledDate = e.detail.value
|
||||
},
|
||||
|
||||
handleTimeChange(e: any) {
|
||||
this.scheduledTime = e.detail.value
|
||||
},
|
||||
|
||||
getContentLength(): number {
|
||||
return this.messageData.content.length
|
||||
},
|
||||
|
||||
canSend(): boolean {
|
||||
// 必须有内容
|
||||
if (this.messageData.content.trim().length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 必须选择接收者
|
||||
if (this.selectedReceiverType === 'user' && this.selectedUsers.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 必须选择消息类型
|
||||
if (this.messageTypes.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
|
||||
handleSend() {
|
||||
if (!this.canSend() || this.sending) {
|
||||
return
|
||||
}
|
||||
|
||||
const typeIndex = this.selectedTypeIndex[0]
|
||||
const priorityIndex = this.selectedPriorityIndex[0]
|
||||
|
||||
const params: SendMessageParams = {
|
||||
message_type_id: this.messageTypes[typeIndex].id,
|
||||
receiver_type: this.selectedReceiverType,
|
||||
receiver_id: this.getReceiverId(),
|
||||
title: this.messageData.title.length > 0 ? this.messageData.title : null,
|
||||
content: this.messageData.content.trim(),
|
||||
content_type: 'text',
|
||||
attachments: null,
|
||||
priority: this.priorityOptions[priorityIndex].value,
|
||||
expires_at: null,
|
||||
is_urgent: this.messageData.is_urgent,
|
||||
push_notification: this.messageData.push_notification,
|
||||
email_notification: this.messageData.email_notification,
|
||||
sms_notification: false,
|
||||
scheduled_at: this.getScheduledDateTime(),
|
||||
conversation_id: null,
|
||||
parent_message_id: null,
|
||||
metadata: null
|
||||
}
|
||||
|
||||
this.$emit('send', params)
|
||||
},
|
||||
|
||||
getReceiverId(): string | null {
|
||||
if (this.selectedReceiverType === 'user') {
|
||||
if (this.selectedUsers.length === 1) {
|
||||
return this.selectedUsers[0].id
|
||||
}
|
||||
return null // 多用户发送
|
||||
} else if (this.selectedReceiverType === 'group') {
|
||||
const groupIndex = this.selectedGroupIndex[0]
|
||||
return this.messageGroups[groupIndex].id
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
getScheduledDateTime(): string | null {
|
||||
if (!this.enableScheduled || this.scheduledDate.length === 0 || this.scheduledTime.length === 0) {
|
||||
return null
|
||||
}
|
||||
return `${this.scheduledDate} ${this.scheduledTime}:00`
|
||||
},
|
||||
|
||||
resetForm() {
|
||||
this.messageData.title = ''
|
||||
this.messageData.content = ''
|
||||
this.messageData.is_urgent = false
|
||||
this.messageData.push_notification = true
|
||||
this.messageData.email_notification = false
|
||||
this.selectedUsers = []
|
||||
this.userSearchKeyword = ''
|
||||
this.enableScheduled = false
|
||||
this.scheduledDate = ''
|
||||
this.scheduledTime = ''
|
||||
this.selectedTypeIndex = [0]
|
||||
this.selectedReceiverTypeIndex = [0]
|
||||
this.selectedGroupIndex = [0]
|
||||
this.selectedPriorityIndex = [1]
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-input {
|
||||
background-color: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.input-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #333333;
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.type-picker,
|
||||
.receiver-type-picker,
|
||||
.group-picker,
|
||||
.priority-picker {
|
||||
height: 120px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.picker-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.receiver-selector {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.user-selector {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.user-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-list {
|
||||
max-height: 150px;
|
||||
margin-top: 8px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.user-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.user-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.user-item.selected {
|
||||
background-color: #e3f2fd;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.selected-mark {
|
||||
font-size: 16px;
|
||||
color: #2196f3;
|
||||
}
|
||||
|
||||
.title-input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.content-textarea {
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.char-count {
|
||||
font-size: 12px;
|
||||
color: #999999;
|
||||
text-align: right;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.options-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.option-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.option-label {
|
||||
font-size: 14px;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.datetime-picker {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.send-section {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background-color: #2196f3;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.send-btn.disabled {
|
||||
background-color: #cccccc;
|
||||
}
|
||||
|
||||
.send-text {
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.options-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.datetime-picker {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
353
components/message/MessageItem.uvue
Normal file
353
components/message/MessageItem.uvue
Normal file
@@ -0,0 +1,353 @@
|
||||
<template>
|
||||
<view class="message-item" :class="{ 'unread': !getIsRead(), 'urgent': item.is_urgent, 'starred': getIsStarred() }" @click="handleClick">
|
||||
<!-- 消息类型指示器 -->
|
||||
<view class="message-indicator" :style="{ backgroundColor: getTypeColor(getMessageType()) }"></view>
|
||||
|
||||
<!-- 消息头部 -->
|
||||
<view class="message-header">
|
||||
<view class="message-meta">
|
||||
<text class="message-type">{{ getTypeName(getMessageType()) }}</text>
|
||||
<text class="message-time">{{ formatTime(item.created_at) }}</text>
|
||||
<text v-if="item.is_urgent" class="urgent-badge">紧急</text>
|
||||
</view>
|
||||
<view class="message-actions">
|
||||
<text v-if="getIsStarred()" class="star-icon">★</text>
|
||||
<text v-if="getIsArchived()" class="archive-icon"></text>
|
||||
<text class="more-icon" @click.stop="toggleActionMenu">⋯</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 消息主体 -->
|
||||
<view class="message-body">
|
||||
<text class="message-title" v-if="item.title !== null">{{ item.title }}</text>
|
||||
<text class="message-content">{{ getDisplayContent(item.content) }}</text>
|
||||
|
||||
<!-- 附件指示 -->
|
||||
<view v-if="hasAttachments(item)" class="attachment-indicator">
|
||||
<text class="attachment-icon"></text>
|
||||
<text class="attachment-count">{{ getAttachmentCount(item) }}个附件</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 消息底部 -->
|
||||
<view class="message-footer">
|
||||
<text class="sender-info">{{ getSenderInfo(item) }}</text>
|
||||
<view class="message-stats">
|
||||
<text v-if="item.reply_count > 0" class="reply-count">{{ item.reply_count }}回复</text>
|
||||
<text v-if="item.read_count > 0 && item.total_recipients > 0" class="read-status">
|
||||
{{ item.read_count }}/{{ item.total_recipients }}已读
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 操作菜单 -->
|
||||
<view v-if="showActionMenu" class="action-menu" @click.stop="">
|
||||
<view class="action-item" @click="handleAction('read')">
|
||||
<text>{{ getIsRead() ? '标为未读' : '标为已读' }}</text>
|
||||
</view>
|
||||
<view class="action-item" @click="handleAction('star')">
|
||||
<text>{{ getIsStarred() ? '取消收藏' : '收藏' }}</text> </view>
|
||||
<view class="action-item" @click="handleAction('archive')">
|
||||
<text>{{ getIsArchived() ? '取消归档' : '归档' }}</text>
|
||||
</view>
|
||||
<view class="action-item danger" @click="handleAction('delete')">
|
||||
<text>删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import { formatTime, getTypeName, getTypeColor, truncateText } from '../../utils/msgUtils.uts'
|
||||
import type { MessageWithRecipient } from '../../utils/msgTypes.uts'
|
||||
|
||||
export default {
|
||||
name: 'MessageItem', props: {
|
||||
item: {
|
||||
type: Object as PropType<MessageWithRecipient>,
|
||||
required: true
|
||||
},
|
||||
showActionButtons: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
emits: ['click', 'action'],
|
||||
data() {
|
||||
return {
|
||||
showActionMenu: false as boolean
|
||||
}
|
||||
},
|
||||
methods: { handleClick() {
|
||||
this.$emit('click', this.item)
|
||||
},
|
||||
|
||||
toggleActionMenu() {
|
||||
this.showActionMenu = !this.showActionMenu
|
||||
},
|
||||
|
||||
handleAction(action: string) {
|
||||
this.showActionMenu = false
|
||||
this.$emit('action', {
|
||||
action: action,
|
||||
item: this.item
|
||||
})
|
||||
},
|
||||
|
||||
formatTime(dateStr: string): string {
|
||||
return formatTime(dateStr)
|
||||
},
|
||||
|
||||
getTypeName(typeId: string): string {
|
||||
return getTypeName(typeId)
|
||||
},
|
||||
|
||||
getTypeColor(typeId: string): string {
|
||||
return getTypeColor(typeId)
|
||||
},
|
||||
getDisplayContent(content: string | null): string {
|
||||
if (content === null) {
|
||||
return '无内容'
|
||||
}
|
||||
if (content.length > 100) {
|
||||
return content.substring(0, 100) + '...'
|
||||
}
|
||||
return content
|
||||
},
|
||||
hasAttachments(item: MessageWithRecipient): boolean {
|
||||
if (item.attachments === null) {
|
||||
return false
|
||||
}
|
||||
const attachments = item.attachments as UTSJSONObject
|
||||
const files = attachments.getAny('files')
|
||||
return files !== null
|
||||
},
|
||||
|
||||
getAttachmentCount(item: MessageWithRecipient): number {
|
||||
if (item.attachments === null) {
|
||||
return 0
|
||||
}
|
||||
const attachments = item.attachments as UTSJSONObject
|
||||
const files = attachments.getAny('files') as Array<UTSJSONObject> | null
|
||||
return files !== null ? files.length : 0
|
||||
},
|
||||
|
||||
getSenderInfo(item: MessageWithRecipient): string {
|
||||
if (item.sender_name !== null) {
|
||||
return `来自: ${item.sender_name}`
|
||||
}
|
||||
if (item.sender_type === 'system') {
|
||||
return '系统消息'
|
||||
} else if (item.sender_type === 'device') {
|
||||
return '设备消息'
|
||||
} else {
|
||||
return '未知发送者'
|
||||
}
|
||||
},
|
||||
|
||||
// 获取消息类型代码
|
||||
getMessageType(): string {
|
||||
return this.item.message_type ?? this.item.message_type_id ?? 'default'
|
||||
},
|
||||
|
||||
// 获取是否已读
|
||||
getIsRead(): boolean {
|
||||
return this.item.is_read ?? false
|
||||
},
|
||||
|
||||
// 获取是否已收藏
|
||||
getIsStarred(): boolean {
|
||||
return this.item.is_starred ?? false
|
||||
},
|
||||
|
||||
// 获取是否已归档
|
||||
getIsArchived(): boolean {
|
||||
return this.item.is_archived ?? false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-item {
|
||||
position: relative;
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
padding: 16px;
|
||||
border-left: 4px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.message-item.unread {
|
||||
background-color: #f8f9ff;
|
||||
border-left-color: #2196f3;
|
||||
}
|
||||
|
||||
.message-item.urgent {
|
||||
border-left-color: #f44336;
|
||||
}
|
||||
|
||||
.message-item.starred {
|
||||
background-color: #fffbf0;
|
||||
}
|
||||
|
||||
.message-indicator {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.message-type {
|
||||
font-size: 12px;
|
||||
color: #666666;
|
||||
background-color: #f5f5f5;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 12px;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.urgent-badge {
|
||||
font-size: 10px;
|
||||
color: #ffffff;
|
||||
background-color: #f44336;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.message-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.star-icon {
|
||||
color: #ff9800;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.archive-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.more-icon {
|
||||
font-size: 16px;
|
||||
color: #666666;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.message-body {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.message-title {
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: #333333;
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.attachment-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.attachment-icon {
|
||||
font-size: 14px;
|
||||
color: #2196f3;
|
||||
}
|
||||
|
||||
.attachment-count {
|
||||
font-size: 12px;
|
||||
color: #2196f3;
|
||||
}
|
||||
|
||||
.message-footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.sender-info {
|
||||
font-size: 12px;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.message-stats {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.reply-count,
|
||||
.read-status {
|
||||
font-size: 12px;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.action-menu {
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
right: 16px;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.action-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.action-item text {
|
||||
font-size: 14px;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.action-item.danger text {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.action-item:active {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
</style>
|
||||
436
components/message/MessageList.uvue
Normal file
436
components/message/MessageList.uvue
Normal file
@@ -0,0 +1,436 @@
|
||||
<template>
|
||||
<view class="message-list">
|
||||
<!-- 加载状态 -->
|
||||
<view v-if="loading && messages.length === 0" class="loading-container">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view v-else-if="!loading && messages.length === 0" class="empty-container">
|
||||
<text class="empty-icon"></text>
|
||||
<text class="empty-text">暂无消息</text>
|
||||
<text class="empty-desc">您还没有收到任何消息</text>
|
||||
</view>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<scroll-view
|
||||
v-else
|
||||
class="message-scroll"
|
||||
scroll-y="true"
|
||||
@scrolltolower="handleLoadMore"
|
||||
:refresher-enabled="true"
|
||||
@refresherrefresh="handleRefresh"
|
||||
:refresher-triggered="refreshing"
|
||||
>
|
||||
<!-- 批量选择模式头部 -->
|
||||
<view v-if="selectionMode" class="selection-header">
|
||||
<view class="selection-info">
|
||||
<text class="selection-count">已选择 {{ selectedIds.length }} 条消息</text>
|
||||
</view>
|
||||
<view class="selection-actions">
|
||||
<button class="action-btn" @click="handleBatchRead">
|
||||
<text class="action-text">标为已读</text>
|
||||
</button>
|
||||
<button class="action-btn" @click="handleBatchStar">
|
||||
<text class="action-text">收藏</text>
|
||||
</button>
|
||||
<button class="action-btn danger" @click="handleBatchDelete">
|
||||
<text class="action-text">删除</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 消息项列表 -->
|
||||
<view class="message-items">
|
||||
<view
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
class="message-item-wrapper"
|
||||
:class="{ 'selected': isSelected(message.id) }"
|
||||
>
|
||||
<!-- 选择框 -->
|
||||
<checkbox
|
||||
v-if="selectionMode"
|
||||
class="selection-checkbox"
|
||||
:checked="isSelected(message.id)"
|
||||
@change="handleToggleSelection(message.id)"
|
||||
/>
|
||||
|
||||
<!-- 消息内容 -->
|
||||
<MessageItem
|
||||
:item="message"
|
||||
:show-actions="!selectionMode"
|
||||
@click="handleMessageClick"
|
||||
@action="handleMessageAction"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<view v-if="hasMore" class="load-more-container">
|
||||
<view v-if="loadingMore" class="loading-more">
|
||||
<text class="loading-text">加载更多...</text>
|
||||
</view>
|
||||
<button v-else class="load-more-btn" @click="handleLoadMore">
|
||||
<text class="load-more-text">加载更多</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<!-- 已到底部 -->
|
||||
<view v-else-if="messages.length > 0" class="end-container">
|
||||
<text class="end-text">已显示全部消息</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 浮动操作按钮 -->
|
||||
<view class="fab-container">
|
||||
<view v-if="!selectionMode" class="fab-group">
|
||||
<button class="fab-btn" @click="handleToggleSelection">
|
||||
<text class="fab-icon">☑️</text>
|
||||
</button>
|
||||
<button class="fab-btn primary" @click="handleCompose">
|
||||
<text class="fab-icon">✏️</text>
|
||||
</button>
|
||||
</view>
|
||||
<view v-else class="fab-group">
|
||||
<button class="fab-btn" @click="handleCancelSelection">
|
||||
<text class="fab-icon">❌</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import MessageItem from './MessageItem.uvue'
|
||||
import type { Message } from '../../utils/msgTypes.uts'
|
||||
|
||||
export default {
|
||||
name: 'MessageList',
|
||||
components: {
|
||||
MessageItem
|
||||
},
|
||||
props: {
|
||||
messages: {
|
||||
type: Array as PropType<Array<Message>>,
|
||||
default: (): Array<Message> => []
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
loadingMore: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
refreshing: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
hasMore: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
selectionMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['click', 'action', 'refresh', 'load-more', 'compose', 'batch-action', 'toggle-selection'],
|
||||
data() {
|
||||
return {
|
||||
selectedIds: [] as Array<string>
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
selectionMode(newVal: boolean) {
|
||||
if (!newVal) {
|
||||
this.selectedIds = []
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleMessageClick(message: Message) {
|
||||
if (this.selectionMode) {
|
||||
this.handleToggleSelection(message.id)
|
||||
} else {
|
||||
this.$emit('click', message)
|
||||
}
|
||||
},
|
||||
|
||||
handleMessageAction(event: any) {
|
||||
this.$emit('action', event)
|
||||
},
|
||||
|
||||
handleRefresh() {
|
||||
this.$emit('refresh')
|
||||
},
|
||||
|
||||
handleLoadMore() {
|
||||
if (!this.loadingMore && this.hasMore) {
|
||||
this.$emit('load-more')
|
||||
}
|
||||
},
|
||||
|
||||
handleCompose() {
|
||||
this.$emit('compose')
|
||||
},
|
||||
|
||||
handleToggleSelection() {
|
||||
this.$emit('toggle-selection', !this.selectionMode)
|
||||
},
|
||||
|
||||
handleCancelSelection() {
|
||||
this.selectedIds = []
|
||||
this.$emit('toggle-selection', false)
|
||||
},
|
||||
|
||||
isSelected(messageId: string): boolean {
|
||||
return this.selectedIds.indexOf(messageId) !== -1
|
||||
},
|
||||
|
||||
handleToggleSelection(messageId: string) {
|
||||
const index = this.selectedIds.indexOf(messageId)
|
||||
if (index !== -1) {
|
||||
this.selectedIds.splice(index, 1)
|
||||
} else {
|
||||
this.selectedIds.push(messageId)
|
||||
}
|
||||
},
|
||||
|
||||
handleBatchRead() {
|
||||
if (this.selectedIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this.$emit('batch-action', {
|
||||
action: 'read',
|
||||
messageIds: [...this.selectedIds]
|
||||
})
|
||||
},
|
||||
|
||||
handleBatchStar() {
|
||||
if (this.selectedIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this.$emit('batch-action', {
|
||||
action: 'star',
|
||||
messageIds: [...this.selectedIds]
|
||||
})
|
||||
},
|
||||
|
||||
handleBatchDelete() {
|
||||
if (this.selectedIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
uni.showModal({
|
||||
title: '确认删除',
|
||||
content: `确定要删除选中的 ${this.selectedIds.length} 条消息吗?`,
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
this.$emit('batch-action', {
|
||||
action: 'delete',
|
||||
messageIds: [...this.selectedIds]
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-list {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.loading-container,
|
||||
.empty-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
color: #333333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-desc {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.message-scroll {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.selection-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background-color: #f5f5f5;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.selection-count {
|
||||
font-size: 14px;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.selection-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.action-btn.danger {
|
||||
border-color: #f44336;
|
||||
}
|
||||
|
||||
.action-text {
|
||||
font-size: 12px;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.action-btn.danger .action-text {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.message-items {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.message-item-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.message-item-wrapper.selected {
|
||||
background-color: #e3f2fd;
|
||||
}
|
||||
|
||||
.selection-checkbox {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.load-more-container,
|
||||
.end-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.load-more-btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.load-more-text {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.end-text {
|
||||
font-size: 12px;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.fab-container {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.fab-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.fab-btn {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.fab-btn.primary {
|
||||
background-color: #2196f3;
|
||||
border-color: #2196f3;
|
||||
}
|
||||
|
||||
.fab-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.fab-btn.primary .fab-icon {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.fab-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* 响应式布局 */
|
||||
@media (max-width: 480px) {
|
||||
.selection-header {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.selection-actions {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.fab-container {
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
656
components/message/MessageSearch.uvue
Normal file
656
components/message/MessageSearch.uvue
Normal file
@@ -0,0 +1,656 @@
|
||||
<template>
|
||||
<view class="message-search">
|
||||
<!-- 搜索框 -->
|
||||
<view class="search-header">
|
||||
<view class="search-input-wrapper">
|
||||
<text class="search-icon"></text>
|
||||
<input
|
||||
class="search-input"
|
||||
type="text"
|
||||
placeholder="搜索消息内容、标题或发送者"
|
||||
v-model="keyword"
|
||||
@input="handleInput"
|
||||
@confirm="handleSearch"
|
||||
/>
|
||||
<text
|
||||
v-if="keyword.length > 0"
|
||||
class="clear-icon"
|
||||
@click="handleClear"
|
||||
>
|
||||
✖️
|
||||
</text>
|
||||
</view>
|
||||
<button class="search-btn" @click="handleSearch">
|
||||
<text class="search-text">搜索</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<!-- 搜索筛选 -->
|
||||
<view class="search-filters">
|
||||
<scroll-view class="filter-scroll" scroll-x="true">
|
||||
<view class="filter-items">
|
||||
<!-- 消息类型筛选 -->
|
||||
<view
|
||||
class="filter-item"
|
||||
:class="{ 'active': selectedTypeId === '' }"
|
||||
@click="handleTypeFilter('')"
|
||||
>
|
||||
<text class="filter-text">全部类型</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="type in messageTypes"
|
||||
:key="type.id"
|
||||
class="filter-item"
|
||||
:class="{ 'active': selectedTypeId === type.id }"
|
||||
@click="handleTypeFilter(type.id)"
|
||||
>
|
||||
<text class="filter-text">{{ type.name }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 时间筛选 -->
|
||||
<view class="filter-divider"></view>
|
||||
<view
|
||||
v-for="timeRange in timeRanges"
|
||||
:key="timeRange.value"
|
||||
class="filter-item"
|
||||
:class="{ 'active': selectedTimeRange === timeRange.value }"
|
||||
@click="handleTimeFilter(timeRange.value)"
|
||||
>
|
||||
<text class="filter-text">{{ timeRange.label }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 状态筛选 -->
|
||||
<view class="filter-divider"></view>
|
||||
<view
|
||||
v-for="status in statusOptions"
|
||||
:key="status.value"
|
||||
class="filter-item"
|
||||
:class="{ 'active': selectedStatus === status.value }"
|
||||
@click="handleStatusFilter(status.value)"
|
||||
>
|
||||
<text class="filter-text">{{ status.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<!-- 搜索历史 -->
|
||||
<view v-if="showHistory && searchHistory.length > 0" class="search-history">
|
||||
<view class="history-header">
|
||||
<text class="history-title">搜索历史</text>
|
||||
<text class="clear-history" @click="clearHistory">清空</text>
|
||||
</view>
|
||||
<view class="history-items">
|
||||
<view
|
||||
v-for="(item, index) in searchHistory"
|
||||
:key="index"
|
||||
class="history-item"
|
||||
@click="handleHistoryClick(item)"
|
||||
>
|
||||
<text class="history-icon"></text>
|
||||
<text class="history-text">{{ item }}</text>
|
||||
<text class="remove-history" @click.stop="removeHistoryItem(index)">✖️</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 搜索建议 -->
|
||||
<view v-if="showSuggestions && suggestions.length > 0" class="search-suggestions">
|
||||
<view class="suggestions-header">
|
||||
<text class="suggestions-title">搜索建议</text>
|
||||
</view>
|
||||
<view class="suggestions-items">
|
||||
<view
|
||||
v-for="suggestion in suggestions"
|
||||
:key="suggestion"
|
||||
class="suggestion-item"
|
||||
@click="handleSuggestionClick(suggestion)"
|
||||
>
|
||||
<text class="suggestion-icon"></text>
|
||||
<text class="suggestion-text">{{ suggestion }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 搜索结果 -->
|
||||
<view v-if="showResults" class="search-results">
|
||||
<!-- 结果统计 -->
|
||||
<view class="results-header">
|
||||
<text class="results-count">找到 {{ totalResults }} 条消息</text>
|
||||
<view class="sort-options">
|
||||
<picker-view
|
||||
class="sort-picker"
|
||||
:value="[selectedSortIndex]"
|
||||
@change="handleSortChange"
|
||||
>
|
||||
<picker-view-column>
|
||||
<view v-for="(option, index) in sortOptions" :key="option.value">
|
||||
<text class="sort-text">{{ option.label }}</text>
|
||||
</view>
|
||||
</picker-view-column>
|
||||
</picker-view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 结果列表 -->
|
||||
<MessageList
|
||||
:messages="searchResults"
|
||||
:loading="searching"
|
||||
:loading-more="loadingMore"
|
||||
:has-more="hasMoreResults"
|
||||
@click="handleResultClick"
|
||||
@action="handleResultAction"
|
||||
@load-more="handleLoadMoreResults"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 无结果状态 -->
|
||||
<view v-if="showNoResults" class="no-results">
|
||||
<text class="no-results-icon"></text>
|
||||
<text class="no-results-text">未找到相关消息</text>
|
||||
<text class="no-results-desc">尝试使用其他关键词或调整筛选条件</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import MessageList from './MessageList.uvue'
|
||||
import type { Message, MessageType } from '../../utils/msgTypes.uts'
|
||||
|
||||
type TimeRange = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
type StatusOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
type SortOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'MessageSearch',
|
||||
components: {
|
||||
MessageList
|
||||
},
|
||||
props: {
|
||||
messageTypes: {
|
||||
type: Array as PropType<Array<MessageType>>,
|
||||
default: (): Array<MessageType> => []
|
||||
},
|
||||
searching: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['search', 'result-click', 'result-action'],
|
||||
data() {
|
||||
return {
|
||||
keyword: '' as string,
|
||||
selectedTypeId: '' as string,
|
||||
selectedTimeRange: '' as string,
|
||||
selectedStatus: '' as string,
|
||||
selectedSortIndex: [0] as Array<number>,
|
||||
|
||||
searchResults: [] as Array<Message>,
|
||||
totalResults: 0 as number,
|
||||
loadingMore: false as boolean,
|
||||
hasMoreResults: false as boolean,
|
||||
|
||||
searchHistory: [] as Array<string>,
|
||||
suggestions: [] as Array<string>,
|
||||
|
||||
showHistory: true as boolean,
|
||||
showSuggestions: false as boolean,
|
||||
showResults: false as boolean,
|
||||
showNoResults: false as boolean,
|
||||
|
||||
timeRanges: [
|
||||
{ value: '', label: '全部时间' },
|
||||
{ value: 'today', label: '今天' },
|
||||
{ value: 'week', label: '本周' },
|
||||
{ value: 'month', label: '本月' }
|
||||
] as Array<TimeRange>,
|
||||
|
||||
statusOptions: [
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: 'unread', label: '未读' },
|
||||
{ value: 'read', label: '已读' },
|
||||
{ value: 'starred', label: '已收藏' },
|
||||
{ value: 'urgent', label: '紧急' }
|
||||
] as Array<StatusOption>,
|
||||
|
||||
sortOptions: [
|
||||
{ value: 'time_desc', label: '时间降序' },
|
||||
{ value: 'time_asc', label: '时间升序' },
|
||||
{ value: 'priority_desc', label: '优先级降序' },
|
||||
{ value: 'relevance', label: '相关度' }
|
||||
] as Array<SortOption>
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadSearchHistory()
|
||||
this.loadSuggestions()
|
||||
},
|
||||
methods: {
|
||||
handleInput() {
|
||||
// 实时搜索建议
|
||||
if (this.keyword.length > 0) {
|
||||
this.showHistory = false
|
||||
this.showSuggestions = true
|
||||
this.generateSuggestions()
|
||||
} else {
|
||||
this.showSuggestions = false
|
||||
this.showHistory = true
|
||||
this.showResults = false
|
||||
this.showNoResults = false
|
||||
}
|
||||
},
|
||||
|
||||
handleSearch() {
|
||||
if (this.keyword.trim().length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this.performSearch()
|
||||
this.addToHistory(this.keyword.trim())
|
||||
this.showHistory = false
|
||||
this.showSuggestions = false
|
||||
},
|
||||
|
||||
handleClear() {
|
||||
this.keyword = ''
|
||||
this.showHistory = true
|
||||
this.showSuggestions = false
|
||||
this.showResults = false
|
||||
this.showNoResults = false
|
||||
},
|
||||
|
||||
handleTypeFilter(typeId: string) {
|
||||
this.selectedTypeId = typeId
|
||||
if (this.keyword.trim().length > 0) {
|
||||
this.performSearch()
|
||||
}
|
||||
},
|
||||
|
||||
handleTimeFilter(timeRange: string) {
|
||||
this.selectedTimeRange = timeRange
|
||||
if (this.keyword.trim().length > 0) {
|
||||
this.performSearch()
|
||||
}
|
||||
},
|
||||
|
||||
handleStatusFilter(status: string) {
|
||||
this.selectedStatus = status
|
||||
if (this.keyword.trim().length > 0) {
|
||||
this.performSearch()
|
||||
}
|
||||
},
|
||||
|
||||
handleSortChange(e: UniPickerViewChangeEvent) {
|
||||
this.selectedSortIndex = e.detail.value
|
||||
if (this.keyword.trim().length > 0) {
|
||||
this.performSearch()
|
||||
}
|
||||
},
|
||||
|
||||
handleHistoryClick(historyItem: string) {
|
||||
this.keyword = historyItem
|
||||
this.handleSearch()
|
||||
},
|
||||
|
||||
handleSuggestionClick(suggestion: string) {
|
||||
this.keyword = suggestion
|
||||
this.handleSearch()
|
||||
},
|
||||
|
||||
removeHistoryItem(index: number) {
|
||||
this.searchHistory.splice(index, 1)
|
||||
this.saveSearchHistory()
|
||||
},
|
||||
|
||||
clearHistory() {
|
||||
this.searchHistory = []
|
||||
this.saveSearchHistory()
|
||||
},
|
||||
|
||||
handleResultClick(message: Message) {
|
||||
this.$emit('result-click', message)
|
||||
},
|
||||
|
||||
handleResultAction(event: any) {
|
||||
this.$emit('result-action', event)
|
||||
},
|
||||
|
||||
handleLoadMoreResults() {
|
||||
// 加载更多搜索结果
|
||||
if (!this.loadingMore && this.hasMoreResults) {
|
||||
this.loadingMore = true
|
||||
// TODO: 实现分页搜索
|
||||
this.loadingMore = false
|
||||
}
|
||||
},
|
||||
|
||||
performSearch() {
|
||||
const searchParams = {
|
||||
keyword: this.keyword.trim(),
|
||||
typeId: this.selectedTypeId,
|
||||
timeRange: this.selectedTimeRange,
|
||||
status: this.selectedStatus,
|
||||
sort: this.sortOptions[this.selectedSortIndex[0]].value
|
||||
}
|
||||
|
||||
this.$emit('search', searchParams)
|
||||
this.showResults = true
|
||||
},
|
||||
|
||||
updateSearchResults(results: Array<Message>, total: number, hasMore: boolean) {
|
||||
this.searchResults = results
|
||||
this.totalResults = total
|
||||
this.hasMoreResults = hasMore
|
||||
this.showResults = true
|
||||
this.showNoResults = results.length === 0
|
||||
},
|
||||
|
||||
addToHistory(keyword: string) {
|
||||
const index = this.searchHistory.indexOf(keyword)
|
||||
if (index !== -1) {
|
||||
this.searchHistory.splice(index, 1)
|
||||
}
|
||||
|
||||
this.searchHistory.unshift(keyword)
|
||||
|
||||
// 限制历史记录数量
|
||||
if (this.searchHistory.length > 10) {
|
||||
this.searchHistory = this.searchHistory.slice(0, 10)
|
||||
}
|
||||
|
||||
this.saveSearchHistory()
|
||||
},
|
||||
|
||||
loadSearchHistory() {
|
||||
try {
|
||||
const historyStr = uni.getStorageSync('message_search_history')
|
||||
if (historyStr !== null && historyStr !== '') {
|
||||
this.searchHistory = JSON.parse(historyStr as string) as Array<string>
|
||||
}
|
||||
} catch (e) {
|
||||
this.searchHistory = []
|
||||
}
|
||||
},
|
||||
|
||||
saveSearchHistory() {
|
||||
try {
|
||||
uni.setStorageSync('message_search_history', JSON.stringify(this.searchHistory))
|
||||
} catch (e) {
|
||||
console.error('保存搜索历史失败:', e)
|
||||
}
|
||||
},
|
||||
|
||||
loadSuggestions() {
|
||||
// 加载常用搜索建议
|
||||
this.suggestions = [
|
||||
'系统通知',
|
||||
'训练计划',
|
||||
'作业提醒',
|
||||
'紧急消息',
|
||||
'今日消息'
|
||||
]
|
||||
},
|
||||
|
||||
generateSuggestions() {
|
||||
// 根据关键词生成建议
|
||||
const keyword = this.keyword.toLowerCase()
|
||||
const filtered: Array<string> = []
|
||||
|
||||
for (let i = 0; i < this.suggestions.length; i++) {
|
||||
const suggestion = this.suggestions[i]
|
||||
if (suggestion.toLowerCase().indexOf(keyword) !== -1) {
|
||||
filtered.push(suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
this.suggestions = filtered
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-search {
|
||||
height: 100%;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.search-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background-color: #ffffff;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
font-size: 16px;
|
||||
color: #666666;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
color: #333333;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.clear-icon {
|
||||
font-size: 14px;
|
||||
color: #999999;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
padding: 8px 16px;
|
||||
background-color: #2196f3;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.search-text {
|
||||
font-size: 14px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.search-filters {
|
||||
background-color: #ffffff;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.filter-scroll {
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.filter-items {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
padding: 6px 12px;
|
||||
border-radius: 16px;
|
||||
background-color: #f0f0f0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-item.active {
|
||||
background-color: #2196f3;
|
||||
}
|
||||
|
||||
.filter-text {
|
||||
font-size: 12px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.filter-item.active .filter-text {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.filter-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background-color: #e0e0e0;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.search-history,
|
||||
.search-suggestions {
|
||||
background-color: #ffffff;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.history-header,
|
||||
.suggestions-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.history-title,
|
||||
.suggestions-title {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.clear-history {
|
||||
font-size: 12px;
|
||||
color: #2196f3;
|
||||
}
|
||||
|
||||
.history-items,
|
||||
.suggestions-items {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.history-item,
|
||||
.suggestion-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f8f8f8;
|
||||
}
|
||||
|
||||
.history-icon,
|
||||
.suggestion-icon {
|
||||
font-size: 14px;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.history-text,
|
||||
.suggestion-text {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.remove-history {
|
||||
font-size: 12px;
|
||||
color: #999999;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
flex: 1;
|
||||
background-color: #ffffff;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.results-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.results-count {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.sort-picker {
|
||||
width: 120px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.sort-text {
|
||||
font-size: 12px;
|
||||
color: #333333;
|
||||
text-align: center;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 300px;
|
||||
background-color: #ffffff;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.no-results-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.no-results-text {
|
||||
font-size: 16px;
|
||||
color: #333333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.no-results-desc {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 响应式布局 */
|
||||
@media (max-width: 480px) {
|
||||
.search-header {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.filter-items {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.results-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
252
components/message/MessageStats.uvue
Normal file
252
components/message/MessageStats.uvue
Normal file
@@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<view class="message-stats">
|
||||
<view class="stats-header">
|
||||
<text class="stats-title">消息统计</text>
|
||||
<text class="stats-time">{{ getCurrentTime() }}</text>
|
||||
</view>
|
||||
|
||||
<view class="stats-grid">
|
||||
<!-- 总消息数 -->
|
||||
<view class="stat-item">
|
||||
<view class="stat-icon total"></view>
|
||||
<view class="stat-content">
|
||||
<text class="stat-number">{{ stats.total_count }}</text>
|
||||
<text class="stat-label">总消息</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 未读消息数 -->
|
||||
<view class="stat-item" :class="{ 'highlight': stats.unread_count > 0 }">
|
||||
<view class="stat-icon unread"></view>
|
||||
<view class="stat-content">
|
||||
<text class="stat-number">{{ stats.unread_count }}</text>
|
||||
<text class="stat-label">未读</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 收藏消息数 -->
|
||||
<view class="stat-item">
|
||||
<view class="stat-icon starred">⭐</view>
|
||||
<view class="stat-content">
|
||||
<text class="stat-number">{{ stats.starred_count }}</text>
|
||||
<text class="stat-label">收藏</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 紧急消息数 -->
|
||||
<view class="stat-item" :class="{ 'highlight': stats.urgent_count > 0 }">
|
||||
<view class="stat-icon urgent"></view>
|
||||
<view class="stat-content">
|
||||
<text class="stat-number">{{ stats.urgent_count }}</text>
|
||||
<text class="stat-label">紧急</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="stats-detail">
|
||||
<view class="detail-row">
|
||||
<text class="detail-label">今日新增</text>
|
||||
<text class="detail-value">{{ stats.today_count }}</text>
|
||||
</view>
|
||||
<view class="detail-row">
|
||||
<text class="detail-label">本周新增</text>
|
||||
<text class="detail-value">{{ stats.week_count }}</text>
|
||||
</view>
|
||||
<view class="detail-row">
|
||||
<text class="detail-label">本月新增</text>
|
||||
<text class="detail-value">{{ stats.month_count }}</text>
|
||||
</view>
|
||||
<view class="detail-row">
|
||||
<text class="detail-label">已归档</text>
|
||||
<text class="detail-value">{{ stats.archived_count }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<view class="refresh-btn" @click="handleRefresh">
|
||||
<text class="refresh-text">刷新统计</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import { formatTime } from '../../utils/msgUtils.uts'
|
||||
import type { MessageStats } from '../../utils/msgTypes.uts'
|
||||
|
||||
export default {
|
||||
name: 'MessageStats',
|
||||
props: {
|
||||
stats: {
|
||||
type: Object as PropType<MessageStats>,
|
||||
required: true
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['refresh'],
|
||||
methods: {
|
||||
getCurrentTime(): string {
|
||||
const now = new Date()
|
||||
return formatTime(now.toISOString())
|
||||
},
|
||||
|
||||
handleRefresh() {
|
||||
this.$emit('refresh')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-stats {
|
||||
background-color: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stats-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.stats-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.stats-time {
|
||||
font-size: 12px;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.stat-item.highlight {
|
||||
background-color: #fff3e0;
|
||||
border: 1px solid #ff9800;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.stat-icon.total {
|
||||
background-color: #e3f2fd;
|
||||
}
|
||||
|
||||
.stat-icon.unread {
|
||||
background-color: #ffebee;
|
||||
}
|
||||
|
||||
.stat-icon.starred {
|
||||
background-color: #fff8e1;
|
||||
}
|
||||
|
||||
.stat-icon.urgent {
|
||||
background-color: #fce4ec;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #333333;
|
||||
display: block;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #666666;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.stats-detail {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
background-color: #2196f3;
|
||||
color: #ffffff;
|
||||
padding: 10px 16px;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.refresh-btn:active {
|
||||
background-color: #1976d2;
|
||||
}
|
||||
|
||||
.refresh-text {
|
||||
font-size: 14px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* 响应式布局 */
|
||||
@media (max-width: 480px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
0
components/message/layouts/MessageLayout.vue
Normal file
0
components/message/layouts/MessageLayout.vue
Normal file
0
components/message/layouts/MessageSidebar.vue
Normal file
0
components/message/layouts/MessageSidebar.vue
Normal file
Reference in New Issue
Block a user