From a9bac9d3bf1c6bba37e1ae8bfb389a8498ec51a1 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Tue, 3 Nov 2026 19:09:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E8=81=8A=E5=A4=A9=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E6=92=A4=E5=9B=9E=E6=B6=88=E6=81=AF=20fix:=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E7=A6=81=E7=94=A8bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 2 +- src/api/chat/private-msg.ts | 79 ++- .../components/MessageModerationActions.vue | 307 +++++++++ .../chat/components/message-moderation.ts | 41 ++ src/views/chat/list/chat-utils.ts | 124 ++++ .../list/components/ConversationDrawer.vue | 581 ++++++++++++++++++ .../list/components/ConversationMessage.vue | 209 +++++++ .../chat/list/components/ConversationUser.vue | 67 ++ src/views/chat/list/index.vue | 513 ++++++++++++++++ src/views/chat/private-msg/index.vue | 38 +- tests/chat-conversation.test.cjs | 390 ++++++++++++ tests/chat-message-moderation.test.cjs | 375 +++++++++++ 12 files changed, 2709 insertions(+), 17 deletions(-) create mode 100644 src/views/chat/components/MessageModerationActions.vue create mode 100644 src/views/chat/components/message-moderation.ts create mode 100644 src/views/chat/list/chat-utils.ts create mode 100644 src/views/chat/list/components/ConversationDrawer.vue create mode 100644 src/views/chat/list/components/ConversationMessage.vue create mode 100644 src/views/chat/list/components/ConversationUser.vue create mode 100644 src/views/chat/list/index.vue create mode 100644 tests/chat-conversation.test.cjs create mode 100644 tests/chat-message-moderation.test.cjs diff --git a/package-lock.json b/package-lock.json index 9719f40b..72662f86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15678,4 +15678,4 @@ "license": "0BSD" } } -} +} \ No newline at end of file diff --git a/src/api/chat/private-msg.ts b/src/api/chat/private-msg.ts index 7ce7c67f..4ce69e7d 100644 --- a/src/api/chat/private-msg.ts +++ b/src/api/chat/private-msg.ts @@ -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 { + 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({ url: '/chat/private-msg/moderate', data }) + +export const conversationPage = (data: ConversationPageRequest) => + request.post>({ + url: '/chat/private-msg/conversation/page', + data + }) + // 查询私聊消息列表 -export const page = async (data: any) => { - return await request.post({ +export const page = async (data: any) => { + return await request.post({ 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 }) } - diff --git a/src/views/chat/components/MessageModerationActions.vue b/src/views/chat/components/MessageModerationActions.vue new file mode 100644 index 00000000..9f7358b3 --- /dev/null +++ b/src/views/chat/components/MessageModerationActions.vue @@ -0,0 +1,307 @@ + + + + + + diff --git a/src/views/chat/components/message-moderation.ts b/src/views/chat/components/message-moderation.ts new file mode 100644 index 00000000..2bc5beb2 --- /dev/null +++ b/src/views/chat/components/message-moderation.ts @@ -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()) +export const messageModerationRevision = ref(0) +export const lastModeratedMessageId = ref() + +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) +} diff --git a/src/views/chat/list/chat-utils.ts b/src/views/chat/list/chat-utils.ts new file mode 100644 index 00000000..707ba21d --- /dev/null +++ b/src/views/chat/list/chat-utils.ts @@ -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 | 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 = { + 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] || '未知状态' diff --git a/src/views/chat/list/components/ConversationDrawer.vue b/src/views/chat/list/components/ConversationDrawer.vue new file mode 100644 index 00000000..d49991f8 --- /dev/null +++ b/src/views/chat/list/components/ConversationDrawer.vue @@ -0,0 +1,581 @@ + + + + + diff --git a/src/views/chat/list/components/ConversationMessage.vue b/src/views/chat/list/components/ConversationMessage.vue new file mode 100644 index 00000000..eea16c05 --- /dev/null +++ b/src/views/chat/list/components/ConversationMessage.vue @@ -0,0 +1,209 @@ + + + + + diff --git a/src/views/chat/list/components/ConversationUser.vue b/src/views/chat/list/components/ConversationUser.vue new file mode 100644 index 00000000..fb121d32 --- /dev/null +++ b/src/views/chat/list/components/ConversationUser.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/src/views/chat/list/index.vue b/src/views/chat/list/index.vue new file mode 100644 index 00000000..4bd954cb --- /dev/null +++ b/src/views/chat/list/index.vue @@ -0,0 +1,513 @@ + + + + + diff --git a/src/views/chat/private-msg/index.vue b/src/views/chat/private-msg/index.vue index 5ae44ce2..dc011cf9 100644 --- a/src/views/chat/private-msg/index.vue +++ b/src/views/chat/private-msg/index.vue @@ -41,10 +41,16 @@ class="!w-160px" v-model="queryParams.center" placeholder="请输入发送内容 + + + +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([]) const total = ref(0) const queryParams = reactive({ @@ -71,25 +83,22 @@ const queryParams = reactive({ }) 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() }) - \ No newline at end of file +watch(messageModerationRevision, () => { + list.value.forEach((message) => { if (sameUser(message.id, lastModeratedMessageId.value)) message.status = 2 }) + getList().catch(() => {}) +}) +onBeforeUnmount(() => { ++requestVersion }) + diff --git a/tests/chat-conversation.test.cjs b/tests/chat-conversation.test.cjs new file mode 100644 index 00000000..50e6e798 --- /dev/null +++ b/tests/chat-conversation.test.cjs @@ -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 = ' 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('