feat:聊天后台撤回消息

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

No files matched your search

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