Files
abot/admin/dashboard/templates/message_list.html
2026-04-13 12:10:37 +08:00

580 lines
22 KiB
HTML

{% extends "base.html" %}
{% block title %}消息列表{% endblock %}
{% block content %}
<div class="page-shell message-page">
<div class="page-hero">
<div class="page-hero-copy">
<div class="page-eyebrow">Messages Center</div>
<h1>消息列表</h1>
<p>按群组、时间与内容快速回溯消息记录,把查看明细、媒体预览与分页统一到一个工作台里。</p>
</div>
</div>
<el-card class="filter-card workspace-card" shadow="hover">
<div slot="header" class="workspace-header">
<div>
<h3>筛选条件</h3>
<p>组合筛选群组、日期与关键词,快速定位你要看的消息。</p>
</div>
<div class="filter-summary">
当前共 {% raw %}{{ pagination.total }}{% endraw %} 条消息
</div>
</div>
<el-form :inline="true" size="small" class="filter-form">
<el-form-item label="群组">
<el-select v-model="filter.groupId" placeholder="选择群组" clearable>
<el-option
v-for="group in groups"
:key="group.id"
:label="group.name"
:value="group.id">
</el-option>
</el-select>
</el-form-item>
<el-form-item label="日期范围">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="yyyy-MM-dd"
:picker-options="pickerOptions">
</el-date-picker>
</el-form-item>
<el-form-item label="搜索内容">
<el-input v-model="filter.searchText" placeholder="搜索消息内容" clearable></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="searchMessages">搜索</el-button>
<el-button @click="resetFilter">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="message-card workspace-card" shadow="hover">
<div slot="header" class="workspace-header">
<div>
<h3>消息记录</h3>
<p>支持文本、图片、视频等多类型消息的统一查看。</p>
</div>
</div>
<el-table
:data="messages"
style="width: 100%"
size="small"
v-loading="loading">
<el-table-column prop="timestamp" label="时间" width="165"></el-table-column>
<el-table-column prop="group_name" label="群组" width="160"></el-table-column>
<el-table-column prop="sender_name" label="发送者" width="140"></el-table-column>
<el-table-column prop="content" label="内容" min-width="420">
<template slot-scope="scope">
<div class="message-preview">
<div v-if="scope.row.message_type == 1" class="message-text-preview">
{% raw %}{{ scope.row.content }}{% endraw %}
</div>
<div v-else-if="scope.row.message_type == 3" class="message-media-preview">
<div class="message-media-label">【图片消息】</div>
<img v-if="scope.row.image_path" :src="getImageUrl(scope.row.image_path)" class="message-thumb" @click="showImage(scope.row)">
<img v-else-if="scope.row.message_thumb" :src="scope.row.message_thumb" class="message-thumb" @click="showImage(scope.row)">
</div>
<div v-else-if="isEmojiMessage(scope.row)" class="message-media-preview">
<div class="message-media-label">【表情消息】</div>
<img
v-if="getEmojiPreviewUrl(scope.row)"
:src="getEmojiPreviewUrl(scope.row)"
class="message-thumb"
@click="showEmoji(scope.row)">
<div v-else class="message-text-preview is-muted">【表情消息】等待下载完成</div>
</div>
<div v-else-if="scope.row.message_type == 43" class="message-media-preview">
<div class="message-media-label">【视频消息】</div>
<img v-if="scope.row.message_thumb" :src="scope.row.message_thumb" class="message-thumb" @click="showVideo(scope.row)">
</div>
<div v-else class="message-text-preview is-muted">
{% raw %}{{ scope.row.content || `【消息类型: ${scope.row.message_type}】` }}{% endraw %}
<div v-if="scope.row.quoted_type === 'image' && hasQuotedPreview(scope.row.quoted_preview_image)" class="quoted-media-preview">
<div class="message-media-label">【引用图片】</div>
<img :src="getQuotedPreviewUrl(scope.row.quoted_preview_image)" class="message-thumb" @click="showQuotedImage(scope.row.quoted_preview_image)">
</div>
<div v-else-if="scope.row.quoted_type === 'image'" class="quoted-media-text">
【图片消息】
</div>
<div v-else-if="scope.row.quoted_type === 'video' && scope.row.quoted_preview_video_thumb" class="quoted-media-preview">
<div class="message-media-label">【引用视频】</div>
<img :src="scope.row.quoted_preview_video_thumb" class="message-thumb">
</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
<template slot-scope="scope">
<el-button type="text" size="small" @click="showMessageDetail(scope.row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-container">
<el-pagination
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:current-page="pagination.page"
:page-sizes="[10, 20, 50, 100]"
:page-size="pagination.pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="pagination.total">
</el-pagination>
</div>
</el-card>
<el-dialog title="消息详情" :visible.sync="detailDialogVisible" width="60%">
<div v-if="selectedMessage">
<el-descriptions :column="1" border>
<el-descriptions-item label="时间">{% raw %}{{ selectedMessage.timestamp }}{% endraw %}</el-descriptions-item>
<el-descriptions-item label="群组">{% raw %}{{ selectedMessage.group_name }}{% endraw %}</el-descriptions-item>
<el-descriptions-item label="发送者">{% raw %}{{ selectedMessage.sender_name }}{% endraw %}</el-descriptions-item>
<el-descriptions-item label="消息类型">{% raw %}{{ getMessageTypeName(selectedMessage.message_type) }}{% endraw %}</el-descriptions-item>
<el-descriptions-item label="内容">{% raw %}{{ selectedMessage.content }}{% endraw %}</el-descriptions-item>
<el-descriptions-item v-if="selectedMessage.message_type == 3 || selectedMessage.message_type == 43 || isEmojiMessage(selectedMessage)" label="媒体内容">
<img v-if="selectedMessage.message_type == 3 && selectedMessage.image_path" :src="getImageUrl(selectedMessage.image_path)" style="max-width: 100%; border-radius: 16px;">
<img v-else-if="isEmojiMessage(selectedMessage) && getEmojiPreviewUrl(selectedMessage)" :src="getEmojiPreviewUrl(selectedMessage)" style="max-width: 100%; border-radius: 16px;">
<img v-else-if="selectedMessage.message_type == 3 && selectedMessage.message_thumb" :src="selectedMessage.message_thumb" style="max-width: 100%; border-radius: 16px;">
<video v-if="selectedMessage.message_type == 43 && selectedMessage.attachment_url" :src="selectedMessage.attachment_url" controls style="max-width: 100%; border-radius: 16px;"></video>
</el-descriptions-item>
<el-descriptions-item label="原始XML" v-if="selectedMessage.message_xml">
<pre class="message-xml">{% raw %}{{ selectedMessage.message_xml }}{% endraw %}</pre>
</el-descriptions-item>
</el-descriptions>
</div>
</el-dialog>
<el-dialog :visible.sync="imageDialogVisible" append-to-body width="80%" class="image-dialog">
<img v-if="selectedMessage && selectedMessage.image_path" :src="getImageUrl(selectedMessage.image_path)" style="max-width: 100%; border-radius: 18px;">
<img v-else-if="selectedMessage && getEmojiPreviewUrl(selectedMessage)" :src="getEmojiPreviewUrl(selectedMessage)" style="max-width: 100%; border-radius: 18px;">
<img v-else-if="selectedMessage && selectedMessage.message_thumb" :src="selectedMessage.message_thumb" style="max-width: 100%; border-radius: 18px;">
</el-dialog>
</div>
{% endblock %}
{% block scripts %}
<script>
new Vue({
el: '#app',
mixins: [baseApp],
data() {
return {
loading: false,
groups: [],
messages: [],
filter: {
groupId: '',
startDate: '',
endDate: '',
searchText: ''
},
dateRange: [],
pagination: {
page: 1,
pageSize: 20,
total: 0,
totalPages: 0
},
pickerOptions: {
shortcuts: [{
text: '今天',
onClick(picker) {
const end = new Date();
const start = new Date();
picker.$emit('pick', [start, end]);
}
}, {
text: '最近一周',
onClick(picker) {
const end = new Date();
const start = new Date();
start.setTime(start.getTime() - 3600 * 1000 * 24 * 7);
picker.$emit('pick', [start, end]);
}
}, {
text: '最近一个月',
onClick(picker) {
const end = new Date();
const start = new Date();
start.setTime(start.getTime() - 3600 * 1000 * 24 * 30);
picker.$emit('pick', [start, end]);
}
}]
},
detailDialogVisible: false,
imageDialogVisible: false,
selectedMessage: null,
quotedPreviewUrl: ''
}
},
mounted() {
this.currentView = '7';
const today = new Date();
this.dateRange = [this.formatDate(today), this.formatDate(today)];
this.filter.startDate = this.formatDate(today);
this.filter.endDate = this.formatDate(today);
this.loadGroups();
this.loadMessages();
},
methods: {
formatDate(date) {
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
return `${year}-${month}-${day}`;
},
loadGroups() {
axios.get('/api/groups')
.then(response => {
if (response.data && response.data.groups) {
this.groups = response.data.groups.map(group => ({
id: group.group_id,
name: group.group_name || group.group_id
}));
}
})
.catch(error => {
console.error('加载群组失败:', error);
this.$message.error('加载群组失败');
});
},
loadMessages() {
this.loading = true;
const params = {
page: this.pagination.page,
page_size: this.pagination.pageSize
};
if (this.filter.groupId) {
params.group_id = this.filter.groupId;
}
if (this.filter.startDate) {
params.start_date = this.filter.startDate;
}
if (this.filter.endDate) {
params.end_date = this.filter.endDate;
}
if (this.filter.searchText) {
params.search_text = this.filter.searchText;
}
axios.get('/api/messages', { params })
.then(response => {
this.messages = response.data.messages || [];
this.pagination.total = response.data.total || 0;
this.pagination.totalPages = response.data.total_pages || 0;
})
.catch(error => {
console.error('加载消息失败:', error);
this.$message.error('加载消息失败');
})
.finally(() => {
this.loading = false;
});
},
searchMessages() {
if (this.dateRange && this.dateRange.length === 2) {
this.filter.startDate = this.dateRange[0];
this.filter.endDate = this.dateRange[1];
}
this.pagination.page = 1;
this.loadMessages();
},
resetFilter() {
this.filter.groupId = '';
this.filter.searchText = '';
const today = new Date();
this.dateRange = [this.formatDate(today), this.formatDate(today)];
this.filter.startDate = this.formatDate(today);
this.filter.endDate = this.formatDate(today);
this.pagination.page = 1;
this.loadMessages();
},
handleSizeChange(size) {
this.pagination.pageSize = size;
this.loadMessages();
},
handleCurrentChange(page) {
this.pagination.page = page;
this.loadMessages();
},
showMessageDetail(message) {
this.selectedMessage = message;
this.detailDialogVisible = true;
},
showImage(message) {
this.selectedMessage = message;
this.imageDialogVisible = true;
},
showEmoji(message) {
this.selectedMessage = message;
this.imageDialogVisible = true;
},
showQuotedImage(url) {
const resolvedUrl = this.getQuotedPreviewUrl(url);
if (!resolvedUrl) return;
this.selectedMessage = { image_path: '', message_thumb: resolvedUrl };
this.imageDialogVisible = true;
},
showVideo(message) {
this.selectedMessage = message;
this.detailDialogVisible = true;
},
getMessageTypeName(type) {
const typeMap = {
1: '文本消息',
3: '图片消息',
47: '表情消息',
43: '视频消息',
49: '链接消息'
};
return typeMap[type] || `未知类型(${type})`;
},
isEmojiMessage(message) {
if (!message) return false;
return ['47', '1048625', '1090519089'].includes(String(message.message_type));
},
getImageUrl(imagePath) {
if (!imagePath) return '';
if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
return imagePath;
}
const pathParts = imagePath.split(/[\/\\]/);
const fileName = pathParts[pathParts.length - 1];
if (pathParts.length >= 2) {
const groupId = pathParts[pathParts.length - 2];
if (groupId.includes('@chatroom')) {
return `/static/images/${groupId}/${fileName}`;
}
}
if (imagePath.includes('static/images') || imagePath.includes('static\\images')) {
const parts = imagePath.split(/static[\/\\]images[\/\\]/);
if (parts.length > 1) {
return `/static/images/${parts[1]}`;
}
}
return `/static/images/${fileName}`;
},
getEmojiPreviewUrl(message) {
if (!message) return '';
if (message.image_path) {
return this.getImageUrl(message.image_path);
}
const previewUrl = message.emoji_preview_url || '';
if (!previewUrl) {
return '';
}
if (previewUrl.startsWith('http://') || previewUrl.startsWith('https://')) {
return this.getMediaProxyUrl(previewUrl);
}
return this.getImageUrl(previewUrl);
},
getMediaProxyUrl(url) {
if (!url) return '';
if (url.startsWith('/api/messages/media_proxy')) {
return url;
}
return `/api/messages/media_proxy?url=${encodeURIComponent(url)}`;
},
isUsableQuotedPreview(url) {
if (!url) return false;
if (url.startsWith('http://') || url.startsWith('https://')) {
return true;
}
if (url.includes('static/images') || url.includes('static\\images')) {
return true;
}
if (url.includes('/') || url.includes('\\')) {
return true;
}
return false;
},
hasQuotedPreview(url) {
return !!this.getQuotedPreviewUrl(url);
},
getQuotedPreviewUrl(url) {
if (!url) return '';
if (!this.isUsableQuotedPreview(url)) {
return '';
}
if (url.startsWith('http://') || url.startsWith('https://')) {
return this.getMediaProxyUrl(url);
}
return this.getImageUrl(url);
}
},
watch: {
dateRange(val) {
if (val && val.length === 2) {
this.filter.startDate = val[0];
this.filter.endDate = val[1];
}
}
}
});
</script>
<style>
.page-shell {
display: flex;
flex-direction: column;
gap: 16px;
}
.page-hero {
padding: 24px 26px;
border-radius: 24px;
background: linear-gradient(135deg, rgba(79,70,229,0.10), rgba(59,130,246,0.08), rgba(255,255,255,0.9));
border: 1px solid rgba(148, 163, 184, 0.16);
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.06);
}
.page-eyebrow {
font-size: 12px;
text-transform: uppercase;
letter-spacing: .08em;
color: #6366f1;
font-weight: 700;
margin-bottom: 8px;
}
.page-hero-copy h1 {
font-size: 30px;
line-height: 1.1;
margin-bottom: 10px;
color: #0f172a;
}
.page-hero-copy p {
color: #64748b;
font-size: 14px;
}
.workspace-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.workspace-header h3 {
font-size: 18px;
margin-bottom: 4px;
}
.workspace-header p,
.filter-summary {
font-size: 13px;
color: #64748b;
}
.filter-form {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 10px;
}
.message-preview {
display: flex;
flex-direction: column;
gap: 8px;
}
.message-text-preview {
color: #0f172a;
line-height: 1.6;
word-break: break-word;
}
.message-text-preview.is-muted {
color: #64748b;
}
.message-media-preview {
display: flex;
flex-direction: column;
gap: 8px;
}
.quoted-media-preview {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.quoted-media-text {
margin-top: 8px;
color: #64748b;
font-size: 13px;
font-weight: 600;
}
.message-media-label {
font-size: 12px;
color: #64748b;
}
.message-thumb {
max-width: 120px;
max-height: 120px;
cursor: pointer;
border-radius: 14px;
border: 1px solid rgba(148,163,184,0.16);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
}
.pagination-container {
margin-top: 20px;
text-align: right;
}
.image-dialog .el-dialog__body {
text-align: center;
}
.message-xml {
white-space: pre-wrap;
word-break: break-all;
background: rgba(248,250,252,0.85);
border: 1px solid rgba(148,163,184,0.12);
border-radius: 14px;
padding: 14px;
color: #334155;
}
</style>
{% endblock %}