feat:聊天后台撤回消息
fix:聊天禁用bug
This commit is contained in:
1 parent
57a35f2f9d
commit
a9bac9d3bf
12 files changed
+2709
-17
No files matched your search
Generated
+1
-1
@@ -15678,4 +15678,4 @@
|
||||
"license": "0BSD"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,390 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const vue = require('vue')
|
||||
const { parse, compileTemplate } = require('vue/compiler-sfc')
|
||||
|
||||
const root = path.join(__dirname, '../src/views/chat/list')
|
||||
const plain = (value) => JSON.parse(JSON.stringify(value))
|
||||
const transpile = (source) =>
|
||||
ts.transpileModule(source, {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
|
||||
}).outputText
|
||||
const utilsContext = { exports: {}, URL, Intl, Date }
|
||||
vm.runInNewContext(
|
||||
transpile(fs.readFileSync(path.join(root, 'chat-utils.ts'), 'utf8')),
|
||||
utilsContext
|
||||
)
|
||||
const utils = utilsContext.exports
|
||||
function createModerationState() {
|
||||
const context = {
|
||||
exports: {},
|
||||
require: (name) => {
|
||||
if (name === 'vue') return vue
|
||||
throw new Error(name)
|
||||
}
|
||||
}
|
||||
vm.runInNewContext(
|
||||
transpile(fs.readFileSync(path.join(root, '../components/message-moderation.ts'), 'utf8')),
|
||||
context
|
||||
)
|
||||
return context.exports
|
||||
}
|
||||
const deferred = () => {
|
||||
let resolve, reject
|
||||
const promise = new Promise((yes, no) => {
|
||||
resolve = yes
|
||||
reject = no
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
const conversation = (first = 1, second = 2) => ({
|
||||
userA: { id: first, nickname: `用户${first}` },
|
||||
userB: { id: second, nickname: `用户${second}` },
|
||||
messageCount: 4,
|
||||
lastMessage: {
|
||||
id: 4,
|
||||
sendId: second,
|
||||
recvId: first,
|
||||
type: 0,
|
||||
status: 3,
|
||||
content: '你好',
|
||||
sendTime: 1793664000000
|
||||
}
|
||||
})
|
||||
|
||||
function createHarness(component, overrides = {}, moderation = createModerationState()) {
|
||||
const calls = []
|
||||
const lifecycle = {}
|
||||
const context = {
|
||||
exports: {},
|
||||
defineOptions() {},
|
||||
defineExpose(value) {
|
||||
context.exposed = value
|
||||
},
|
||||
document: { activeElement: null },
|
||||
HTMLElement: class {},
|
||||
require(name) {
|
||||
if (name === 'vue')
|
||||
return {
|
||||
...vue,
|
||||
onMounted(callback) {
|
||||
lifecycle.mounted = callback
|
||||
},
|
||||
onBeforeUnmount(callback) {
|
||||
lifecycle.unmount = callback
|
||||
},
|
||||
onDeactivated(callback) {
|
||||
lifecycle.deactivate = callback
|
||||
}
|
||||
}
|
||||
if (name === '@/api/chat/private-msg')
|
||||
return {
|
||||
async conversationPage(params) {
|
||||
calls.push(plain(params))
|
||||
return { list: [], total: 0 }
|
||||
},
|
||||
async page(params) {
|
||||
calls.push(plain(params))
|
||||
return { list: [], total: 0 }
|
||||
},
|
||||
...overrides
|
||||
}
|
||||
if (name.endsWith('chat-utils')) return utils
|
||||
if (name.endsWith('message-moderation')) return moderation
|
||||
if (name.endsWith('.vue')) return {}
|
||||
throw new Error(`Unexpected import ${name}`)
|
||||
}
|
||||
}
|
||||
const source = fs.readFileSync(path.join(root, component), 'utf8')
|
||||
const { descriptor } = parse(source)
|
||||
const exports =
|
||||
component === 'index.vue'
|
||||
? 'filters, applied, pagination, list, total, loading, error, getList, handleQuery, resetQuery, handleSizeChange, drawerRef, openConversation'
|
||||
: 'filters, applied, pagination, messages, total, loading, error, visible, getList, handleQuery, resetQuery, handleSizeChange, open, handleClose, restoreFocus, scrollRef, searchRef, focusSearch'
|
||||
const scope = vue.effectScope()
|
||||
scope.run(() =>
|
||||
vm.runInNewContext(
|
||||
transpile(descriptor.scriptSetup.content) + `\nglobalThis.state = { ${exports} };`,
|
||||
context
|
||||
)
|
||||
)
|
||||
const unmount = lifecycle.unmount
|
||||
lifecycle.unmount = () => {
|
||||
unmount?.()
|
||||
scope.stop()
|
||||
}
|
||||
return { state: context.state, exposed: context.exposed, calls, lifecycle, moderation }
|
||||
}
|
||||
|
||||
test('China date ranges include the entire final calendar day independently of machine timezone', () => {
|
||||
assert.deepEqual(plain(utils.toChinaDateRange(['2026-11-03', '2026-11-03'])), [
|
||||
Date.parse('2026-11-02T16:00:00.000Z'),
|
||||
Date.parse('2026-11-03T15:59:59.999Z')
|
||||
])
|
||||
assert.equal(utils.toChinaDateRange(null), undefined)
|
||||
assert.equal(utils.toChinaDateRange(['2026-11-04', '2026-11-03']), undefined)
|
||||
assert.equal(utils.formatChatTime('2026-11-03 08:01:02'), '2026-11-03 08:01:02')
|
||||
assert.equal(utils.formatChatTime('2026-11-03T00:01:02Z'), '2026-11-03 08:01:02')
|
||||
})
|
||||
|
||||
test('media payload failures and dangerous protocols have safe fallbacks', () => {
|
||||
for (const value of [undefined, null, '', '{broken', 'null', '[]', '1'])
|
||||
assert.equal(utils.parseMedia(value), null)
|
||||
for (const url of [
|
||||
'javascript:alert(1)',
|
||||
'data:text/html,hello',
|
||||
'file:///etc/passwd',
|
||||
'//evil.example/x',
|
||||
'https://user:pass@example.com/file',
|
||||
'https://x.example/\nfile',
|
||||
'\\evil.example\\x'
|
||||
]) {
|
||||
assert.equal(utils.safeResourceUrl(url, 'https://cdn.example/media/'), '')
|
||||
}
|
||||
assert.equal(
|
||||
utils.safeResourceUrl('a.png', 'https://cdn.example/media'),
|
||||
'https://cdn.example/media/a.png'
|
||||
)
|
||||
assert.equal(utils.safeResourceUrl('https://cdn.example/a.png'), 'https://cdn.example/a.png')
|
||||
assert.equal(utils.messagePreview({ type: 1, status: 2 }), '[已撤回/屏蔽] [图片]')
|
||||
assert.equal(utils.messagePreview({ type: 10, content: '1234', status: 0 }), '撤回了一条消息')
|
||||
assert.equal(
|
||||
utils.messagePreview({ type: 40, content: '通话时长 01:23', status: 3 }),
|
||||
'[语音通话] 通话时长 01:23'
|
||||
)
|
||||
})
|
||||
|
||||
test('text links preserve text, keep HTML escaped, and support uppercase HTTP', () => {
|
||||
const input = '<img onerror="alert(1)"> HTTP://example.com?a=1&b=2, www.example.com。'
|
||||
const parts = utils.messageTextParts(input)
|
||||
assert.equal(parts.map((part) => part.text).join(''), input)
|
||||
assert.deepEqual(plain(parts.filter((part) => part.href).map((part) => part.href)), [
|
||||
'http://example.com/?a=1&b=2',
|
||||
'https://www.example.com/'
|
||||
])
|
||||
assert.equal(utils.escapeMessageText('<script>"&\''), '<script>"&'')
|
||||
assert.ok(utils.messageTextParts('javascript:alert(1)').every((part) => !part.href))
|
||||
})
|
||||
|
||||
test('conversation search resets pagination and uses applied filters for subsequent page requests', async () => {
|
||||
const { state, calls } = createHarness('index.vue')
|
||||
state.pagination.pageNum = 4
|
||||
state.filters.userKeyword = ' 张三 '
|
||||
state.filters.peerKeyword = ' 李四 '
|
||||
state.filters.dates = ['2026-11-01', '2026-11-03']
|
||||
await state.handleQuery()
|
||||
assert.equal(calls[0].pageNum, 1)
|
||||
assert.equal(calls[0].pageSize, 20)
|
||||
assert.equal(calls[0].userKeyword, '张三')
|
||||
assert.equal(calls[0].peerKeyword, '李四')
|
||||
assert.equal(calls[0].lastSendTimeList[1], Date.parse('2026-11-03T23:59:59.999+08:00'))
|
||||
state.filters.userKeyword = '尚未提交的筛选'
|
||||
state.pagination.pageNum = 2
|
||||
await state.getList()
|
||||
assert.equal(calls[1].userKeyword, '张三')
|
||||
await state.resetQuery()
|
||||
assert.deepEqual(calls.at(-1), { pageNum: 1, pageSize: 20 })
|
||||
})
|
||||
|
||||
test('late conversation responses cannot replace a newer search or update an unmounted page', async () => {
|
||||
const first = deferred(),
|
||||
second = deferred(),
|
||||
third = deferred()
|
||||
const pending = [first, second, third]
|
||||
const { state, lifecycle } = createHarness('index.vue', {
|
||||
conversationPage: () => pending.shift().promise
|
||||
})
|
||||
const oldRequest = state.getList()
|
||||
const newRequest = state.handleQuery()
|
||||
second.resolve({ list: [conversation(3, 4)], total: 1 })
|
||||
await newRequest
|
||||
first.resolve({ list: [conversation(1, 2)], total: 99 })
|
||||
await oldRequest
|
||||
assert.equal(state.list.value[0].userA.id, 3)
|
||||
assert.equal(state.total.value, 1)
|
||||
const disposedRequest = state.getList()
|
||||
lifecycle.unmount()
|
||||
third.resolve({ list: [conversation(5, 6)], total: 100 })
|
||||
await disposedRequest
|
||||
assert.equal(state.list.value[0].userA.id, 3)
|
||||
})
|
||||
|
||||
test('drawer opens bilateral history with independent filters and reverses each latest-first page', async () => {
|
||||
const calls = []
|
||||
const { state } = createHarness('components/ConversationDrawer.vue', {
|
||||
async page(params) {
|
||||
calls.push(plain(params))
|
||||
return { list: [{ id: 3 }, { id: 2 }, { id: 1 }], total: 3 }
|
||||
}
|
||||
})
|
||||
state.filters.content = '旧会话筛选'
|
||||
state.filters.dates = ['2026-10-01', '2026-10-02']
|
||||
await state.open(conversation(11, 22))
|
||||
assert.deepEqual(calls[0], { pageNum: 1, pageSize: 20, sendId: 11, recvId: 22 })
|
||||
assert.deepEqual(plain(state.messages.value.map((message) => message.id)), [1, 2, 3])
|
||||
state.pagination.pageNum = 7
|
||||
state.filters.content = ' 内容 '
|
||||
state.filters.dates = ['2026-11-03', '2026-11-03']
|
||||
await state.handleQuery()
|
||||
assert.equal(calls[1].pageNum, 1)
|
||||
assert.equal(calls[1].content, '内容')
|
||||
assert.equal(calls[1].sendTimeList[1], Date.parse('2026-11-03T23:59:59.999+08:00'))
|
||||
await state.resetQuery()
|
||||
assert.deepEqual(calls[2], { pageNum: 1, pageSize: 20, sendId: 11, recvId: 22 })
|
||||
})
|
||||
|
||||
test('switching or closing a drawer invalidates older requests and restores its trigger focus', async () => {
|
||||
const first = deferred(),
|
||||
second = deferred(),
|
||||
third = deferred()
|
||||
const pending = [first, second, third]
|
||||
const { state } = createHarness('components/ConversationDrawer.vue', {
|
||||
page: () => pending.shift().promise
|
||||
})
|
||||
let focused = 0
|
||||
const trigger = {
|
||||
isConnected: true,
|
||||
focus() {
|
||||
focused++
|
||||
}
|
||||
}
|
||||
const oldRequest = state.open(conversation(1, 2), trigger)
|
||||
const newRequest = state.open(conversation(3, 4), trigger)
|
||||
second.resolve({ list: [{ id: 33 }], total: 1 })
|
||||
await newRequest
|
||||
first.resolve({ list: [{ id: 11 }], total: 10 })
|
||||
await oldRequest
|
||||
assert.equal(state.messages.value[0].id, 33)
|
||||
const closing = state.getList()
|
||||
state.visible.value = false
|
||||
state.handleClose()
|
||||
third.resolve({ list: [{ id: 99 }], total: 100 })
|
||||
await closing
|
||||
assert.equal(state.messages.value.length, 0)
|
||||
assert.equal(state.loading.value, false)
|
||||
state.restoreFocus()
|
||||
assert.equal(focused, 1)
|
||||
})
|
||||
|
||||
test('failed history stays retryable and empty history is a successful result', async () => {
|
||||
let attempt = 0
|
||||
const { state } = createHarness('components/ConversationDrawer.vue', {
|
||||
async page() {
|
||||
if (!attempt++) throw new Error('offline')
|
||||
return { list: null, total: 0 }
|
||||
}
|
||||
})
|
||||
await state.open(conversation())
|
||||
assert.equal(state.error.value, true)
|
||||
assert.equal(state.loading.value, false)
|
||||
await state.getList()
|
||||
assert.equal(state.error.value, false)
|
||||
assert.equal(state.messages.value.length, 0)
|
||||
})
|
||||
|
||||
test('drawer focuses search without an event payload and closes on keep-alive deactivation', async () => {
|
||||
const { state, lifecycle } = createHarness('components/ConversationDrawer.vue')
|
||||
let focused = 0
|
||||
state.searchRef.value = {
|
||||
focus() {
|
||||
focused++
|
||||
}
|
||||
}
|
||||
await state.focusSearch()
|
||||
assert.equal(focused, 1)
|
||||
await state.open(conversation())
|
||||
lifecycle.deactivate()
|
||||
assert.equal(state.visible.value, false)
|
||||
assert.equal(state.messages.value.length, 0)
|
||||
})
|
||||
|
||||
test('refreshing a page removed by data changes returns to the last valid page', async () => {
|
||||
const listRequests = []
|
||||
const list = createHarness('index.vue', {
|
||||
async conversationPage(params) {
|
||||
listRequests.push(params.pageNum)
|
||||
return { list: params.pageNum === 1 ? [conversation()] : [], total: 1 }
|
||||
}
|
||||
})
|
||||
list.state.pagination.pageNum = 3
|
||||
await list.state.getList()
|
||||
assert.deepEqual(listRequests, [3, 1])
|
||||
assert.equal(list.state.list.value.length, 1)
|
||||
const detailRequests = []
|
||||
const detail = createHarness('components/ConversationDrawer.vue', {
|
||||
async page(params) {
|
||||
detailRequests.push(params.pageNum)
|
||||
return { list: params.pageNum === 1 ? [{ id: 1 }] : [], total: 1 }
|
||||
}
|
||||
})
|
||||
await detail.state.open(conversation())
|
||||
detail.state.pagination.pageNum = 5
|
||||
await detail.state.getList()
|
||||
assert.deepEqual(detailRequests, [1, 5, 1])
|
||||
assert.equal(detail.state.messages.value.length, 1)
|
||||
})
|
||||
|
||||
test('a moderation success refreshes list and history while preserving detail filters and reading position', async () => {
|
||||
const shared = createModerationState()
|
||||
let hidden = false,
|
||||
listReads = 0
|
||||
const detailQueries = []
|
||||
const list = createHarness(
|
||||
'index.vue',
|
||||
{
|
||||
async conversationPage() {
|
||||
listReads++
|
||||
const row = conversation()
|
||||
row.lastMessage.status = hidden ? 2 : 3
|
||||
return { list: [row], total: 1 }
|
||||
}
|
||||
},
|
||||
shared
|
||||
)
|
||||
const drawer = createHarness(
|
||||
'components/ConversationDrawer.vue',
|
||||
{
|
||||
async page(params) {
|
||||
detailQueries.push(plain(params))
|
||||
return { list: [{ ...conversation().lastMessage, status: hidden ? 2 : 3 }], total: 100 }
|
||||
}
|
||||
},
|
||||
shared
|
||||
)
|
||||
await list.state.getList()
|
||||
await drawer.state.open(conversation())
|
||||
drawer.state.filters.content = '待保留的条件'
|
||||
await drawer.state.handleQuery()
|
||||
drawer.state.pagination.pageNum = 2
|
||||
drawer.state.scrollRef.value = { scrollTop: 480, scrollHeight: 4000 }
|
||||
hidden = true
|
||||
shared.notifyMessageModerated(4)
|
||||
for (let index = 0; index < 6; index++) await vue.nextTick()
|
||||
assert.equal(listReads, 2)
|
||||
assert.equal(list.state.list.value[0].lastMessage.status, 2)
|
||||
assert.equal(detailQueries.at(-1).content, '待保留的条件')
|
||||
assert.equal(detailQueries.at(-1).pageNum, 2)
|
||||
assert.equal(drawer.state.scrollRef.value.scrollTop, 480)
|
||||
assert.equal(drawer.state.messages.value[0].status, 2)
|
||||
list.lifecycle.unmount()
|
||||
drawer.lifecycle.unmount()
|
||||
})
|
||||
|
||||
test('all conversation Vue templates compile', () => {
|
||||
for (const file of [
|
||||
'index.vue',
|
||||
'components/ConversationDrawer.vue',
|
||||
'components/ConversationMessage.vue',
|
||||
'components/ConversationUser.vue'
|
||||
]) {
|
||||
const filename = path.join(root, file)
|
||||
const { descriptor, errors } = parse(fs.readFileSync(filename, 'utf8'), { filename })
|
||||
assert.deepEqual(errors, [], file)
|
||||
const result = compileTemplate({ source: descriptor.template.content, filename, id: file })
|
||||
assert.deepEqual(result.errors, [], file)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,375 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const vue = require('vue')
|
||||
const { parse, compileTemplate } = require('vue/compiler-sfc')
|
||||
|
||||
const root = path.join(__dirname, '../src/views/chat')
|
||||
const plain = (value) => JSON.parse(JSON.stringify(value))
|
||||
const transpile = (source) =>
|
||||
ts.transpileModule(source, {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
|
||||
}).outputText
|
||||
function loadModule(
|
||||
file,
|
||||
require = (name) => {
|
||||
if (name === 'vue') return vue
|
||||
throw new Error(name)
|
||||
}
|
||||
) {
|
||||
const context = { exports: {}, require, URL, Intl, Date }
|
||||
vm.runInNewContext(transpile(fs.readFileSync(path.join(root, file), 'utf8')), context)
|
||||
return context.exports
|
||||
}
|
||||
const utils = loadModule('list/chat-utils.ts')
|
||||
const stateModule = () => loadModule('components/message-moderation.ts')
|
||||
const message = (id = 100, overrides = {}) => ({
|
||||
id,
|
||||
sendId: 1,
|
||||
recvId: 2,
|
||||
content: '一条聊天消息',
|
||||
type: 0,
|
||||
status: 3,
|
||||
sendTime: '2026-11-03 14:20:00',
|
||||
...overrides
|
||||
})
|
||||
const success = (id = 100, overrides = {}) => ({
|
||||
messageId: id,
|
||||
status: 2,
|
||||
recallMessageId: 101,
|
||||
alreadyHidden: false,
|
||||
notificationSent: true,
|
||||
...overrides
|
||||
})
|
||||
const deferred = () => {
|
||||
let resolve, reject
|
||||
const promise = new Promise((yes, no) => {
|
||||
resolve = yes
|
||||
reject = no
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function harness(initialMessage = message(), options = {}) {
|
||||
const shared = options.shared || stateModule()
|
||||
const permission = vue.ref(options.permitted !== false)
|
||||
const props = vue.reactive({ message: initialMessage, senderName: '张三', receiverName: '李四' })
|
||||
const calls = [],
|
||||
notices = [],
|
||||
lifecycle = {}
|
||||
const context = {
|
||||
exports: {},
|
||||
defineProps: () => props,
|
||||
require(name) {
|
||||
if (name === 'vue')
|
||||
return {
|
||||
...vue,
|
||||
onBeforeUnmount: (fn) => {
|
||||
lifecycle.unmount = fn
|
||||
},
|
||||
onDeactivated: (fn) => {
|
||||
lifecycle.deactivate = fn
|
||||
}
|
||||
}
|
||||
if (name === 'element-plus')
|
||||
return {
|
||||
ElMessage: {
|
||||
success: (text) => notices.push(['success', text]),
|
||||
warning: (text) => notices.push(['warning', text])
|
||||
}
|
||||
}
|
||||
if (name === '@/utils/permission') return { checkPermi: () => permission.value }
|
||||
if (name === '@/api/chat/private-msg')
|
||||
return {
|
||||
moderateMessage: async (params) => {
|
||||
calls.push(plain(params))
|
||||
return options.api ? options.api(params) : success(params.messageId)
|
||||
}
|
||||
}
|
||||
if (name.endsWith('chat-utils')) return utils
|
||||
if (name.endsWith('message-moderation')) return shared
|
||||
throw new Error(name)
|
||||
}
|
||||
}
|
||||
const source = fs.readFileSync(path.join(root, 'components/MessageModerationActions.vue'), 'utf8')
|
||||
const { descriptor } = parse(source)
|
||||
const scope = vue.effectScope()
|
||||
scope.run(() =>
|
||||
vm.runInNewContext(
|
||||
transpile(descriptor.scriptSetup.content) +
|
||||
'\nglobalThis.state = { open, submit, handleClosed, beforeClose, visible, submitting, reason, errorMessage, target, permitted, busy, disabledReason, isHidden };',
|
||||
context
|
||||
)
|
||||
)
|
||||
const unmount = lifecycle.unmount
|
||||
lifecycle.unmount = () => {
|
||||
unmount?.()
|
||||
scope.stop()
|
||||
}
|
||||
return { state: context.state, calls, notices, props, permission, lifecycle, shared }
|
||||
}
|
||||
|
||||
test('number message IDs must be safe integers while string IDs retain full precision', () => {
|
||||
const shared = stateModule()
|
||||
for (const id of [Number.MAX_SAFE_INTEGER + 1, NaN, Infinity, 1.5, 0, -1, '', '0', '1e4', '12x'])
|
||||
assert.equal(shared.messageIdentity(id), undefined)
|
||||
assert.deepEqual(plain(shared.messageIdentity('9223372036854775807')), {
|
||||
messageId: '9223372036854775807',
|
||||
key: '9223372036854775807'
|
||||
})
|
||||
assert.deepEqual(plain(shared.messageIdentity('000123')), { messageId: '000123', key: '123' })
|
||||
const current = harness(message(Number.MAX_SAFE_INTEGER + 1))
|
||||
current.state.open()
|
||||
assert.equal(current.state.visible.value, false)
|
||||
assert.match(current.notices[0][1], /刷新/)
|
||||
assert.equal(current.calls.length, 0)
|
||||
current.lifecycle.unmount()
|
||||
})
|
||||
|
||||
test('normal and call messages are eligible; control records and missing permissions cannot open an action', () => {
|
||||
const shared = stateModule()
|
||||
for (const type of [0, 1, 2, 3, 4, 9, 40, 41])
|
||||
assert.equal(shared.canModerateMessageType(type), true)
|
||||
for (const type of [-1, 10, 11, 21, 30, 100, 200])
|
||||
assert.equal(shared.canModerateMessageType(type), false)
|
||||
const control = harness(message(100, { type: 10 }))
|
||||
control.state.open()
|
||||
assert.equal(control.state.visible.value, false)
|
||||
const denied = harness(message(), { permitted: false })
|
||||
denied.state.open()
|
||||
assert.equal(denied.state.visible.value, false)
|
||||
assert.equal(denied.calls.length, 0)
|
||||
control.lifecycle.unmount()
|
||||
denied.lifecycle.unmount()
|
||||
})
|
||||
|
||||
test('the same message across two views shares one confirmation and submitting lock', async () => {
|
||||
const shared = stateModule(),
|
||||
pending = deferred()
|
||||
const first = harness(message(100), { shared, api: () => pending.promise })
|
||||
const second = harness(message('100'), { shared })
|
||||
first.state.open()
|
||||
second.state.open()
|
||||
assert.equal(second.state.visible.value, false)
|
||||
assert.equal(second.state.busy.value, true)
|
||||
const request = first.state.submit()
|
||||
await first.state.submit()
|
||||
assert.equal(first.calls.length, 1)
|
||||
let closed = false
|
||||
first.state.beforeClose(() => {
|
||||
closed = true
|
||||
})
|
||||
assert.equal(closed, false)
|
||||
pending.resolve(success())
|
||||
await request
|
||||
first.state.handleClosed()
|
||||
assert.equal(second.state.busy.value, false)
|
||||
first.lifecycle.unmount()
|
||||
second.lifecycle.unmount()
|
||||
})
|
||||
|
||||
test('cancel releases the lock without sending a request or modifying shared revision', () => {
|
||||
const current = harness()
|
||||
current.state.open()
|
||||
current.state.visible.value = false
|
||||
current.state.handleClosed()
|
||||
assert.equal(current.calls.length, 0)
|
||||
assert.equal(current.shared.messageModerationRevision.value, 0)
|
||||
assert.equal(current.shared.isMessageModerationBusy('100'), false)
|
||||
current.lifecycle.unmount()
|
||||
})
|
||||
|
||||
test('confirmation snapshots the original message, preserves string IDs, and trims the optional reason', async () => {
|
||||
const id = '9223372036854775807'
|
||||
const current = harness(message(id), { api: () => success(Number(id)) })
|
||||
current.state.open()
|
||||
current.props.message = message(200)
|
||||
current.state.reason.value = ' 违反聊天规范 '
|
||||
await current.state.submit()
|
||||
assert.deepEqual(current.calls[0], { messageId: id, reason: '违反聊天规范' })
|
||||
assert.equal(current.shared.lastModeratedMessageId.value, id)
|
||||
assert.equal(current.shared.messageModerationRevision.value, 1)
|
||||
current.lifecycle.unmount()
|
||||
})
|
||||
|
||||
test('reason length and permission changes are checked again before submission', async () => {
|
||||
const current = harness()
|
||||
current.state.open()
|
||||
current.state.reason.value = '字'.repeat(201)
|
||||
await current.state.submit()
|
||||
assert.equal(current.calls.length, 0)
|
||||
assert.match(current.state.errorMessage.value, /200/)
|
||||
current.state.reason.value = '有效原因'
|
||||
current.permission.value = false
|
||||
await current.state.submit()
|
||||
await vue.nextTick()
|
||||
assert.equal(current.calls.length, 0)
|
||||
assert.equal(current.shared.isMessageModerationBusy('100'), false)
|
||||
current.lifecycle.unmount()
|
||||
})
|
||||
|
||||
test('a failed request keeps the dialog and reason available for retry', async () => {
|
||||
let attempt = 0
|
||||
const current = harness(message(), {
|
||||
api: () => {
|
||||
if (!attempt++) throw new Error('offline')
|
||||
return success()
|
||||
}
|
||||
})
|
||||
current.state.open()
|
||||
current.state.reason.value = '保留原因'
|
||||
await current.state.submit()
|
||||
assert.equal(current.state.visible.value, true)
|
||||
assert.equal(current.state.submitting.value, false)
|
||||
assert.equal(current.state.reason.value, '保留原因')
|
||||
assert.match(current.state.errorMessage.value, /失败/)
|
||||
assert.equal(current.shared.messageModerationRevision.value, 0)
|
||||
await current.state.submit()
|
||||
assert.equal(current.calls.length, 2)
|
||||
assert.equal(current.state.visible.value, false)
|
||||
current.lifecycle.unmount()
|
||||
})
|
||||
|
||||
test('already-hidden messages retry synchronization without a new moderation reason', async () => {
|
||||
const current = harness(message(100, { status: 2 }), {
|
||||
api: () => success(100, { alreadyHidden: true })
|
||||
})
|
||||
assert.equal(current.state.isHidden.value, true)
|
||||
current.state.open()
|
||||
current.state.reason.value = '不应再提交的原因'
|
||||
await current.state.submit()
|
||||
assert.deepEqual(current.calls[0], { messageId: 100 })
|
||||
assert.equal(current.notices[0][1], '已发送撤回同步通知')
|
||||
current.lifecycle.unmount()
|
||||
})
|
||||
|
||||
test('failed push notification still refreshes hidden status and offers resynchronization', async () => {
|
||||
const current = harness(message(), { api: () => success(100, { notificationSent: false }) })
|
||||
current.state.open()
|
||||
await current.state.submit()
|
||||
assert.equal(current.shared.messageModerationRevision.value, 1)
|
||||
assert.equal(current.notices[0][0], 'warning')
|
||||
assert.match(current.notices[0][1], /重新同步/)
|
||||
assert.equal(current.state.visible.value, false)
|
||||
current.lifecycle.unmount()
|
||||
})
|
||||
|
||||
test('unmounting during a request keeps its lock until completion and still notifies other views', async () => {
|
||||
const pending = deferred(),
|
||||
current = harness(message(), { api: () => pending.promise })
|
||||
current.state.open()
|
||||
const request = current.state.submit()
|
||||
current.lifecycle.unmount()
|
||||
assert.equal(current.shared.isMessageModerationBusy('100'), true)
|
||||
pending.resolve(success())
|
||||
await request
|
||||
assert.equal(current.shared.isMessageModerationBusy('100'), false)
|
||||
assert.equal(current.shared.messageModerationRevision.value, 1)
|
||||
})
|
||||
|
||||
test('deactivation and permission changes cannot release an in-flight lock through dialog closed', async () => {
|
||||
for (const lifecycleChange of ['deactivate', 'permission']) {
|
||||
const pending = deferred()
|
||||
const current = harness(message(), { api: () => pending.promise })
|
||||
current.state.open()
|
||||
const request = current.state.submit()
|
||||
if (lifecycleChange === 'deactivate') current.lifecycle.deactivate()
|
||||
else {
|
||||
current.permission.value = false
|
||||
await vue.nextTick()
|
||||
}
|
||||
current.state.handleClosed()
|
||||
assert.equal(current.shared.isMessageModerationBusy('100'), true, lifecycleChange)
|
||||
assert.equal(current.state.target.value.messageId, 100)
|
||||
pending.resolve(success())
|
||||
await request
|
||||
assert.equal(current.shared.isMessageModerationBusy('100'), false, lifecycleChange)
|
||||
assert.equal(current.state.target.value, undefined)
|
||||
current.lifecycle.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('moderation and the legacy table templates compile with text-only confirmation context', () => {
|
||||
for (const file of ['components/MessageModerationActions.vue', 'private-msg/index.vue']) {
|
||||
const filename = path.join(root, file)
|
||||
const { descriptor, errors } = parse(fs.readFileSync(filename, 'utf8'), { filename })
|
||||
assert.deepEqual(errors, [])
|
||||
const result = compileTemplate({ source: descriptor.template.content, filename, id: file })
|
||||
assert.deepEqual(result.errors, [])
|
||||
if (file.includes('Actions')) assert.ok(!descriptor.template.content.includes('v-html'))
|
||||
}
|
||||
})
|
||||
|
||||
test('the legacy table refreshes the current query after moderation and ignores superseded reads', async () => {
|
||||
const shared = stateModule(),
|
||||
calls = [],
|
||||
pending = []
|
||||
let hidden = false,
|
||||
unmount
|
||||
const context = {
|
||||
exports: {},
|
||||
ref: vue.ref,
|
||||
reactive: vue.reactive,
|
||||
onMounted() {},
|
||||
require(name) {
|
||||
if (name === 'vue')
|
||||
return {
|
||||
...vue,
|
||||
onBeforeUnmount(fn) {
|
||||
unmount = fn
|
||||
}
|
||||
}
|
||||
if (name === '@/utils/dict') return { DICT_TYPE: {} }
|
||||
if (name === '@/utils/formatTime') return { dateFormatter() {} }
|
||||
if (name === '@/utils/permission') return { checkPermi: () => true }
|
||||
if (name === '@/api/chat/private-msg')
|
||||
return {
|
||||
async page(params) {
|
||||
calls.push(plain(params))
|
||||
return pending.length
|
||||
? pending.shift().promise
|
||||
: { list: [message(100, { status: hidden ? 2 : 3 })], total: 30 }
|
||||
}
|
||||
}
|
||||
if (name.endsWith('message-moderation')) return shared
|
||||
if (name.endsWith('chat-utils')) return utils
|
||||
if (name.endsWith('.vue')) return {}
|
||||
throw new Error(name)
|
||||
}
|
||||
}
|
||||
const { descriptor } = parse(fs.readFileSync(path.join(root, 'private-msg/index.vue'), 'utf8'))
|
||||
const scope = vue.effectScope()
|
||||
scope.run(() =>
|
||||
vm.runInNewContext(
|
||||
transpile(descriptor.scriptSetup.content) +
|
||||
'\nglobalThis.state = { getList, list, queryParams };',
|
||||
context
|
||||
)
|
||||
)
|
||||
const state = context.state
|
||||
state.queryParams.pageNum = 2
|
||||
state.queryParams.sendUserName = '张三'
|
||||
await state.getList()
|
||||
hidden = true
|
||||
shared.notifyMessageModerated(100)
|
||||
for (let index = 0; index < 5; index++) await vue.nextTick()
|
||||
assert.equal(calls.length, 2)
|
||||
assert.equal(calls[1].pageNum, 2)
|
||||
assert.equal(calls[1].sendUserName, '张三')
|
||||
assert.equal(state.list.value[0].status, 2)
|
||||
const first = deferred(),
|
||||
second = deferred()
|
||||
pending.push(first, second)
|
||||
const oldRequest = state.getList(),
|
||||
newRequest = state.getList()
|
||||
second.resolve({ list: [message(100, { status: 2 })], total: 30 })
|
||||
await newRequest
|
||||
first.resolve({ list: [message(100, { status: 3 })], total: 30 })
|
||||
await oldRequest
|
||||
assert.equal(state.list.value[0].status, 2)
|
||||
unmount()
|
||||
scope.stop()
|
||||
})
|
||||
Reference in new issue
Block a user