feat:聊天后台撤回消息
fix:聊天禁用bug
This commit is contained in:
1 parent
57a35f2f9d
commit
a9bac9d3bf
12 files changed
+2709
-17
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user