feat:聊天后台撤回消息

fix:聊天禁用bug
This commit is contained in:
dzc committed 2026-11-03 19:09:09 +08:00
1 parent 57a35f2f9d
commit a9bac9d3bf
12 files changed
+2709 -17

No files matched your search

+75 -4
View File
@@ -1,8 +1,80 @@
import request from '@/config/axios'
export type ChatUserId = number | string
export interface ConversationUser {
id: ChatUserId
nickname?: string
username?: string
avatar?: string
}
export interface PrivateMessage {
id: ChatUserId
sendId: ChatUserId
recvId: ChatUserId
sendUserName?: string
recvUserName?: string
content?: string | null
type: number
status: number
sendTime: string | number
}
export interface ChatConversation {
userA: ConversationUser
userB: ConversationUser
messageCount: number
lastMessage: PrivateMessage
}
export interface ConversationPageRequest {
pageNum: number
pageSize: number
userKeyword?: string
peerKeyword?: string
lastSendTimeList?: [number, number]
}
export interface PrivateMessagePageRequest {
pageNum: number
pageSize: number
sendId: ChatUserId
recvId: ChatUserId
content?: string
sendTimeList?: [number, number]
}
export interface ChatPageResult<T> {
list: T[]
total: number
}
export interface PrivateMessageModerateRequest {
messageId: ChatUserId
reason?: string
}
export interface PrivateMessageModerateResult {
messageId: ChatUserId
status: 2
recallMessageId: ChatUserId
alreadyHidden: boolean
notificationSent: boolean
}
export const moderateMessage = (data: PrivateMessageModerateRequest) =>
request.post<PrivateMessageModerateResult>({ url: '/chat/private-msg/moderate', data })
export const conversationPage = (data: ConversationPageRequest) =>
request.post<ChatPageResult<ChatConversation>>({
url: '/chat/private-msg/conversation/page',
data
})
// 查询私聊消息列表
export const page = async (data: any) => {
return await request.post({
export const page = async <T = any>(data: any) => {
return await request.post<T>({
url: '/chat/private-msg/page',
data: data
})
@@ -11,7 +83,7 @@ export const page = async (data: any) => {
// 获取私聊详细信息
export const getInfo = async (id: any) => {
return await request.get({
url: '/chat/private-msg/getById?id=' + id,
url: '/chat/private-msg/getById?id=' + id
})
}
@@ -22,4 +94,3 @@ export const dailyCount = async (params: any) => {
params: params
})
}
@@ -0,0 +1,307 @@
<template>
<span v-if="permitted" class="message-moderation-actions">
<el-tooltip
v-if="identity"
:content="disabledReason || actionDescription"
:disabled="!disabledReason"
placement="top"
>
<span>
<el-button
link
:type="isHidden ? 'primary' : 'danger'"
size="small"
:disabled="Boolean(disabledReason) || busy"
:loading="busy && !visible"
:aria-label="disabledReason || actionDescription"
@click.stop="open"
>
<Icon :icon="isHidden ? 'ep:refresh' : 'ep:circle-close'" aria-hidden="true" />
{{ isHidden ? '重新同步' : '屏蔽消息' }}
</el-button>
</span>
</el-tooltip>
<span v-else class="invalid-message-id" role="status">消息编号异常,请刷新后重试</span>
<el-dialog
v-model="visible"
:title="target?.hidden ? '重新同步撤回通知' : '屏蔽消息'"
class="chat-message-moderation-dialog"
width="480px"
append-to-body
destroy-on-close
:close-on-click-modal="false"
:close-on-press-escape="!submitting"
:show-close="!submitting"
:before-close="beforeClose"
@closed="handleClosed"
>
<template v-if="target">
<el-alert :closable="false" type="warning" show-icon>
<template #title>{{
target.hidden
? '将重新向双方发送这条消息的撤回通知。'
: 'App 中的原内容将不可见,并显示撤回提示;后台保留记录。'
}}</template>
</el-alert>
<dl class="moderation-context">
<div
><dt>消息编号</dt><dd>{{ target.messageId }}</dd></div
>
<div
><dt>聊天双方</dt><dd>{{ target.senderName }} → {{ target.receiverName }}</dd></div
>
<div
><dt>发送时间</dt><dd>{{ target.sendTime }}</dd></div
>
<div
><dt>消息摘要</dt><dd class="moderation-preview">{{ target.preview }}</dd></div
>
</dl>
<p class="moderation-scope">仅处理以上这一条消息。</p>
<el-form v-if="!target.hidden" label-position="top" @submit.prevent="submit">
<el-form-item label="屏蔽原因(选填)">
<el-input
v-model="reason"
type="textarea"
:rows="3"
:maxlength="200"
show-word-limit
placeholder="请输入原因,最多 200 字"
:disabled="submitting"
/>
</el-form-item>
</el-form>
<el-alert
v-if="errorMessage"
:title="errorMessage"
type="error"
:closable="false"
show-icon
role="alert"
/>
</template>
<template #footer>
<el-button :disabled="submitting" @click="visible = false">取消</el-button>
<el-button
:type="target?.hidden ? 'primary' : 'danger'"
:loading="submitting"
:disabled="!permitted"
@click="submit"
>
{{ target?.hidden ? '确认同步' : '确认屏蔽' }}
</el-button>
</template>
</el-dialog>
</span>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onDeactivated, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { checkPermi } from '@/utils/permission'
import { moderateMessage } from '@/api/chat/private-msg'
import type { ChatUserId, PrivateMessage } from '@/api/chat/private-msg'
import { formatChatTime, messagePreview } from '../list/chat-utils'
import {
MESSAGE_MODERATE_PERMISSION,
acquireMessageModeration,
canModerateMessageType,
isMessageModerationBusy,
messageIdentity,
notifyMessageModerated,
releaseMessageModeration
} from './message-moderation'
const props = defineProps<{ message: PrivateMessage; senderName?: string; receiverName?: string }>()
const permitted = computed(() => checkPermi([MESSAGE_MODERATE_PERMISSION]))
const identity = computed(() => messageIdentity(props.message.id))
const isHidden = computed(() => Number(props.message.status) === 2)
const busy = computed(() => isMessageModerationBusy(identity.value?.key))
const disabledReason = computed(() =>
!identity.value
? '消息编号异常,请刷新页面后重试'
: !canModerateMessageType(props.message.type)
? '系统控制记录不支持此操作'
: ''
)
const actionDescription = computed(
() => `${isHidden.value ? '重新同步撤回通知' : '屏蔽消息'},消息编号 ${props.message.id}`
)
const visible = ref(false)
const submitting = ref(false)
const reason = ref('')
const errorMessage = ref('')
const target = ref<{
messageId: ChatUserId
key: string
owner: symbol
hidden: boolean
senderName: string
receiverName: string
sendTime: string
preview: string
}>()
let mounted = true
let closedWhilePending = false
const open = () => {
if (!permitted.value || busy.value || target.value) return
if (disabledReason.value) {
ElMessage.warning(disabledReason.value)
return
}
const current = identity.value!
const owner = acquireMessageModeration(current.key)
if (!owner) return
target.value = {
...current,
owner,
hidden: isHidden.value,
senderName: props.senderName || props.message.sendUserName || `用户 ${props.message.sendId}`,
receiverName:
props.receiverName || props.message.recvUserName || `用户 ${props.message.recvId}`,
sendTime: formatChatTime(props.message.sendTime),
preview: messagePreview(props.message).slice(0, 200)
}
reason.value = ''
errorMessage.value = ''
closedWhilePending = false
visible.value = true
}
const release = () => releaseMessageModeration(target.value?.key, target.value?.owner)
const handleClosed = () => {
if (visible.value) return
if (submitting.value) {
closedWhilePending = true
return
}
release()
target.value = undefined
errorMessage.value = ''
}
const beforeClose = (done: () => void) => {
if (!submitting.value) done()
}
const submit = async () => {
if (!target.value || submitting.value) return
if (!permitted.value) {
errorMessage.value = '操作权限已变更,请刷新页面后重试。'
return
}
if (reason.value.trim().length > 200) {
errorMessage.value = '屏蔽原因不能超过 200 字。'
return
}
const current = target.value
submitting.value = true
errorMessage.value = ''
try {
const result = await moderateMessage({
messageId: current.messageId,
reason: !current.hidden && reason.value.trim() ? reason.value.trim() : undefined
})
if (result.notificationSent) {
ElMessage.success(result.alreadyHidden ? '已发送撤回同步通知' : '消息已屏蔽,已发送同步通知')
} else {
ElMessage.warning('消息已撤回/屏蔽,同步通知未发送,请点击“重新同步”重试。')
}
visible.value = false
// Notify all mounted views even if the source row disappeared during the request.
notifyMessageModerated(current.messageId)
} catch {
if (mounted) errorMessage.value = '操作失败,请稍后重试。当前消息和原因已保留。'
} finally {
submitting.value = false
if (!mounted || closedWhilePending || !permitted.value) {
releaseMessageModeration(current.key, current.owner)
if (target.value?.owner === current.owner) target.value = undefined
}
}
}
onBeforeUnmount(() => {
mounted = false
if (!submitting.value) release()
})
onDeactivated(() => {
visible.value = false
if (submitting.value) closedWhilePending = true
else {
release()
target.value = undefined
}
})
watch(permitted, (allowed) => {
if (!allowed) {
visible.value = false
if (submitting.value) closedWhilePending = true
else {
release()
target.value = undefined
}
}
})
</script>
<style scoped lang="scss">
.message-moderation-actions {
display: inline-flex;
align-items: center;
:deep(.el-button) {
gap: 4px;
}
.invalid-message-id {
color: var(--el-text-color-regular);
font-size: 12px;
overflow-wrap: anywhere;
}
}
</style>
<style lang="scss">
.chat-message-moderation-dialog {
max-width: calc(100vw - 32px);
border-radius: 10px;
.el-dialog__body {
max-height: 65vh;
max-height: 65dvh;
overflow-y: auto;
}
.el-alert__title {
line-height: 1.6;
color: var(--el-text-color-regular);
}
.moderation-context {
margin: 20px 0 12px;
color: var(--el-text-color-primary);
> div {
display: flex;
gap: 12px;
margin-bottom: 12px;
}
dt {
flex: 0 0 64px;
color: var(--el-text-color-regular);
}
dd {
margin: 0;
min-width: 0;
overflow-wrap: anywhere;
line-height: 1.6;
}
}
.moderation-preview {
white-space: pre-wrap;
}
.moderation-scope {
margin: 0 0 16px;
color: var(--el-text-color-regular);
font-size: 12px;
}
.el-dialog__footer {
border-top: 1px solid var(--el-border-color-lighter);
padding-top: 16px;
}
}
</style>
@@ -0,0 +1,41 @@
import { reactive, ref } from 'vue'
import type { ChatUserId } from '@/api/chat/private-msg'
export const MESSAGE_MODERATE_PERMISSION = 'chat:private-msg:moderate'
// The same message can appear in the conversation summary and the open drawer.
const locks = reactive(new Map<string, symbol>())
export const messageModerationRevision = ref(0)
export const lastModeratedMessageId = ref<ChatUserId>()
export const notifyMessageModerated = (messageId: ChatUserId) => {
lastModeratedMessageId.value = messageId
messageModerationRevision.value += 1
}
export const messageIdentity = (
id: unknown
): { messageId: ChatUserId; key: string } | undefined => {
if (typeof id === 'number') {
return Number.isSafeInteger(id) && id > 0 ? { messageId: id, key: String(id) } : undefined
}
if (typeof id !== 'string') return undefined
const text = id.trim()
if (!/^\d+$/.test(text) || /^0+$/.test(text)) return undefined
return { messageId: text, key: text.replace(/^0+(?=\d)/, '') }
}
export const canModerateMessageType = (type: number) =>
Number.isInteger(type) && ((type >= 0 && type <= 9) || type === 40 || type === 41)
export const isMessageModerationBusy = (key?: string) => Boolean(key && locks.has(key))
export const acquireMessageModeration = (key: string): symbol | undefined => {
if (locks.has(key)) return undefined
const owner = Symbol(key)
locks.set(key, owner)
return owner
}
export const releaseMessageModeration = (key?: string, owner?: symbol) => {
if (key && owner && locks.get(key) === owner) locks.delete(key)
}
+124
View File
@@ -0,0 +1,124 @@
import type { ConversationUser, PrivateMessage } from '@/api/chat/private-msg'
export type DateRange = [string, string] | undefined
/** Date picker values are calendar dates in China, regardless of the browser's timezone. */
export const toChinaDateRange = (range?: DateRange | null): [number, number] | undefined => {
if (!range || range.length !== 2) return undefined
if (!range.every((value) => /^\d{4}-\d{2}-\d{2}$/.test(value))) return undefined
const start = Date.parse(`${range[0]}T00:00:00.000+08:00`)
const end = Date.parse(`${range[1]}T23:59:59.999+08:00`)
return Number.isFinite(start) && Number.isFinite(end) && start <= end ? [start, end] : undefined
}
export const userLabel = (user?: ConversationUser) =>
user?.nickname?.trim() || user?.username?.trim() || (user ? `用户 ${user.id}` : '未知用户')
export const sameUser = (first: unknown, second: unknown) => String(first) === String(second)
const timeFormatter = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23'
})
export const formatChatTime = (value?: string | number, dateOnly = false) => {
if (value === undefined || value === null || value === '') return '时间未知'
// The IM service may return a local datetime without an offset.
const input =
typeof value === 'string' &&
/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}/.test(value) &&
!/(Z|[+-]\d{2}:?\d{2})$/i.test(value)
? `${value.replace(' ', 'T')}+08:00`
: value
const date = new Date(input)
if (!Number.isFinite(date.getTime())) return '时间未知'
const parts = Object.fromEntries(
timeFormatter.formatToParts(date).map(({ type, value }) => [type, value])
)
const day = `${parts.year}-${parts.month}-${parts.day}`
return dateOnly ? day : `${day} ${parts.hour}:${parts.minute}:${parts.second}`
}
export const parseMedia = (content: unknown): Record<string, unknown> | null => {
if (typeof content !== 'string') return null
try {
const parsed = JSON.parse(content)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null
} catch {
return null
}
}
/** Only ordinary HTTP(S) resources may be opened by the read-only viewer. */
export const safeResourceUrl = (value: unknown, baseUrl?: string): string => {
if (typeof value !== 'string' || !value.trim() || /[\u0000-\u001f\u007f]/.test(value)) return ''
const raw = value.trim()
if (raw.startsWith('//') || raw.includes('\\')) return ''
try {
const url = baseUrl
? new URL(raw, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`)
: new URL(raw)
return ['https:', 'http:'].includes(url.protocol) && !url.username && !url.password
? url.href
: ''
} catch {
return ''
}
}
export interface MessageTextPart {
text: string
href?: string
}
export const messageTextParts = (content: unknown): MessageTextPart[] => {
const text = String(content ?? '')
const matcher = /https?:\/\/[^\s<>"']+|www\.[^\s<>"']+/gi
const parts: MessageTextPart[] = []
let start = 0
for (const match of text.matchAll(matcher)) {
const index = match.index ?? 0
if (index > start) parts.push({ text: text.slice(start, index) })
const urlText = match[0].replace(/[.,;!?,。;!?))]+$/, '')
const href = safeResourceUrl(/^www\./i.test(urlText) ? `https://${urlText}` : urlText)
parts.push({ text: urlText, ...(href ? { href } : {}) })
start = index + urlText.length
}
if (start < text.length) parts.push({ text: text.slice(start) })
return parts
}
export const escapeMessageText = (content: unknown) =>
String(content ?? '').replace(
/[&<>"']/g,
(char) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char] || char
)
export const messagePreview = (message?: PrivateMessage): string => {
if (!message) return '暂无记录'
const content = String(message.content ?? '')
const labels: Record<number, string> = {
1: '[图片]',
3: '[语音]',
4: '[视频]',
10: '撤回了一条消息',
40: '[语音通话]',
41: '[视频通话]'
}
let text = content
if (message.type === 2) text = `[文件] ${String(parseMedia(content)?.name || '文件')}`
else if (message.type === 40 || message.type === 41) text = `${labels[message.type]} ${content}`
else if (labels[message.type]) text = labels[message.type]
else if (message.type !== 0 && message.type !== 21) text = `[类型 ${message.type}] ${content}`
return `${message.status === 2 ? '[已撤回/屏蔽] ' : ''}${text.replace(/\s+/g, ' ').trim() || '[空消息]'}`
}
export const statusLabel = (status: number) =>
({ 0: '未送达', 1: '已送达', 2: '已撤回/屏蔽', 3: '已读' })[status] || '未知状态'
@@ -0,0 +1,581 @@
<template>
<el-drawer
v-model="visible"
class="chat-conversation-drawer"
direction="rtl"
size="min(960px, 92vw)"
append-to-body
destroy-on-close
:close-on-click-modal="false"
@close="handleClose"
@closed="restoreFocus"
@open-auto-focus="focusSearch"
>
<template #header="{ titleId }">
<div class="drawer-heading">
<h2 :id="titleId">聊天记录</h2>
<span>历史消息</span>
</div>
</template>
<div v-if="conversation" class="drawer-layout">
<div class="drawer-participants">
<ConversationUser :user="conversation.userA" :size="40" />
<Icon icon="ep:sort" class="pair-divider" aria-hidden="true" />
<ConversationUser :user="conversation.userB" :size="40" />
</div>
<el-form class="drawer-filters" label-position="top" @submit.prevent="handleQuery">
<el-form-item label="消息内容">
<el-input
ref="searchRef"
v-model="filters.content"
placeholder="搜索这两人的聊天内容"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="发送日期">
<el-date-picker
v-model="filters.dates"
type="daterange"
popper-class="chat-conversation-date-picker"
value-format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
unlink-panels
/>
</el-form-item>
<div class="drawer-filter-actions">
<el-button type="primary" @click="handleQuery"
><Icon icon="ep:search" aria-hidden="true" />搜索</el-button
>
<el-button @click="resetQuery">重置</el-button>
</div>
</el-form>
<div class="drawer-record-summary">
<span aria-live="polite">{{
loading ? '正在加载记录…' : `共 ${total.toLocaleString()} 条记录`
}}</span>
<span>第 1 页为最新记录 · 北京时间</span>
</div>
<div
ref="scrollRef"
class="message-scroll"
:aria-busy="loading"
tabindex="0"
aria-label="两人的聊天记录"
>
<div v-if="loading" class="message-loading"><el-skeleton :rows="7" animated /></div>
<div v-else-if="error" class="drawer-state" role="alert">
<Icon icon="ep:warning" aria-hidden="true" />
<strong>聊天记录加载失败</strong>
<span>请稍后重试,当前筛选条件已保留。</span>
<el-button type="primary" plain @click="getList">重新加载</el-button>
</div>
<el-empty v-else-if="!messages.length" description="没有符合条件的聊天记录">
<el-button v-if="hasFilter" @click="resetQuery">查看全部记录</el-button>
</el-empty>
<div v-else-if="visible" class="message-list">
<template v-for="(message, index) in messages" :key="String(message.id)">
<div v-if="isNewDay(index)" class="message-day">{{
formatChatTime(message.sendTime, true)
}}</div>
<div v-if="message.type === 10" class="recall-event">
{{ userLabel(sender(message)) }}撤回了一条消息
<span>{{ formatChatTime(message.sendTime).slice(-8) }}</span>
<MessageModerationActions :message="message" />
</div>
<article
v-else
class="message-row"
:class="{ 'message-row--right': sameUser(message.sendId, conversation.userB.id) }"
>
<ConversationUser :user="sender(message)" :size="32" avatar-only />
<div class="message-main">
<div class="message-meta"
><span>{{ userLabel(sender(message)) }}</span
><time>{{ formatChatTime(message.sendTime).slice(-8) }}</time></div
>
<div class="message-bubble"><ConversationMessage :message="message" /></div>
<div class="message-footer">
<span
class="message-status"
:class="{ 'message-status--recalled': message.status === 2 }"
>{{ statusLabel(message.status) }}</span
>
<MessageModerationActions
:message="message"
:sender-name="userLabel(sender(message))"
:receiver-name="userLabel(receiver(message))"
/>
</div>
</div>
</article>
</template>
</div>
</div>
<div class="drawer-footer">
<el-pagination
v-model:current-page="pagination.pageNum"
v-model:page-size="pagination.pageSize"
:total="total"
:page-sizes="[20, 50, 100]"
:pager-count="5"
layout="sizes, prev, pager, next"
background
:disabled="loading"
@size-change="handleSizeChange"
@current-change="getList"
/>
<el-button :disabled="loading" @click="getList"
><Icon icon="ep:refresh" aria-hidden="true" />刷新</el-button
>
</div>
</div>
</el-drawer>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onDeactivated, reactive, ref, watch } from 'vue'
import type { InputInstance } from 'element-plus'
import * as privateMsgApi from '@/api/chat/private-msg'
import type {
ChatConversation,
ChatPageResult,
PrivateMessage,
PrivateMessagePageRequest
} from '@/api/chat/private-msg'
import {
type DateRange,
formatChatTime,
sameUser,
statusLabel,
toChinaDateRange,
userLabel
} from '../chat-utils'
import ConversationUser from './ConversationUser.vue'
import ConversationMessage from './ConversationMessage.vue'
import MessageModerationActions from '../../components/MessageModerationActions.vue'
import {
lastModeratedMessageId,
messageModerationRevision
} from '../../components/message-moderation'
defineOptions({ name: 'ChatConversationDrawer' })
const visible = ref(false)
const conversation = ref<ChatConversation>()
const filters = reactive({ content: '', dates: undefined as DateRange })
const applied = reactive({ content: '', dates: undefined as DateRange })
const pagination = reactive({ pageNum: 1, pageSize: 20 })
const messages = ref<PrivateMessage[]>([])
const total = ref(0)
const loading = ref(false)
const error = ref(false)
const scrollRef = ref<HTMLDivElement>()
const searchRef = ref<InputInstance>()
const hasFilter = computed(() => Boolean(applied.content || applied.dates))
let requestVersion = 0
let returnFocusTo: HTMLElement | null = null
const loadMessages = async (preserveScroll = false): Promise<void> => {
if (!visible.value || !conversation.value) return
const previousScrollTop = scrollRef.value?.scrollTop || 0
const version = ++requestVersion
const { userA, userB } = conversation.value
loading.value = true
error.value = false
messages.value = []
try {
const params: PrivateMessagePageRequest = {
...pagination,
sendId: userA.id,
recvId: userB.id,
content: applied.content.trim() || undefined,
sendTimeList: toChinaDateRange(applied.dates)
}
const result = await privateMsgApi.page<ChatPageResult<PrivateMessage>>(params)
if (version !== requestVersion || !visible.value) return
total.value = Number(result.total) || 0
messages.value = [...(result.list || [])].reverse()
const lastPage = Math.max(1, Math.ceil(total.value / pagination.pageSize))
if (pagination.pageNum > lastPage) {
pagination.pageNum = lastPage
await loadMessages(preserveScroll)
}
} catch {
if (version === requestVersion && visible.value) {
error.value = true
total.value = 0
}
} finally {
if (version === requestVersion && visible.value) {
loading.value = false
await nextTick()
if (version === requestVersion && scrollRef.value)
scrollRef.value.scrollTop = preserveScroll
? previousScrollTop
: scrollRef.value.scrollHeight
}
}
}
const getList = () => loadMessages()
const handleQuery = () => {
applied.content = filters.content.trim()
applied.dates = filters.dates ? [...filters.dates] : undefined
pagination.pageNum = 1
return getList()
}
const resetQuery = () => {
filters.content = ''
filters.dates = undefined
return handleQuery()
}
const handleSizeChange = () => {
pagination.pageNum = 1
return getList()
}
const sender = (message: PrivateMessage) =>
sameUser(message.sendId, conversation.value?.userA.id)
? conversation.value!.userA
: conversation.value!.userB
const receiver = (message: PrivateMessage) =>
sameUser(message.recvId, conversation.value?.userA.id)
? conversation.value!.userA
: conversation.value!.userB
const isNewDay = (index: number) =>
index === 0 ||
formatChatTime(messages.value[index - 1].sendTime, true) !==
formatChatTime(messages.value[index].sendTime, true)
const open = (row: ChatConversation, trigger?: HTMLElement) => {
returnFocusTo =
trigger || (document.activeElement instanceof HTMLElement ? document.activeElement : null)
conversation.value = row
visible.value = true
filters.content = ''
filters.dates = undefined
applied.content = ''
applied.dates = undefined
pagination.pageNum = 1
pagination.pageSize = 20
total.value = 0
return getList()
}
const focusSearch = async (event?: Event) => {
event?.preventDefault()
await nextTick()
searchRef.value?.focus()
}
const handleClose = () => {
++requestVersion
loading.value = false
messages.value = []
}
const restoreFocus = () => {
if (!visible.value && returnFocusTo?.isConnected) returnFocusTo.focus()
}
onBeforeUnmount(() => {
++requestVersion
})
onDeactivated(() => {
visible.value = false
handleClose()
})
watch(messageModerationRevision, () => {
if (!visible.value) return
messages.value.forEach((message) => {
if (sameUser(message.id, lastModeratedMessageId.value)) message.status = 2
})
loadMessages(true)
})
defineExpose({ open })
</script>
<style lang="scss">
@media (max-width: 767px) {
.chat-conversation-date-picker.el-popper {
position: fixed !important;
top: 50% !important;
left: 50% !important;
transform: translate(-50%, -50%) !important;
width: calc(100vw - 24px);
max-width: calc(100vw - 24px);
box-sizing: border-box;
max-height: 70vh;
max-height: 70dvh;
overflow-y: auto;
overflow-x: hidden;
.el-popper__arrow {
display: none;
}
.el-date-range-picker {
width: 100%;
}
.el-picker-panel__body-wrapper {
max-height: none;
overflow: visible;
}
.el-picker-panel__body {
min-width: 0;
}
.el-date-range-picker__content {
float: none;
width: 100%;
padding: 12px;
box-sizing: border-box;
&.is-left {
border-right: 0;
border-bottom: 1px solid var(--el-border-color-lighter);
}
}
}
}
.chat-conversation-drawer {
--chat-panel-padding: 24px;
.el-drawer__header {
margin: 0;
padding: 20px var(--chat-panel-padding);
border-bottom: 1px solid var(--el-border-color-lighter);
}
.el-drawer__body {
padding: 0;
overflow: hidden;
min-height: 0;
}
.el-drawer__close-btn {
color: var(--el-text-color-regular);
}
.drawer-heading {
display: flex;
align-items: center;
gap: 12px;
h2 {
margin: 0;
font-size: 18px;
color: var(--el-text-color-primary);
}
span {
color: var(--el-text-color-regular);
font-size: 12px;
}
}
.drawer-layout {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
}
.drawer-participants {
display: flex;
align-items: center;
gap: 24px;
padding: 20px var(--chat-panel-padding) 16px;
> .conversation-user {
flex: 1;
min-width: 0;
}
}
.pair-divider {
flex-shrink: 0;
color: var(--el-text-color-regular);
transform: rotate(90deg);
}
.drawer-filters {
display: grid;
grid-template-columns: minmax(140px, 1fr) minmax(240px, 1.2fr) auto;
gap: 12px;
padding: 0 var(--chat-panel-padding) 16px;
.el-form-item {
margin-bottom: 0;
min-width: 0;
}
.el-form-item__label {
color: var(--el-text-color-regular);
font-size: 12px;
}
.el-date-editor {
width: 100%;
min-width: 0;
}
}
.drawer-filter-actions {
display: flex;
align-items: flex-end;
padding-bottom: 0;
.el-button {
gap: 4px;
}
}
.drawer-record-summary {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 6px;
padding: 12px var(--chat-panel-padding);
font-size: 12px;
color: var(--el-text-color-regular);
border-top: 1px solid var(--el-border-color-lighter);
border-bottom: 1px solid var(--el-border-color-lighter);
}
.message-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 0 var(--chat-panel-padding);
background: var(--el-fill-color-extra-light);
scroll-padding: 16px;
&:focus-visible {
outline: 2px solid var(--el-color-primary);
outline-offset: -2px;
}
}
.message-loading {
padding: 32px 0;
}
.message-list {
padding: 20px 0;
}
.message-day {
margin: 4px auto 24px;
text-align: center;
color: var(--el-text-color-regular);
font-size: 12px;
}
.message-row {
display: flex;
gap: 10px;
margin-bottom: 24px;
align-items: flex-start;
> .conversation-user {
flex-shrink: 0;
}
}
.message-main {
max-width: min(76%, 560px);
min-width: 0;
}
.message-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 7px;
color: var(--el-text-color-regular);
font-size: 12px;
overflow-wrap: anywhere;
}
.message-bubble {
width: fit-content;
max-width: 100%;
box-sizing: border-box;
padding: 10px 14px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 2px 10px 10px;
background: var(--el-bg-color);
color: var(--el-text-color-primary);
}
.message-status {
color: var(--el-text-color-regular);
font-size: 12px;
}
.message-footer {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-top: 5px;
}
.message-status--recalled {
font-weight: 600;
}
.message-row--right {
flex-direction: row-reverse;
.message-main {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.message-meta {
justify-content: flex-end;
}
.message-bubble {
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary-light-7);
border-radius: 10px 2px 10px 10px;
}
}
.recall-event {
margin: 0 auto 20px;
color: var(--el-text-color-regular);
font-size: 12px;
text-align: center;
overflow-wrap: anywhere;
span {
margin-left: 8px;
}
}
.drawer-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px var(--chat-panel-padding);
border-top: 1px solid var(--el-border-color-lighter);
background: var(--el-bg-color);
.el-button {
gap: 4px;
}
}
.drawer-state {
display: flex;
min-height: 250px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--el-text-color-regular);
strong {
color: var(--el-text-color-primary);
}
}
@media (max-width: 767px) {
width: 100% !important;
--chat-panel-padding: 16px;
.drawer-participants {
gap: 12px;
}
.drawer-filters {
grid-template-columns: 1fr;
gap: 10px;
}
.drawer-filter-actions {
justify-content: flex-end;
}
.drawer-footer {
flex-wrap: wrap;
justify-content: center;
.el-pagination__sizes {
display: none;
}
> .el-button {
display: none;
}
}
.message-main {
max-width: calc(100% - 48px);
}
}
@media (max-height: 650px) and (min-width: 768px) {
.drawer-participants {
padding-top: 12px;
padding-bottom: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.el-skeleton__item {
animation: none;
}
}
}
</style>
@@ -0,0 +1,209 @@
<template>
<div class="conversation-message">
<div v-if="failed" class="message-fallback" role="status">
<Icon icon="ep:warning" aria-hidden="true" /> {{ failureText }}
</div>
<el-image
v-else-if="message.type === 1 && imageUrl"
:src="imageUrl"
:preview-src-list="[originalUrl || imageUrl]"
preview-teleported
hide-on-click-modal
fit="contain"
class="message-image"
alt="聊天图片,点击预览原图"
loading="lazy"
@error="failed = true"
/>
<a
v-else-if="message.type === 2 && resourceUrl"
class="message-file"
:href="resourceUrl"
:download="fileName"
target="_blank"
rel="noopener noreferrer"
>
<Icon icon="ep:document" aria-hidden="true" />
<span
>{{ fileName }}<small v-if="fileSize">{{ fileSize }}</small></span
>
<Icon icon="ep:download" aria-hidden="true" />
</a>
<audio
v-else-if="message.type === 3 && resourceUrl"
ref="mediaRef"
:src="resourceUrl"
controls
preload="none"
aria-label="聊天语音"
@error="failed = true"
></audio>
<video
v-else-if="message.type === 4 && videoUrl"
ref="mediaRef"
:src="videoUrl"
:poster="posterUrl"
controls
playsinline
preload="metadata"
aria-label="聊天视频"
@error="failed = true"
></video>
<div v-else-if="[1, 2, 3, 4].includes(message.type)" class="message-fallback">
<Icon icon="ep:warning" aria-hidden="true" /> {{ failureText }}
</div>
<div v-else-if="message.type === 40 || message.type === 41" class="message-call">
<Icon :icon="message.type === 40 ? 'ep:phone' : 'ep:video-camera'" aria-hidden="true" />
<div
><strong>{{ message.type === 40 ? '语音通话' : '视频通话' }}</strong
><span>{{ message.content || '通话记录' }}</span></div
>
</div>
<div v-else-if="message.type === 0 || message.type === 21" class="message-text">
<template v-if="textParts.length">
<template v-for="part in textParts" :key="part.key">
<a v-if="part.href" :href="part.href" target="_blank" rel="noopener noreferrer">{{
part.text
}}</a>
<span v-else v-dompurify-html="transform(escapeMessageText(part.text))"></span>
</template>
</template>
<span v-else class="message-fallback">空消息</span>
</div>
<div v-else class="message-text"
><span class="message-unknown">类型 {{ message.type }}</span
>{{ message.content || '暂无内容' }}</div
>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import type { PrivateMessage } from '@/api/chat/private-msg'
import { transform } from '../../private-msg/tool/emotion'
import { escapeMessageText, messageTextParts, parseMedia, safeResourceUrl } from '../chat-utils'
const props = defineProps<{ message: PrivateMessage }>()
const failed = ref(false)
const mediaRef = ref<HTMLMediaElement>()
const payload = computed(() => parseMedia(props.message.content))
const url = (value: unknown) =>
safeResourceUrl(value, import.meta.env.VITE_IMAGE_BASE_PATH || window.location.origin)
const imageUrl = computed(() => url(payload.value?.thumbUrl) || url(payload.value?.originUrl))
const originalUrl = computed(() => url(payload.value?.originUrl))
const resourceUrl = computed(() => url(payload.value?.url))
const videoUrl = computed(() => url(payload.value?.videoUrl))
const posterUrl = computed(() => url(payload.value?.coverUrl))
const fileName = computed(() =>
typeof payload.value?.name === 'string' && payload.value.name ? payload.value.name : '下载文件'
)
const fileSize = computed(() => {
const size = Number(payload.value?.size)
if (!Number.isFinite(size) || size <= 0) return ''
return size >= 1048576
? `${(size / 1048576).toFixed(1)} MB`
: size >= 1024
? `${Math.ceil(size / 1024)} KB`
: `${size} B`
})
const failureText = computed(
() => `${{ 1: '图片', 2: '文件', 3: '语音', 4: '视频' }[props.message.type] || '消息'}内容不可用`
)
const textParts = computed(() =>
messageTextParts(props.message.content).map((part, key) => ({ ...part, key }))
)
watch(
() => [props.message.id, props.message.content],
() => {
failed.value = false
}
)
onBeforeUnmount(() => mediaRef.value?.pause())
</script>
<style scoped lang="scss">
.conversation-message {
min-width: 0;
font-size: 14px;
line-height: 1.65;
}
.message-text {
white-space: pre-wrap;
overflow-wrap: anywhere;
:deep(img) {
vertical-align: middle;
}
}
a {
color: var(--el-color-primary);
text-decoration: underline;
text-underline-offset: 3px;
&:focus-visible {
outline: 2px solid var(--el-color-primary);
outline-offset: 3px;
}
}
.message-image {
display: block;
width: 240px;
max-width: 100%;
max-height: 280px;
border-radius: 6px;
:deep(img) {
max-height: 280px;
}
}
audio {
display: block;
width: 280px;
max-width: 100%;
}
video {
display: block;
width: 360px;
max-width: 100%;
max-height: 280px;
border-radius: 6px;
background: var(--el-fill-color-darker);
}
.message-file {
display: flex;
align-items: center;
min-width: 0;
gap: 12px;
span {
overflow-wrap: anywhere;
}
small {
display: block;
color: var(--el-text-color-regular);
}
}
.message-call {
display: flex;
gap: 12px;
align-items: center;
strong,
span {
display: block;
}
strong {
font-weight: 500;
}
span {
color: var(--el-text-color-regular);
}
}
.message-fallback {
display: flex;
align-items: center;
gap: 6px;
color: var(--el-text-color-regular);
}
.message-unknown {
display: block;
margin-bottom: 4px;
color: var(--el-text-color-regular);
font-size: 12px;
}
</style>
@@ -0,0 +1,67 @@
<template>
<div class="conversation-user">
<el-avatar :size="size" :src="avatar" aria-hidden="true">{{
userLabel(user).slice(0, 1)
}}</el-avatar>
<div v-if="!avatarOnly" class="conversation-user__info">
<span class="conversation-user__name" :title="userLabel(user)">{{ userLabel(user) }}</span>
<span v-if="user.username" class="conversation-user__account" :title="`账号 ${user.username}`"
>账号 {{ user.username }}</span
>
<span class="conversation-user__id">编号 {{ user.id }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ConversationUser as User } from '@/api/chat/private-msg'
import { safeResourceUrl, userLabel } from '../chat-utils'
const props = withDefaults(defineProps<{ user: User; size?: number; avatarOnly?: boolean }>(), {
size: 36,
avatarOnly: false
})
const avatar = computed(() =>
safeResourceUrl(props.user.avatar, import.meta.env.VITE_IMAGE_BASE_PATH || window.location.origin)
)
</script>
<style scoped lang="scss">
.conversation-user {
display: flex;
align-items: center;
min-width: 0;
gap: 10px;
:deep(.el-avatar) {
flex-shrink: 0;
color: var(--el-text-color-regular);
background: var(--el-fill-color-dark);
font-weight: 600;
}
}
.conversation-user__info {
display: flex;
flex-direction: column;
min-width: 0;
gap: 4px;
}
.conversation-user__name {
overflow: hidden;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-user__id {
color: var(--el-text-color-regular);
font-size: 12px;
overflow-wrap: anywhere;
}
.conversation-user__account {
overflow: hidden;
color: var(--el-text-color-regular);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+513
View File
@@ -0,0 +1,513 @@
<template>
<div class="chat-conversation-page">
<header class="page-heading">
<div><h1>聊天列表</h1><p>按聊天双方查看会话,快速找到完整记录</p></div>
<el-button :loading="loading" @click="getList"
><Icon icon="ep:refresh" aria-hidden="true" />刷新</el-button
>
</header>
<section class="conversation-panel filter-panel" aria-label="筛选聊天会话">
<el-form label-position="top" class="conversation-filters" @submit.prevent="handleQuery">
<el-form-item label="参与用户">
<el-input
v-model="filters.userKeyword"
placeholder="昵称、用户名或用户编号"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="另一位用户">
<el-input
v-model="filters.peerKeyword"
placeholder="选填,查询指定两人的会话"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="最近聊天日期">
<el-date-picker
v-model="filters.dates"
type="daterange"
popper-class="chat-conversation-date-picker"
value-format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
unlink-panels
/>
</el-form-item>
<div class="filter-actions">
<el-button type="primary" @click="handleQuery"
><Icon icon="ep:search" aria-hidden="true" />搜索</el-button
>
<el-button @click="resetQuery">重置</el-button>
</div>
</el-form>
<p class="filter-hint">不区分发送方与接收方;日期按会话的最后一条记录筛选。</p>
</section>
<section class="conversation-panel results-panel" aria-label="会话列表" :aria-busy="loading">
<div class="results-heading">
<h2
>{{ hasFilter ? '筛选结果' : '全部会话' }}
<span v-if="!loading && !error">{{ total.toLocaleString() }}</span></h2
>
<span>最近聊天优先 · 北京时间</span>
</div>
<div v-if="loading" class="list-loading"><el-skeleton :rows="8" animated /></div>
<div v-else-if="error" class="list-state" role="alert">
<Icon icon="ep:warning" aria-hidden="true" />
<strong>会话列表加载失败</strong>
<span>请稍后重试,当前筛选条件已保留。</span>
<el-button type="primary" plain @click="getList">重新加载</el-button>
</div>
<el-empty
v-else-if="!list.length"
:description="hasFilter ? '没有符合条件的会话' : '暂无聊天会话'"
>
<el-button v-if="hasFilter" @click="resetQuery">清空筛选</el-button>
</el-empty>
<template v-else>
<div class="conversation-columns" aria-hidden="true"
><span>聊天双方</span><span>最后一条记录</span><span>记录数</span><span>最近聊天时间</span
><span></span
></div>
<ul class="conversation-list">
<li v-for="row in list" :key="`${row.userA.id}:${row.userB.id}`" class="conversation-row">
<div class="row-participants">
<ConversationUser :user="row.userA" />
<Icon icon="ep:sort" class="pair-divider" aria-hidden="true" />
<ConversationUser :user="row.userB" />
</div>
<div class="row-preview">
<span class="preview-sender">{{ lastSender(row) }}</span>
<span class="preview-content" :title="messagePreview(row.lastMessage)">{{
messagePreview(row.lastMessage)
}}</span>
<MessageModerationActions
:message="row.lastMessage"
:sender-name="lastSender(row)"
:receiver-name="lastReceiver(row)"
/>
</div>
<span class="row-count"
><span class="mobile-label">记录数 </span
>{{ Number(row.messageCount || 0).toLocaleString() }}</span
>
<time class="row-time">{{ formatChatTime(row.lastMessage?.sendTime) }}</time>
<el-button
class="view-records"
type="primary"
plain
:aria-label="`查看${userLabel(row.userA)}与${userLabel(row.userB)}的聊天记录`"
@click="openConversation(row, $event)"
>查看记录<Icon icon="ep:arrow-right" aria-hidden="true"
/></el-button>
</li>
</ul>
</template>
<div v-if="total > 0 && !error" class="list-pagination">
<span aria-live="polite">共 {{ total.toLocaleString() }} 段会话</span>
<el-pagination
v-model:current-page="pagination.pageNum"
v-model:page-size="pagination.pageSize"
:page-sizes="[20, 50, 100]"
:total="total"
:pager-count="5"
layout="sizes, prev, pager, next, jumper"
background
:disabled="loading"
@size-change="handleSizeChange"
@current-change="getList"
/>
</div>
</section>
<ConversationDrawer ref="drawerRef" />
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import * as privateMsgApi from '@/api/chat/private-msg'
import type { ChatConversation } from '@/api/chat/private-msg'
import {
type DateRange,
formatChatTime,
messagePreview,
sameUser,
toChinaDateRange,
userLabel
} from './chat-utils'
import ConversationDrawer from './components/ConversationDrawer.vue'
import ConversationUser from './components/ConversationUser.vue'
import MessageModerationActions from '../components/MessageModerationActions.vue'
import { lastModeratedMessageId, messageModerationRevision } from '../components/message-moderation'
defineOptions({ name: 'ChatList' })
const filters = reactive({ userKeyword: '', peerKeyword: '', dates: undefined as DateRange })
const applied = reactive({ userKeyword: '', peerKeyword: '', dates: undefined as DateRange })
const pagination = reactive({ pageNum: 1, pageSize: 20 })
const list = ref<ChatConversation[]>([])
const total = ref(0)
const loading = ref(false)
const error = ref(false)
const drawerRef = ref<InstanceType<typeof ConversationDrawer>>()
const hasFilter = computed(() =>
Boolean(applied.userKeyword || applied.peerKeyword || applied.dates)
)
let requestVersion = 0
const getList = async (): Promise<void> => {
const version = ++requestVersion
loading.value = true
error.value = false
try {
const result = await privateMsgApi.conversationPage({
...pagination,
userKeyword: applied.userKeyword.trim() || undefined,
peerKeyword: applied.peerKeyword.trim() || undefined,
lastSendTimeList: toChinaDateRange(applied.dates)
})
if (version !== requestVersion) return
list.value = result.list || []
total.value = Number(result.total) || 0
const lastPage = Math.max(1, Math.ceil(total.value / pagination.pageSize))
if (pagination.pageNum > lastPage) {
pagination.pageNum = lastPage
await getList()
}
} catch {
if (version === requestVersion) {
error.value = true
list.value = []
total.value = 0
}
} finally {
if (version === requestVersion) loading.value = false
}
}
const handleQuery = () => {
applied.userKeyword = filters.userKeyword.trim()
applied.peerKeyword = filters.peerKeyword.trim()
applied.dates = filters.dates ? [...filters.dates] : undefined
pagination.pageNum = 1
return getList()
}
const resetQuery = () => {
filters.userKeyword = ''
filters.peerKeyword = ''
filters.dates = undefined
return handleQuery()
}
const handleSizeChange = () => {
pagination.pageNum = 1
return getList()
}
const lastSender = (row: ChatConversation) =>
userLabel(sameUser(row.lastMessage?.sendId, row.userA.id) ? row.userA : row.userB)
const lastReceiver = (row: ChatConversation) =>
userLabel(sameUser(row.lastMessage?.recvId, row.userA.id) ? row.userA : row.userB)
const openConversation = (row: ChatConversation, event: MouseEvent) =>
drawerRef.value?.open(row, event.currentTarget as HTMLElement)
onMounted(getList)
watch(messageModerationRevision, () => {
list.value.forEach((row) => {
if (sameUser(row.lastMessage?.id, lastModeratedMessageId.value)) row.lastMessage.status = 2
})
getList()
})
onBeforeUnmount(() => {
++requestVersion
})
</script>
<style scoped lang="scss">
.chat-conversation-page {
color: var(--el-text-color-primary);
}
.page-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
h1 {
margin: 0 0 6px;
font-size: 22px;
font-weight: 600;
line-height: 1.4;
}
p {
margin: 0;
font-size: 13px;
color: var(--el-text-color-regular);
}
}
.conversation-panel {
border: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
background: var(--el-bg-color);
}
.filter-panel {
padding: 20px 24px 16px;
margin-bottom: 20px;
}
.conversation-filters {
display: grid;
grid-template-columns: minmax(170px, 1fr) minmax(190px, 1fr) minmax(270px, 1.3fr) auto;
gap: 16px;
:deep(.el-form-item) {
margin-bottom: 0;
min-width: 0;
}
:deep(.el-form-item__label) {
color: var(--el-text-color-regular);
}
:deep(.el-date-editor) {
width: 100%;
min-width: 0;
}
}
.filter-actions {
display: flex;
align-items: flex-end;
}
.filter-hint {
margin: 12px 0 0;
color: var(--el-text-color-regular);
font-size: 12px;
}
:deep(.el-button) {
gap: 5px;
}
.results-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 20px 24px;
h2 {
display: flex;
align-items: center;
gap: 10px;
font-size: 16px;
margin: 0;
font-weight: 600;
span {
padding: 2px 8px;
border-radius: 6px;
background: var(--el-fill-color);
font-size: 12px;
font-weight: 500;
}
}
> span {
font-size: 12px;
color: var(--el-text-color-regular);
}
}
.conversation-columns,
.conversation-row {
display: grid;
grid-template-columns: minmax(320px, 1.2fr) minmax(140px, 1fr) 70px 154px 104px;
align-items: center;
gap: 20px;
padding: 16px 24px;
}
.conversation-columns {
padding-top: 12px;
padding-bottom: 12px;
font-size: 12px;
color: var(--el-text-color-regular);
background: var(--el-fill-color-extra-light);
border-top: 1px solid var(--el-border-color-lighter);
border-bottom: 1px solid var(--el-border-color-lighter);
}
.conversation-list {
margin: 0;
padding: 0;
list-style: none;
}
.conversation-row {
min-height: 52px;
border-bottom: 1px solid var(--el-border-color-lighter);
transition: background-color 150ms;
&:hover {
background: var(--el-fill-color-extra-light);
}
&:last-child {
border-bottom: none;
}
}
.row-participants {
display: flex;
align-items: center;
min-width: 0;
gap: 12px;
> .conversation-user {
flex: 1;
}
}
.pair-divider {
flex-shrink: 0;
color: var(--el-text-color-regular);
transform: rotate(90deg);
}
.row-preview {
display: flex;
flex-direction: column;
min-width: 0;
gap: 6px;
}
.preview-sender,
.preview-content {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
.preview-sender {
color: var(--el-text-color-primary);
}
.preview-content {
color: var(--el-text-color-regular);
}
.row-count {
font-variant-numeric: tabular-nums;
font-size: 13px;
}
.row-time {
color: var(--el-text-color-regular);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.mobile-label {
display: none;
}
.list-pagination {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 16px;
padding: 16px 24px;
border-top: 1px solid var(--el-border-color-lighter);
> span {
color: var(--el-text-color-regular);
font-size: 12px;
}
}
.list-loading {
padding: 24px;
min-height: 320px;
}
.list-state {
display: flex;
min-height: 320px;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 12px;
color: var(--el-text-color-regular);
strong {
color: var(--el-text-color-primary);
}
}
@media (max-width: 1250px) {
.conversation-filters {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.filter-actions {
justify-content: flex-end;
}
.conversation-columns,
.conversation-row {
grid-template-columns: minmax(260px, 1.2fr) minmax(120px, 1fr) 56px 142px 100px;
gap: 12px;
padding-right: 16px;
padding-left: 16px;
}
}
@media (max-width: 1050px) {
.conversation-columns {
display: none;
}
.conversation-row {
grid-template-columns: 1fr auto;
gap: 12px;
padding: 20px 24px;
}
.row-participants {
grid-column: 1 / -1;
max-width: 560px;
}
.row-preview {
grid-column: 1 / -1;
}
.preview-sender {
display: none;
}
.row-count {
grid-column: 1;
font-size: 12px;
color: var(--el-text-color-regular);
}
.mobile-label {
display: inline;
}
.row-time {
grid-column: 1;
}
.view-records {
grid-column: 2;
grid-row: 3 / 5;
}
}
@media (max-width: 600px) {
.page-heading {
align-items: flex-start;
h1 {
font-size: 20px;
}
p {
max-width: 240px;
line-height: 1.6;
}
}
.filter-panel {
padding: 16px;
}
.conversation-filters {
grid-template-columns: 1fr;
gap: 12px;
}
.results-heading {
padding: 16px;
align-items: flex-start;
flex-direction: column;
}
.conversation-row {
padding: 16px;
}
.row-participants {
gap: 8px;
}
.list-pagination {
padding: 16px;
justify-content: center;
:deep(.el-pagination__sizes),
:deep(.el-pagination__jump) {
display: none;
}
}
}
@media (prefers-reduced-motion: reduce) {
.conversation-row {
transition: none;
}
:deep(.el-skeleton__item) {
animation: none;
}
}
</style>
+26 -12
View File
@@ -41,10 +41,16 @@ class="!w-160px" v-model="queryParams.center" placeholder="请输入发送内容
</el-table-column>
<el-table-column label="状态" align="center" prop="status">
<template #default="{ row }">
<dict-tag size="" :type="DICT_TYPE.CHAT_MSG_STATUS" :value="row.status" />
<el-tag v-if="row.status === 2" type="info">已撤回/屏蔽</el-tag>
<dict-tag v-else size="" :type="DICT_TYPE.CHAT_MSG_STATUS" :value="row.status" />
</template>
</el-table-column>
<el-table-column label="发送时间" align="center" prop="sendTime" :formatter="dateFormatter" width="180px" />
<el-table-column v-if="checkPermi([MESSAGE_MODERATE_PERMISSION])" label="操作" align="center" width="130" fixed="right">
<template #default="{ row }">
<MessageModerationActions :message="row" />
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination
@@ -54,15 +60,21 @@ class="!w-160px" v-model="queryParams.center" placeholder="请输入发送内容
</template>
<script setup lang="ts">
import { onBeforeUnmount, watch } from 'vue'
import { DICT_TYPE } from '@/utils/dict'
import { dateFormatter } from '@/utils/formatTime'
import * as privateMsgApi from '@/api/chat/private-msg'
import type { PrivateMessage } from '@/api/chat/private-msg'
import { checkPermi } from '@/utils/permission'
import MessageModerationActions from '../components/MessageModerationActions.vue'
import { MESSAGE_MODERATE_PERMISSION, lastModeratedMessageId, messageModerationRevision } from '../components/message-moderation'
import { sameUser } from '../list/chat-utils'
import MessageContent from './cmp/msg-content.vue'
const loading = ref(true)
const list = ref([])
const list = ref<PrivateMessage[]>([])
const total = ref(0)
const queryParams = reactive<any>({
@@ -71,25 +83,22 @@ const queryParams = reactive<any>({
})
const queryFormRef = ref()
let requestVersion = 0
/** 查询列表 */
const getList = async () => {
const version = ++requestVersion
loading.value = true
try {
const data = await privateMsgApi.page(queryParams)
list.value = data.list
total.value = data.total
if (version !== requestVersion) return
list.value = data.list || []
total.value = Number(data.total) || 0
} finally {
loading.value = false
if (version === requestVersion) loading.value = false
}
}
/** 添加/修改操作 */
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
@@ -105,4 +114,9 @@ const resetQuery = () => {
onMounted(() => {
getList()
})
</script>
watch(messageModerationRevision, () => {
list.value.forEach((message) => { if (sameUser(message.id, lastModeratedMessageId.value)) message.status = 2 })
getList().catch(() => {})
})
onBeforeUnmount(() => { ++requestVersion })
</script>